What's new in App Intents(二) 实现App Intents

895 阅读7分钟

实体定义

创建一个 TrailEntity 代表路径信息,并确保它可以被快捷方式和 Siri 交互使用。

import Foundation
import SwiftUI
import AppIntents

struct TrailEntity: AppEntity {

    static var typeDisplayRepresentation: TypeDisplayRepresentation {
      TypeDisplayRepresentation(
        name: LocalizedStringResource("TypeDisplay路径", table: "AppIntents"),
        numericFormat: LocalizedStringResource("(placeholder: .int) TypeDisplay路径", table: "AppIntents")
      )
    }
    static var defaultQuery = TrailEntityQuery()

    var id: String
    
    @Property(title: "Trail Name")
    var name: String
    
    var trailStyle: TrailsStyle

    // 实现显示名称,供快捷方式界面展示
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "(name)")
    }
    

    // 实体查询器,提供供选择的路径列表
    struct TrailEntityQuery: EntityQuery {
        func entities(for identifiers: [String]) -> [TrailEntity] {
            trails.filter { identifiers.contains($0.id) }
        }

        func suggestedEntities() -> [TrailEntity] {
            // 返回在提供此查询支持的选项列表时显示的初始结果。
            return trails
        }
    }
}

// 模拟路径数据
let trails = [
    TrailEntity(id: "1", name: "爬山路径", trailStyle: .mountaineering),
    TrailEntity(id: "2", name: "海滩路径", trailStyle: .beach),
    TrailEntity(id: "3", name: "骑行路径", trailStyle: .biking)
]

AppEntity 是 App Intents 框架中的一个协议,用于在快捷方式和 Siri 中定义和表示应用的模型数据,每个快捷方式实体都需要遵循AppEntity协议。如果你愿意,你还可以遵循IndexedEntity协议,它将帮助你将对象捐赠给 App Intents 系统,以便您可以执行搜索等活动。

static var defaultQuery = TrailEntityQuery()

defaultQuery属性为系统提供了查询TrailEntity结构所需的接口。

@Property(title: "Trail Name")

可以通过定义@Property属性包装器公开属性给系统的。方便用户在交互此参数时清楚具体含义

static var typeDisplayRepresentation: TypeDisplayRepresentation {
      TypeDisplayRepresentation(
        name: LocalizedStringResource("TypeDisplay路径", table: "AppIntents"),
        numericFormat: LocalizedStringResource("(placeholder: .int) TypeDisplay路径", table: "AppIntents")
      )
    }

