Highlight
iOS 16 和 macOS Ventura 为 Focus(专注模式)引入了 Focus Filters 机制。之前 Focus 只能控制系统层面的通知行为,现在它可以深入到每个应用的内部逻辑。
核心内容
Focus 在 iOS 15、macOS Monterey 和 watchOS 8 中出现。它先解决的是系统层面的打扰控制:工作时只允许同事通知,个人时间只让少数 App 提醒用户(00:18)。
到了 iOS 16 和 macOS Ventura,Focus filters 把这个能力交给 App。用户启用某个 Focus 后,App 可以按当前场景切换内容、账号、外观、通知和角标。日历可以只显示个人日历,邮件可以只显示当前 Focus 相关的邮箱和通知(01:04)。
演讲里的示例是聊天 App。一个用户可能同时有工作账号和个人账号。Personal Focus 启用时,App 只显示个人账号,把状态改成个人时间,并让界面跟随用户配置的外观;Work Focus 启用时,App 再切回工作账号和工作通知(02:52)。
开发者要做三件事。第一,用 App Intents 定义一个 SetFocusFilterIntent,告诉系统这个 App 支持 Focus filter。第二,把用户可配置的选项声明成参数。第三,在 Focus 变化时执行 perform(),把系统传回来的参数应用到 App 或 Extension(03:41)。
详细内容
定义 Focus filter 的入口
(04:57)Focus filter 本质上是一个遵守 SetFocusFilterIntent 的 Swift 类型。系统在安装 App 时读取它的静态标题和描述,并把它显示在 Focus 设置页面。
// Implementing SetFocusFilterIntent
import AppIntents
struct ExampleChatAppFocusFilter: SetFocusFilterIntent {
static var title: LocalizedStringResource = "Set account, status & look"
static var description: LocalizedStringResource? = """
Select an account, set your status, and configure
the look of Example Chat App.
"""
}
关键点:
import AppIntents引入 Focus filters 所在的 App Intents 能力。ExampleChatAppFocusFilter是 App 提供给系统的 Focus filter 类型。SetFocusFilterIntent表明这个 Intent 用来设置某个 Focus 下的 App 行为。title会出现在 Focus 设置里的卡片副标题位置。description会在用户点进配置页面后提供额外说明。- 标题和描述是
static,系统在 App 安装时读取它们。
把可配置项声明成参数
(07:02)用户能配置什么,由 @Parameter 决定。参数可以是 Bool、String、Float 等标准类型,也可以是 App 自己定义的 Entity。非 optional 参数需要提供默认值。
// Defining your Parameters & Entities
import AppIntents
struct ExampleChatAppFocusFilter: SetFocusFilterIntent {
@Parameter(title: "Use Dark Mode", default: false)
var alwaysUseDarkMode: Bool
@Parameter(title: "Status Message")
var status: String?
@Parameter(title: "Selected Account")
var account: AccountEntity?
// ...
}
关键点:
alwaysUseDarkMode是必填布尔参数,所以用default: false给出默认值。status是可选字符串,用户可以不为某个 Focus 设置状态消息。account是 App 自己的AccountEntity,用于让用户选择账号。- 每个
@Parameter的title都会显示在系统设置界面,帮助用户理解这个选项。 - 参数的值由用户在每个 Focus 的设置中填写,App 不需要自己构建这套配置 UI。
让设置页面显示具体配置
(08:43)Focus filter 配置完成后,系统设置页会显示动态标题和副标题。displayRepresentation 用来描述“配置了哪些参数”和“这些参数的当前值”。
// Display Representation
struct ExampleChatAppFocusFilter: SetFocusFilterIntent {
// ...
var localizedDarkModeString: String {
return self.alwaysUseDarkMode ? "Dark" : "Dynamic"
}
var displayRepresentation: DisplayRepresentation {
var titleList: [LocalizedStringResource] = [], subtitleList: [String] = []
if let account = self.account {
titleList.append("Account")
subtitleList.append(account.displayName)
}
if let status = self.status {
titleList.append("Status")
subtitleList.append(status)
}
titleList.append("Look")
subtitleList.append(self.localizedDarkModeString)
let title = LocalizedStringResource("Set \(titleList, format: .list(type: .and))")
let subtitle = LocalizedStringResource("\(subtitleList.formatted())")
return DisplayRepresentation(title: title, subtitle: subtitle)
}
// ...
}
关键点:
localizedDarkModeString把布尔值转换成用户能读懂的外观描述。titleList保存已经配置的参数名称,例如 Account、Status、Look。subtitleList保存参数值,例如账号显示名、状态消息、Dark 或 Dynamic。account和status是 optional,只在用户设置后才进入显示内容。alwaysUseDarkMode是必填参数,所以 Look 永远会显示。DisplayRepresentation的title和subtitle会变成 Focus 设置卡片上的主文案和副文案。
在 Focus 变化时更新 App
(09:23)系统判断 App 需要响应 Focus 变化时,会调用 Focus filter 的 perform()。如果 App 正在运行,调用发生在 App 内;如果 App 没有运行,开发者可以实现 Extension,让系统启动 Extension 处理这次变化。只更新 App 自己视图时,App 内实现通常就够了;需要改 widget、通知或角标时,应考虑 Extension。
(11:24)perform() 被调用时,参数已经填入用户在 Focus 设置里配置的值。示例把这些值组装成 AppData,再交给 App 的模型层更新界面和行为。
// Implementing Perform on your Focus filter
import AppIntents
struct ExampleChatAppFocusFilter: SetFocusFilterIntent {
// ...
func perform() async throws -> some IntentResult {
let myData = AppData(
alwaysUseDarkMode: self.alwaysUseDarkMode,
status: self.status,
account: self.account
)
myModel.shared.updateAppWithData(myData)
return .result()
}
// ...
}
关键点:
perform()是 Focus transition 后的执行入口。self.alwaysUseDarkMode读取用户为当前 Focus 配置的外观选项。self.status读取当前 Focus 下的状态消息。self.account读取当前 Focus 绑定的账号。AppData是示例 App 自己的数据结构,用来集中传递这些配置。updateAppWithData(myData)把 Focus 配置应用到 App。return .result()告诉系统这次 Intent 执行完成。
主动查询当前 Focus filter
(11:47)有时 App 需要主动读取当前 Focus filter,而不是等待系统调用 perform()。这时可以访问具体 Focus filter 类型上的 current。
// Calling Current
import AppIntents
func updateCurrentFilter() async throws {
do {
let currentFilter = try await ExampleChatAppFocusFilter.current
let myData = AppData(
myRequiredBoolValue: currentFilter.myRequiredBoolValue,
myOptionalStringValue: currentFilter.myOptionalStringValue,
myOptionalAppEnum: currentFilter.myOptionalAppEnum,
myAppEntity: currentFilter.myAppEntity
)
myModel.shared.updateAppWithData(myData)
} catch let error {
print("Error loading current filter: \(error.localizedDescription)")
throw error
}
}
关键点:
ExampleChatAppFocusFilter.current异步返回当前生效的 Focus filter 参数。try await表明读取当前配置可能失败,也可能需要等待系统结果。currentFilter.myRequiredBoolValue读取必填参数。currentFilter.myOptionalStringValue、myOptionalAppEnum和myAppEntity读取可选参数或自定义对象。updateAppWithData(myData)复用和perform()相同的更新路径。catch中记录错误并重新抛出,避免静默丢失读取失败。
用 App Context 过滤通知
(12:16)Focus filter 还可以把 App 当前上下文交还给系统。一个典型用途是通知过滤:App 在 FocusFilterAppContext 中设置 filterPredicate,通知内容再提供 filterCriteria。如果两者不匹配,通知会被静音。
// Set filterPredicate on an App context
import AppIntents
struct ExampleChatAppFocusFilter: SetFocusFilterIntent {
var appContext: FocusFilterAppContext {
let allowedAccountList = [account.identifier]
let predicate = NSPredicate(format: "SELF IN %@", allowedAccountList)
return FocusFilterAppContext(notificationFilterPredicate: predicate)
}
}
关键点:
appContext返回系统需要的额外上下文。allowedAccountList保存当前 Focus 允许打扰用户的账号标识符。NSPredicate(format: "SELF IN %@", allowedAccountList)定义通知过滤条件。FocusFilterAppContext(notificationFilterPredicate: predicate)把过滤条件交给系统。- 这个上下文可以作为
perform()的结果返回,也可以在需要时通过 invalidate 让系统重新读取。
(13:53)发送通知时,App 要把通知所属账号写入 filterCriteria。演讲示例里,当前 Personal Focus 只允许个人账号;一条工作账号通知带着 work-account-identifier,因此会被系统静音。
// Pass filterCriteria on UNNotificationContent
let content = UNMutableNotificationContent()
content.title = "Curt Rothert"
content.subtitle = "Slide Feedback"
content.body = "The run through today was great. I had few comments about slide 22 and 28."
content.filterCriteria = "work-account-identifier"
关键点:
UNMutableNotificationContent()创建本地通知内容。title、subtitle和body填写通知正文。filterCriteria写入这条通知所属的上下文标识。- 系统会把
filterCriteria与 Focus filter 提供的filterPredicate匹配。 - 远程通知也可以在 JSON payload 中设置 filter criteria。
(14:29)角标也应该跟随当前 Focus。演讲提到 UNUserNotificationCenter 新增 setBadgeCount,开发者可以把角标改成当前 Focus 下真正重要的数量,减少无关提醒。
核心启发
1. 多账号 App 的 Focus 账号切换
- 做什么:让聊天、邮件、客服或社交 App 在 Work Focus 下只显示工作账号,在 Personal Focus 下只显示个人账号。
- 为什么值得做:演讲明确提到多账号 App 适合把某个 Focus 关联到某个账号,避免用户在错误场景里看到无关内容。
- 怎么开始:把账号建模成
AppEntity,在SetFocusFilterIntent中声明@Parameter(title: "Selected Account") var account: AccountEntity?,再在perform()里刷新当前账号和列表。
2. 根据 Focus 切换状态和外观
- 做什么:在 Work Focus 自动设置“工作中”的状态消息,在 Personal Focus 使用更轻量的界面布局或外观。
- 为什么值得做:示例 Focus filter 同时包含
status和alwaysUseDarkMode,说明 Focus filter 不限于过滤内容,也能控制 App 行为和外观。 - 怎么开始:为状态消息和外观分别声明
@Parameter,用displayRepresentation把用户配置展示清楚,避免设置页只显示模糊名称。
3. 给通知加上下文过滤
- 做什么:通知发送前写入账号、项目或频道标识,让系统在当前 Focus 下只突出相关通知。
- 为什么值得做:演讲说明
filterPredicate和filterCriteria不匹配时,通知会被静音,适合减少非当前场景的打扰。 - 怎么开始:在
FocusFilterAppContext中返回notificationFilterPredicate,在本地通知的UNMutableNotificationContent.filterCriteria或远程通知 payload 中写入同一套标识。
4. 让角标只反映当前 Focus 的待处理事项
- 做什么:Work Focus 下角标只统计工作账号未读数,Personal Focus 下角标只统计个人内容。
- 为什么值得做:演讲把角标列为 App 向系统提供额外上下文的方式,用来减少不相关提醒。
- 怎么开始:在 Focus 变化后重新计算当前 Focus 的重要计数,调用
UNUserNotificationCenter的setBadgeCount更新角标。
关联 Session
- Dive into App Intents — Focus filters 用
SetFocusFilterIntent定义,理解 AppIntent、Parameter 和 Entity 是接入前提。 - Implement App Shortcuts with App Intents — 同样基于 App Intents,展示如何把 App 功能交给系统发现和运行。
- Design App Shortcuts — 补充系统入口的文案、图标和用户理解方式,和 Focus 设置页的 display representation 设计相邻。
- Efficiency awaits: Background tasks in SwiftUI — 适合了解 App 响应系统事件时如何安排后台更新。
评论
GitHub Issues · utterances