从 iOS 17 开始,Apple 为 Widget 引入了期待已久的交互能力:通过 Button(intent:) 和 Toggle,用户可以直接在 Widget 上完成操作,无需跳转到主 App。这极大地扩展了 Widget 的应用场景。
本篇将全面讲解 Widget 交互的实现方式、限制和最佳实践。
1. Widget 交互的演进历程
| iOS 版本 | 交互能力 |
|---|---|
| iOS 14-16 | 仅支持 .widgetURL() 点击跳转 |
| iOS 17+ | Button(intent:) + Toggle + App Intents |
2. Button + AppIntent 基础
核心原理
- 定义一个
AppIntent,实现具体的业务操作 - 在 Widget 视图中使用
Button(intent:)或Toggle(isOn: intent:)绑定该 Intent - 系统在用户点击时执行 Intent(不打开主 App)
最简单的示例:打卡按钮
swift
import AppIntents
// 1. 定义 Intent
struct MarkTaskDoneIntent: AppIntent {
static var title: LocalizedStringResource = "完成任务"
@Parameter(title: "任务ID")
var taskID: String
init() {}
init(taskID: String) { self.taskID = taskID }
func perform() async throws -> some IntentResult {
// 更新数据(写入 App Group 共享存储)
let defaults = UserDefaults(suiteName: "group.com.example.app")
var doneList = defaults?.array(forKey: "done_tasks") as? [String] ?? []
doneList.append(taskID)
defaults?.set(doneList, forKey: "done_tasks")
// 返回结果
return .result()
}
}
// 2. 在 Widget View 中使用
struct TaskWidgetView: View {
var entry: TaskEntry
var body: some View {
VStack(spacing: 8) {
Text(entry.taskName)
.font(.headline)
Button(intent: MarkTaskDoneIntent(taskID: entry.taskID)) {
Label("完成", systemImage: "checkmark.circle.fill")
.foregroundColor(.green)
}
.buttonStyle(.plain)
}
}
}
带返回值与 UI 更新的 Intent
swift
struct ToggleFavoriteIntent: AppIntent {
static var title: LocalizedStringResource = "切换收藏状态"
@Parameter(title: "项目ID")
var itemID: String
init() {}
init(itemID: String) { self.itemID = itemID }
func perform() async throws -> some IntentResult & OpensIntentWhenRun {
// 切换收藏状态
toggleFavorite(itemID)
return .result(opensIntentWhenRun: true)
}
}
3. Toggle 交互
Toggle 适合二元状态切换(如开启/关闭某功能):
swift
struct EnableAlarmIntent: AppIntent {
static var title: LocalizedStringResource = "切换闹钟"
@Parameter(title: "闹钟ID")
var alarmID: String
init() {}
init(alarmID: String) { self.alarmID = alarmID }
func perform() async throws -> some IntentResult {
// 执行切换逻辑
switchAlarm(alarmID)
return .result()
}
}
// View 中使用
struct AlarmWidgetView: View {
@State private var isEnabled: Bool = true
var body: some View {
Toggle(isOn: $isEnabled, intent: EnableAlarmIntent(alarmID: "morning")) {
Label("早晨闹钟", systemImage: "alarm")
}
.toggleStyle(.button)
}
}
4. 实战:TODO Widget 带完整交互
swift
// 数据模型
struct TodoItem: Codable, Identifiable {
let id: String
var title: String
var isDone: Bool
}
// 完成 Intent
struct CompleteTodoIntent: AppIntent {
static var title: LocalizedStringResource = "完成待办"
@Parameter(title: "待办ID")
var todoID: String
init() {}
init(todoID: String) { self.todoID = todoID }
func perform() async throws -> some IntentResult {
updateTodoStatus(id: todoID, isDone: true)
// 刷新 Widget 时间线
do {
try await Task.sleep(for: .milliseconds(100))
} catch {}
return .result()
}
private func updateTodoStatus(id: String, isDone: Bool) {
let defaults = UserDefaults(suiteName: "group.com.example.app")
guard let data = defaults?.data(forKey: "todos"),
var todos = try? JSONDecoder().decode([TodoItem].self, from: data)
else { return }
if let index = todos.firstIndex(where: { $0.id == id }) {
todos[index].isDone = isDone
defaults?.set(try? JSONEncoder().encode(todos), forKey: "todos")
}
}
}
// Widget View
struct TodoWidgetView: View {
var entry: TodoEntry
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(entry.todos.prefix(3)) { todo in
HStack {
Button(intent: CompleteTodoIntent(todoID: todo.id)) {
Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle")
.foregroundColor(todo.isDone ? .green : .gray)
}
.buttonStyle(.plain)
Text(todo.title)
.font(.caption)
.strikethrough(todo.isDone)
.lineLimit(1)
}
}
}
}
}
5. 交互的限制与注意事项
| 限制 | 说明 |
|---|---|
| 交互有延迟 | Intent 执行是异步的,用户点击后可能有 0.5-2 秒的视觉反馈延迟 |
| 不能执行 UI 操作 | Intent 在后台执行,不能弹 Alert、不能导航 |
| 执行时间限制 | Intent 有执行时间限制,不能执行长时间任务 |
| 不能访问私有数据 | Intent 运行在受限的沙盒中,涉及隐私数据的操作需要授权 |
6. 最佳实践
- 视觉反馈 :使用 SF Symbol 的状态切换(如
circle→checkmark.circle.fill)提供即时反馈 - 操作幂等:确保 Intent 可以安全地重复执行,不会造成数据异常
- 错误处理 :在
perform()中使用 do-catch 处理异常,避免因一次失败导致 Widget 无响应 - 合理分组:将相关操作封装到同一个 Intent 中,不要为每个按钮创建独立的 Intent
- 性能优化 :Intent 的
perform()应尽量轻量,复杂的网络操作应交给主 App - 不要忘记 UI 刷新:Intents 执行后通过合理 Timeline 策略确保 Widget 视图更新
小结
- iOS 17 的
Button(intent:)和Toggle让 Widget 拥有了真正的交互能力 - 通过
AppIntent的perform()方法实现业务逻辑 - 注意交互延迟、执行限制和视图刷新策略
- 适合的场景:打卡、收藏、切换状态、快捷操作等轻量交互
上一篇 :iOS Widget 开发-10: TimelineProvider、IntentTimelineProvider、AppIntentTimelineProvider(中文详解)
下一篇 :iOS Widget 开发-12:Widget 深度链接与导航