typeDisplayRepresentation用来描述当前实体的类型,它主要用于说明该类型的本质,通常在 Siri、Shortcuts 等场景中显示(在使用该实体时,会展示,参见下方Transferable协议的使用

// 实现显示名称,供快捷方式界面展示
var displayRepresentation: DisplayRepresentation {
//        DisplayRepresentation(title: "(name)")
    TrailsStyle.caseDisplayRepresentations[trailStyle] ?? "Unknown Trail Style"
}

displayRepresentation可以提供有关如何向人们显示实体的信息。

struct TrailEntityQuery: EntityQuery

定义了一个名为 TrailEntityQuery 的结构体,并声明它遵循 EntityQuery 协议。

func entities(for identifiers: [String]) -> [TrailEntity] {
    trails.filter { identifiers.contains($0.id) }
}

返回与提供的标识符匹配的 TrailEntity 实体。通过对 trails 数组进行过滤,只有那些 IDidentifiers 数组中的实体会被返回。

func suggestedEntities() -> [TrailEntity] {
    // 返回在提供此查询支持的选项列表时显示的初始结果。
    return trails
}

这里返回整个 trails 数组,意味着所有的 TrailEntity 都会被作为建议选项提供。这可以帮助用户快速选择一个路径。

定义 Intent

使用 AppIntent 创建一个自定义的 Intent。

import Foundation
import AppIntents

// 定义一个 AppIntent,用于选择并打开路径
struct OpenTrailIntent: AppIntent {
    static var title: LocalizedStringResource = "打开路径意图标题"

    static var description = IntentDescription("这是一个APPintent的demo的意图描述",
                                               categoryName: "路径",
                                               resultValueName: "Suggested Trails")
    
    static var openAppWhenRun: Bool = false

    // 如果parameterSummary 未打开,那么系统会检测所有title类型的参数,并在编辑快捷方式时列出,否则,优先展示parameterSummary
//    static var parameterSummary: some ParameterSummary {
//        Summary("Summary (.$trailEntity) 打开") // 如果提供了summary,优先展示带参数的summary,否者走suggestedEntities
//    }
    
    // 有几个title,在编辑快捷方式时就会展示几个选项,可以同时存在,requestValueDialog当用户没有选中,直接运行快捷方式时,会出现弹框,标题就是requestValueDialog
    @Parameter(title: "带参数路径", requestValueDialog: "参数你想要打开什么路径?")
    var trailStyle: TrailsStyle
    
    // 编辑意图时显示名称,供快捷方式界面展示
    @Parameter(title: "路径", requestValueDialog: "你想要打开什么路径?")
    var trailEntity: TrailEntity

    // perform 方法执行打开路径的操作
    func perform() async throws -> some IntentResult & ProvidesDialog {
        return .result(dialog: "打开 (trailEntity.name)对话框内容")
    }
}

OpenTrailIntent遵循了AppIntent协议。

static var title: LocalizedStringResource = "打开路径意图标题"

AppIntent需要一个title属性,LocalizedStringResource为 Intent 提供标题。这是快捷方式等位置中的 Intent 名称。

    static var description = IntentDescription(
        "这是一个APPintent的demo的意图描述",
        categoryName: "路径分类",
        searchKeywords: ["Trail, Map"],
        resultValueName: "Selected Trails"
    )

descriptionText: LocalizedStringResource

这是一个必需参数,用于为参数提供描述性文本。描述性文本会在 Siri 或快捷方式中显示,帮助用户理解参数的目的。例如,在描述一个地点参数时,descriptionText 可能为 "Select a location"

categoryName: LocalizedStringResource?

这是可选的,用于指定参数的类别名称。在 Siri 或快捷方式中可以用来对参数进行分类。例如,如果参数是“日期”或“地点”,categoryName 可以为 "Date""Location",提供一种额外的分类信息。

searchKeywords: [LocalizedStringResource]

可选的搜索关键字数组,帮助用户在快捷方式或 Siri 中更好地搜索和匹配参数。这些关键字并不直接显示给用户,而是用于提高搜索匹配度。例如,关键字可以包含 "Trail""Map" 等,帮助用户在 Siri 或快捷方式搜索中更容易找到特定参数。

resultValueName: LocalizedStringResource?

用于提供结果的显示名称,这个值在返回结果时可以显示给用户。例如,如果参数是一个搜索关键字,可以在 resultValueName 中指定 "Search Term",便于结果输出时清楚地显示其含义。

// 如果parameterSummary 未打开,那么系统会检测所有title类型的参数,并在编辑快捷方式时列出,否则,优先展示parameterSummary
static var parameterSummary: some ParameterSummary {
    Summary("Summary (.$trailEntity) 打开") // 如果提供了summary,优先展示带参数的summary,否者走suggestedEntities
}

parameterSummary属性定义了此意图的摘要,以及其参数的填充方式。如果你的参数是一个枚举,parameterSummary内部还支持swift方式来处理你的参数条件,具体可以参考系统的写法

  1. 如果parameterSummary 未实现,那么系统会检测所有title类型的参数,并在编辑快捷方式时列出,否则,优先展示parameterSummary

  2. 如果parameterSummary里只覆盖了一个title参数,那么在执行的时候,如果其他参数有requestValueDialog,还是会执行一次。

    // 有几个title,在编辑快捷方式时就会展示几个选项,可以同时存在,requestValueDialog当用户没有选中,直接运行快捷方式时,会出现弹框,标题就是requestValueDialog
    @Parameter(title: "带参数路径", requestValueDialog: "带参数的你想要打开什么路径?")
    var trailStyle: TrailsStyle
    
    // 编辑意图时显示名称,供快捷方式界面展示
    @Parameter(title: "路径参数", requestValueDialog: "你想要打开什么路径?")
    var trailEntity: TrailEntity

使用 @Parameter 标记可交互的参数,让用户在快捷方式中进行选择。

func perform() async throws -> some IntentResult & ProvidesDialog {
    return .result(dialog: "打开 (trailEntity.name)对话框内容")
}

实现 perform() 方法,用于在意图执行时执行实际操作。,该方法返回一个IntentResult描述此意图运行结果的 。有时,返回值可能为空.result()。在返回之前,该perform方法要求导航模型导航到指定的页面。有时,可以返回一个对话框,例如本示例。也可以在此函数中实现自定义响应UI

创建应用程序快捷方式

struct AppShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: OpenTrailIntent(),
            // 根据trail来解析所有和trail的意图展示在快捷指令中
            phrases: ["open (.$trailEntity) in (.applicationName) "],
            shortTitle: "打开路径",
            systemImageName: "map"
        )
    }
}

通过实现 AppShortcutsProvider,将 OpenTrailIntent 注册为一个快捷方式,使其可以通过 Siri 和快捷方式调用。

@main
struct MyAppIntentsApp: App {
    init() {
        AppShortcuts.updateAppShortcutParameters()
    }
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

当你的 AppIntent 有多个参数时,其中一些参数的值可能依赖于其他参数时,此时需要去动态更新

Siri Tips和ShortcutsLink使用

在系统中注册 App Shortcut 后,用户无需进一步配置即可通过 Siri 开始使用意图。为了教用户使用意图的短语,应用在相关视图中提供了SiriTipView

SiriTipView(intent: OpenTrailIntent(), isVisible: $visiable)
    .siriTipViewStyle(.automatic)

同时也可在APP内添加快捷方式的跳转链接

ShortcutsLink()
    .shortcutsLinkStyle(.automaticOutline)

如果你使用了APP的快捷方式,那么系统会自动收集此意图,并会通过siri来推荐你使用此意图。当然你也可以选择删除它

Transferable协议的使用

通过遵循 Transferable,使得共享和传输您描述应用实体的数据成为可能。

依据上面的demo继续实现,将TrailEntity的内容导出为富文本和图片两种类型

import AppIntents
import CoreLocation
import Foundation
import CoreTransferable
import UIKit
import SwiftUI

struct TrailEntitysSummary: TransientAppEntity {
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "typeDisplay路径内容总结")
    
    @Property(title: "总结开始时间")
    var summaryStartDate: Date
    
    @Property(title: "路径名称")
    var name: String
    
    @Property(title: "路径类型")
    var style: TrailsStyle

    @Property(title: "路径ID")
    var id: String
    
    init() {
        summaryStartDate = Date()
        name = ""
        id = ""
        style = .beach
    }
    
    var displayRepresentation: DisplayRepresentation {
        var image = "party.popper"
        var subtitle = LocalizedStringResource("")
        
        return DisplayRepresentation(title: "Display路径内容总结",
                                     subtitle: subtitle,
                                     image: DisplayRepresentation.Image(systemName: image),
                                     synonyms: ["Display 路径总结"])
    }
}

extension TrailEntitysSummary: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(exportedContentType: .rtf) { summary in
            try summary.asRTFData
        }

        FileRepresentation(exportedContentType: .png) { summary in
            await SentTransferredFile(try summary.asImageFile)
        }
    }
}

extension TrailEntitysSummary {
    
    // 将实体内容导出为 RTF 数据
    var asRTFData: Data {
        let rtfText = """
        我是TrailEntity转成富文本的Demo
        Trail Summary:
        - ID: (id)
        - Name: (name)
        - Trail Style: (style.rawValue)
        """
        
        let attributedString = NSAttributedString(string: rtfText, attributes: [
            .font: UIFont.systemFont(ofSize: 14)
        ])
        
        return try! attributedString.data(from: NSRange(location: 0, length: attributedString.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf])
    }
    
    // 将实体内容导出为图像文件
    @MainActor
    var asImageFile: URL {
        let controller = UIHostingController(rootView: TrailSummaryView(trail: self))
        controller.view.bounds = CGRect(x: 0, y: 0, width: 300, height: 400)
        
        let renderer = UIGraphicsImageRenderer(bounds: controller.view.bounds)
        let image = renderer.image { _ in
            controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
        
        // 将图像保存为 PNG 格式
        let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("TrailSummary.png")
        try? image.pngData()?.write(to: fileURL)
        
        return fileURL
    }
}

// SwiftUI 视图,用于生成图像内容
struct TrailSummaryView: View {
    let trail: TrailEntitysSummary
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("我是TrailEntity转成图片的Demo")
                .font(.title2)
            Text("Trail Summary")
                .font(.title2)
                .bold()
            Text("ID: (trail.id)")
            Text("Name: (trail.name)")
            Text("Trail Style: (trail.style.rawValue)")
            Spacer(minLength: 10)
        }
        .padding()
        .background(Color.white)
        .cornerRadius(10)
        .shadow(radius: 5)
    }
}
struct TrailEntitysSummary: TransientAppEntity {

和上面创建自定义意图一样,首先创建一个 TrailEntitysSummary 代表路径汇总信息,其遵循TransientAppEntity协议,(一种表示瞬态模型对象的类型,它通过属性将其接口暴露给应用程序intent。注意,TransientAppEntity类型不会被查询。)

    @Property(title: "总结开始时间")
    var summaryStartDate: Date
    
    @Property(title: "路径名称")
    var name: String
    
    @Property(title: "路径类型")
    var style: TrailsStyle

    @Property(title: "路径ID")
    var id: String

使用@Property来将这些属性公开给系统使用,在使用该实体的时候系统会提供这些被暴露出来的属性供使用者互动

extension TrailEntitysSummary: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(exportedContentType: .rtf) { summary in
            try summary.asRTFData
        }

        FileRepresentation(exportedContentType: .png) { summary in
            await SentTransferredFile(try summary.asImageFile)
        }
    }
}

遵循Transferable协议,让TrailEntitysSummary 的内容导出为不同的类型并在不同的地方使用。这里支持富文本和图片两种类型

IntentFile协议使用

import Foundation
import AppIntents
import SwiftUI

struct DisplayPhotoIntent: AppIntent {
    static var title: LocalizedStringResource = "Display Photo"
    
    // 文件参数,用于接收照片文件
    @Parameter(title: "其他APP图片", supportedContentTypes: [.jpeg, .rtf, .image])
    var photoFile: IntentFile
    
    static var parameterSummary: some ParameterSummary {
        Summary("预览(.$photoFile)")
    }
    
    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
        return .result(dialog: "Here's your photo!", view: DisplayPhotoView(photoFile: photoFile))
    }
}

// 显示 IntentFile 图片
struct DisplayPhotoView: View {
    let photoFile: IntentFile
    
    var body: some View {
        if let uiImage = UIImage(data: photoFile.data) {
            Image(uiImage: uiImage)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 300, height: 300)
        } else {
            Text("Unable to display image.")
        }
    }
}

通过声明IntentFile的参数,接受其他应用提供的内容。

首先创建一个 DisplayPhotoIntent 意图

    // 文件参数,用于接收照片文件
    @Parameter(title: "其他APP图片", supportedContentTypes: [.jpeg, .rtf, .image])
    var photoFile: IntentFile

声明一个IntentFile类型的参数,并使用@Parameter将其暴露给系统,当用户在使用快捷方式时,可以打开文件管理系统进行文件的选择。

    func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
        return .result(dialog: "Here's your photo!", view: DisplayPhotoView(photoFile: photoFile))
    }

perform结果遵循ShowsSnippetView协议,支持结果返回一个自定义的view,用于预览选中的文件,当然在这里也可以操作,将文件用户APP的某些功能,比如意见反馈上传。

在 Spotlight 中查找应用实体以及APPIntents实例 Demo

在 Spotlight 中查找应用实体

  1. 实体支持IndexedEntity
  2. 使用CSSearchableIndex上报实例

下载下面资源里的工程查看效果

代码效果对比步骤:

步骤1:运行上面的代码,然后进入手机首页,并向下拖动以显示搜索对话框。输入Session搜索, 只会展示APP

步骤2:在AppDelegate文件中,打开以下代码:

Task {
      try await CSSearchableIndex
        .default()
        .indexAppEntities(sessionDataManager.sessions.map(SessionEntity.init(session:)))
    }

再次执行步骤1,查看搜索结果:会发现,可以搜索到具体的Entity,点击可以跳转到对应的详情页。

参考文档

  1. developer.apple.com/documentati…

资源

github.com/chaserr/MyA…