小组件介绍
小组件是App Extension
的一种,前身为Today Extension
,iOS8推出,只能添加到负一屏。
iOS14推出Widget Extension
取代Today Extension
。 相比Today Extension
,Widget Extension
可以添加到桌面,并且支持了更多的尺寸,可以承载更多内容。
iOS17开始支持在小组件上点击交互,进一步丰富了小组件能力。
小组件开发依赖的主要官方框架是WidgetKit
,界面开发需要使用SwiftUI
,自定义配置、点击交互等和SiriKit
的机制相同。
本文将先简单介绍SwiftUI
,使读者对小组件的UI开发有一个简单了解;然后对小组件的核心技术进行展开;另外,小组件开发中总会看到Siri和快捷指令的影子,因此,最后也会顺带讲一点Siri和快捷指令相关知识。
了解SwiftUI
SwiftUI
是苹果2019年推出的构建界面的声明式框架,相对UIKit
具有更高的开发效率和更好的可读性等优点。 接下来以Xcode创建的工程模版为切入点,介绍一点SwiftUI
开发知识。
创建工程时,Interface
选择SwiftUI
,Xcode生成的工程模版会包含两个Swift文件:
MyApp.swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
ContentView.swift
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
MyApp.swift
中的@main
代表程序入口,一个进程只能有一个@main
标注,能被标注为@main
的类型必须包含一个无参无返回值的静态 main
函数,即static func main()
。
App
代表应用程序,Scene
代表应用程序的窗口,它们都是协议,WindowGroup
是其中一个Scene
。
View
代表视图,也是一个协议,每个View
都有一个body
属性,类型为some View
,用于自定义View
:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor var body: Self.Body { get }
}
@MianActor
作用是将body
的获取派发到主线程。
@ViewBuilder
用于构建一个或多个视图:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@resultBuilder public struct ViewBuilder {
// ...
public static func buildBlock<each Content>(_ content: repeat each Content) -> TupleView<(repeat each Content)> where repeat each Content : View
}
@ViewBuilder
的buildBlock
函数支持传入多个View
,因此ContentView
的body
中可以放多个View
,例如:
struct ContentView: View {
var body: some View {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
}
以上代码也等效于:
struct ContentView: View {
var body: some View {
ViewBuilder.buildBlock(
Image(systemName: "globe")
.imageScale(.large),
Text("Hello, world!")
)
}
}
@resultBuilder
的作用可以简单理解为,省去buildBlock
函数调用,直接在body
里声明View
。
SwiftUI
中还有很多地方用到@ViewBuidler
,例如示例中的VStack
:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct VStack<Content> : View where Content : View {
@inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
public typealias Body = Never
}
小组件里只能使用有限的SwiftUI
控件,具体支持小组件的控件可以参考官方文档 SwiftUI views for widgets
创建小组件扩展
1、在 Xcode 中打开 App 项目,并选取"File > New > Target" ,选择 "Widget Extension"
2、点击"Next"
- Include Live Activity : 灵动岛、实时活动相关
- Include Configuration App Intent:iOS 17以上小组件配置相关
以上两个设置项暂时不用勾选,涉及到相关功能时再添加。
3、点击“Finish”
Xcode会自动生成和小组件Target同名的文件夹,包含以下内容:
- MyWidgetBundle: 小组件容器,
body
可以放多个小组件,用于一个Widget Extension
展示多种小组件@main struct MyWidgetBundle: WidgetBundle { @WidgetBundleBuilder var body: some Widget { MyWidget() } }
- MyWidget: 小组件核心代码从这里开始
struct MyWidget: Widget { let kind: String = "MyWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in if #available(iOS 17.0, *) { MyWidgetEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } else { MyWidgetEntryView(entry: entry) .padding() .background() } } .configurationDisplayName("My Widget") .description("This is an example widget.") } }
WidgetBundle
和Widget
都可以标注@main
作为程序入口,如果Widget
标注@main
,那么整个小组件扩展只有一种小组件。
4、Xcode中运行“MyApp”, 再手动启动一次App,长按桌面 -> 搜索小组件 -> 选择“MyApp”即可添加小组件:
用户必须在安装包含小组件的 App 后至少启动该 App 一次,才能找到小组件
小组件结构
WidgetBundle
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public protocol WidgetBundle {
associatedtype Body : Widget
@WidgetBundleBuilder var body: Self.Body { get }
}
// ...
WidgetBundle
的body
中返回遵守Widget
协议的实例。
@WidgetBundleBuilder
的作用和前面的@ViewBuidler
作用相似。@WidgetBundleBuilder
的buildBlock
方法最多支持传入10个遵守Widget
协议的参数,因此一个WidgetBundle
最多可以支持放10个Widget
:
以下方式可以突破数量的限制:
@main
struct MyWidgetBundle: WidgetBundle {
var body: some Widget {
MyChildWidgetBundle().body
// 最多可以放10个`MyChildWidgetBundle().body`
}
}
struct MyChildWidgetBundle: WidgetBundle {
var body: some Widget {
MyGrandsonWidgetBundle().body
// 最多可以放10个`MyGrandsonWidgetBundle().body`
}
}
struct MyGrandsonWidgetBundle: WidgetBundle {
var body: some Widget {
MyWidget()
// 最多可以放10个`MyWidget()`
}
}
Widget
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public protocol Widget {
associatedtype Body : WidgetConfiguration
var body: Self.Body { get }
}
Widget
的body
返回遵守WidgetConfiguration
协议的实例。系统为小组件提供了三个遵守WidgetConfiguration
协议的结构体:
StaticConfiguration
:适用于没有用户配置(也就是没有编辑功能)的小组件
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public struct StaticConfiguration<Content> : WidgetConfiguration where Content : View {
public var body: some WidgetConfiguration { get }
public typealias Body = some WidgetConfiguration
}
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
extension StaticConfiguration {
public init<Provider>(
kind: String,
provider: Provider,
@ViewBuilder content: @escaping (Provider.Entry) -> Content
) where Provider : TimelineProvider
}
IntentConfiguration
: 适用于有用户自定义配置的小组件,需要创建SiriKit意图定义文件和Intents Extension
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public struct IntentConfiguration<Intent, Content> : WidgetConfiguration where Intent : INIntent, Content : View {
public var body: some WidgetConfiguration { get }
public typealias Body = some WidgetConfiguration
}
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
extension IntentConfiguration {
public init<Provider>(
kind: String,
intent: Intent.Type,
provider: Provider,
@ViewBuilder content: @escaping (Provider.Entry) -> Content
) where Intent == Provider.Intent, Provider : IntentTimelineProvider
}
AppIntentConfiguration
: iOS17推出,不需要添加意图定义文件和Intents Extension
,可以纯代码实现用户配置
public struct AppIntentConfiguration<Intent, Content> : WidgetConfiguration where Intent : WidgetConfigurationIntent, Content : View {
public var body: some WidgetConfiguration { get }
public typealias Body = some WidgetConfiguration
}
@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
@available(tvOS, unavailable)
extension AppIntentConfiguration {
public init<Provider>(
kind: String,
intent: Intent.Type = Intent.self,
provider: Provider,
@ViewBuilder content: @escaping (Provider.Entry) -> Content
) where Intent == Provider.Intent, Provider : AppIntentTimelineProvider
}
以上三种WidgetConfiguration
的初始化方法都包含以下参数:
- kind: 用于标识小组件(主程序刷新小组件时可以根据
kind
刷新指定小组件) - Provider: 用于给小组件生命周期各阶段提供数据
- content:内容闭包,类型为
(Provider.Entry) -> Content)
, 需要在闭包中返回一个SwiftUI视图对象交给WidgetKit显示,视图对象可以根据Provider
生成的TimelineEntry
对象展示数据
TimelineProvider
不同WidgetConfiguration
对应的Provider
遵循的协议也不同,但各协议的方法比较类似,这里以StaticConfiguration
对应的TimelineProvider
为例:
struct Provider: TimelineProvider {
/// 小组件没有数据时占位,每种(kind)小组件只会调用一次
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), emoji: "😀")
}
/// 小组件预览时调用,例如添加小组件的页面
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), emoji: "🧐")
completion(entry)
}
/// 小组件时间线刷新的时候调用
/// 刷新时机:添加小组件、编辑小组件、主App调用WigetKit的刷新方法、定时刷新、操作系统的一些设置等
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// ...
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
Context
小组件上下文,包含小组件的所有环境变量,类型为TimelineProviderContext
结构体
public struct TimelineProviderContext {
/// 环境变量
/// 例如:`environmentVariants.colorScheme`可以获取到系统当前是否是暗黑模式
/// 环境变量也可以在`SwiftUI`通过以下方式获取:
/// @Environment(\.colorScheme) private var colorScheme
///
/// var body: some View {
/// Text(colorScheme == .dark ? "Dark" : "Light")
/// }
public let environmentVariants: TimelineProviderContext.EnvironmentVariants
/// 小组件型号
public let family: WidgetFamily
/// 是否在预览页面
public let isPreview: Bool
/// 小组件尺寸
public let displaySize: CGSize
}
TimelineEntry
小组件数据模型,包含一个必须实现的date
属性,告诉WidgetKit
何时渲染小组件。relevance
不是必须的,几乎用不上,可忽略。可以遵守TimelineEntry
为小组件视图提供业务数据。
public protocol TimelineEntry {
var date: Date { get }
var relevance: TimelineEntryRelevance? { get }
}
Timeline
小组件时间线,包含policy
和一组TimelineEntry
public struct Timeline<EntryType> where EntryType : TimelineEntry {
public let entries: [EntryType]
public let policy: TimelineReloadPolicy
public init(entries: [EntryType], policy: TimelineReloadPolicy)
}
policy
表示小组件时间线的刷新策略,有以下三种:
atEnd
:最后一个TimelineEntry
用完之后立即刷新after(Date)
:指定日期之后刷新小组件never
:永不刷新
小组件UML
小组件尺寸
系统支持的尺寸
iPhone上,小组件支持的尺寸包括:small
、medium
、large
, 从 iOS 16 开始,锁屏界面支持 accessoryCircular
、 accessoryRectangular
和 accessoryInline
类型,这几种小尺寸也可以用在手表中。
小组件尺寸 | iPhone | iPad | Apple Watch | Mac |
---|---|---|---|---|
系统小尺寸 | 主屏幕、“今天”视图和待机显示 | 主屏幕、“今天”视图和锁定屏幕 | 否 | 桌面和“通知中心” |
系统中尺寸 | 主屏幕和“今天”视图 | 主屏幕和“今天”视图 | 否 | 桌面和“通知中心” |
系统大尺寸 | 主屏幕和“今天”视图 | 主屏幕和“今天”视图 | 否 | 桌面和“通知中心” |
系统特大尺寸 | 否 | 主屏幕和“今天”视图 | 否 | 桌面和“通知中心” |
补充圆形 | 锁定屏幕 | 锁定屏幕 | 手表复杂功能和在智能叠放中 | 否 |
补充圆角 | 否 | 否 | 手表复杂功能 | 否 |
补充矩形 | 锁定屏幕 | 锁定屏幕 | 手表复杂功能和在智能叠放中 | 否 |
补充内联 | 锁定屏幕 | 锁定屏幕 | 手表复杂功能 | 否 |
实时活动 | 锁定屏幕 | 锁定屏幕 | 否 | 否 |
更多小组件尺寸相关参考: Widget Design
配置尺寸
通过为WidgetConfiguration
设置supportedFamilies
属性来为小组件设置支持哪些尺寸
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MyWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
.supportedFamilies([.small, .medium]) // 小、中
}
}
获取尺寸
在小组件任何视图中,通过环境变量(@Environment
)获取小组件的尺寸,例如:
struct TestView: View {
@Environment(.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
Text("small")
default:
Text("not small")
}
}
}
小组件刷新
活跃时间段
小组件的活跃时间段:
- 时间线刷新过程
- iOS17及以上
AppIntent
的点击交互过程
在这两段时间内需要准备好数据,以供小组件渲染;另外,在这两段时间内,网络请求、开线程、定时器、收发通知(NotificationCenter)等iOS具备的能力基本上可以正常使用。
刷新机制
小组件的页面刷新是基于时间线的更新。时间线由刷新策略和一组具有时间点的Entry(数据模型)组成。
官方建议entry间隔时间至少5分钟,实践过程中可以间隔秒级。但是小组件对内存有限制,最多30M,间隔时间太短需要创建很多entry,如果页面上元素也较多,会消耗大量内存,导致小组件整体页面显示不出来。
小组件的时间线刷新是串行的,下一次getTimeLine
一定会在上一次completion
之后,如果getTimeLine
到completion
超时,当次刷新会被丢弃,等待下一次刷新。
经测试,每个活跃时间段不能超过28s,例如getTimeLine
到completion
必须在28s以内完成,否则刷新超时。
刷新预算
为节省系统资源以及减少电耗,系统会根据用户浏览小组件的频率和次数、小组件上次刷新时间等因素,为小组件动态分配刷新预算。通常一个小组件24小时可以获得40-70次刷新,时间线刷新策略的时间设置大概是15-60分钟。如果添加了多个小组件,虽然这些小组件同属一个进程,但刷新预算是独立的。
以下场景触发刷新小组件时,不会计入预算:
- 主App在前台
- 主App有活跃的音频或导航会话
- iOS17以上的点击交互
- 系统语言区设置发生更改
- 动态类型或辅助功能设置发生更改
主App通知小组件刷新
除了时间线刷新策略.after(_:)
、一些系统设置触发时间线刷新,主App活跃时,可以通过WidgetKit
的WidgetCenter
通知小组件刷新。
- 刷新指定小组件:
WidgetCenter.shared.reloadTimelines(ofKind: "com.company.carwidget.middle")
- 刷新所有小组件:
WidgetCenter.shared.reloadAllTimelines()
- 根据小组件配置有条件地刷新小组件
WidgetCenter.shared.getCurrentConfigurations { result in
guard case .success(let widgets) = result else { return }
// Iterate over the WidgetInfo elements to find one that matches
// the character from the push notification.
if let widget = widgets.first(
where: { widget in
let intent = widget.configuration as? SelectCharacterIntent
return intent?.character == characterThatReceivedHealingPotion
}
) {
WidgetCenter.shared.reloadTimelines(ofKind: widget.kind)
}
}
经测试,如果主App在前台同时调用多次刷新,系统会合并,直到主App退到后台,小组件的时间线才会开始刷新,因此不用担心主App刷新调用次数过多。
小组件自定义配置
小组件支持自定义配置, 例如一些车控小组件的编辑车控功能等。
小组件自定义配置和Siri、快捷指令交互机制相同,通过自定义意图来定义配置。
iOS17以下,通过创建SiriKit意图定义文件和意图扩展程序,来实现小组件自定义配置,详细教程可以参考官方文档制作可配置小组件。
iOS17及以上,还可以通过实现WidgetConfigurationIntent
协议来完成小组件自定义配置,这种方式是纯代码实现,不需要添加意图定义文件,也不需要添加意图扩展,可以直接运行在小组件进程中。涉及到的数据类型可以参考前面的小组件UML
以下通过SiriKit意图定义文件和意图扩展,来完成自定义配置小组件车控功能,主要包含以下步骤:
- 创建和编辑SiriKit意图定义文件
- 添加并设置意图扩展
- 修改
MyWidget
和Provider
,支持自定义配置
创建和编辑SiriKit意图定义文件
1、创建意图定义文件
2、添加意图
- 点击"+"选择"New Intent",添加后取名为"WidgetConfig"
- 勾选"Intent is eligible for widgets",将意图应用在小组件
3、定义车控按钮数据类型
- 点击"+"选择"New Type"定义外观模式的数据类型,这里取名为"CarControl"
- "Dispaly Name" 取名为“Car Control”,用于在类型列表中展示
- 配置默认包含两个属性:"identifier"和"displayString",也可以添加其他属性
4、为意图添加车控属性
- Display Name:配置页面显示的名称
- Supports multiple values:是否支持多个值,勾选上后,Fixed size才能勾选
- Fixed size:各种尺寸的小组件配置页面显示几个元素
- User can edit value in Shortcuts, widgets, and add to Siri:勾选上才会有「小组件编辑」入口
- Options are provided dynamically:支持动态的配置选项
- Intent handler provides search results are the user types:支持自定义匹配搜索关键字(一般不用勾选)
完成以上步骤后,Xcode会生成以下几个数据类型:
CarControl
结构体WidgetConfigIntent
类,具有carControls
属性WidgetConfigIntentHandling
协议
添加意图扩展
添加完成后,Xcode会自动和扩展同名的文件夹,文件夹中包含一个IntentHandler
文件
代码中支持配置
1、 意图定义文件勾选上Widget
和Intents
对应的Target,使意图定义文件的数据类型在两个Target中能被访问到
2、实现WidgetConfigIntentHandling
协议,提供车控功能选项
车控功能枚举定义
enum CarControlType: String, CaseIterable {
case lock
case findCar
case trunk
case window
case airCondition
case ventilate
case skylight
}
extension CarControlType {
var name: String {
switch self {
case .lock:
return "车锁"
case .findCar:
return "寻车"
case .trunk:
return "后备箱"
case .window:
return "车窗"
case .airCondition:
return "空调"
case .ventilate:
return "透气"
case .skylight:
return "天窗"
}
}
}
IntentHander.swift
import Intents
class IntentHandler: INExtension {
override func handler(for intent: INIntent) -> Any {
return self
}
}
extension IntentHandler: WidgetConfigIntentHandling {
/// 动态提供可选的车控功能(每次时间线刷新都会执行)
/// 勾选`Intent handler provides search results are the user types`后才会有searchTerm参数
func provideCarControlsOptionsCollection(
for intent: WidgetConfigIntent,
searchTerm: String?,
with completion: @escaping (INObjectCollection<CarControl>?, Error?) -> Void
) {
var carControlTypes = CarControlType
.allCases
.filter { carControlType in
!(intent.carControls ?? []).contains { $0.identifier == carControlType.rawValue }
} // 过滤掉编辑页面已经存在的车控功能
// 匹配搜索关键字
if let searchTerm {
carControlTypes = carControlTypes.filter { $0.name.contains(searchTerm) }
}
let carControls = carControlTypes.map { CarControl(identifier: $0.rawValue, display: $0.name) }
completion(INObjectCollection(items: carControls), nil)
}
/// 提供默认4个车控功能(小组件生命周期内只会执行一次)
func defaultCarControls(for intent: WidgetConfigIntent) -> [CarControl]? {
CarControlType
.allCases
.prefix(4)
.map { CarControl(identifier: $0.rawValue, display: $0.name) }
}
}
3、Provider
修改为遵守IntentTimelineProvider
协议
struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
func getSnapshot(for configuration: WidgetConfigIntent, in context: Context, completion: @escaping (Entry) -> Void) {
let entry = SimpleEntry(date: Date())
completion(entry)
}
func getTimeline(for configuration: WidgetConfigIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
let entry = SimpleEntry(date: Date(), carControls: configuration.carControls ?? [])
let timeline = Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(20 * 60)))
completion(timeline)
}
对比TimelineProvider
协议,getSnapshot
和getTimeline
的方法多一个WidgetConfigIntent
类型的参数,通过该参数可以获取当前选择的配置项。
WidgetConfigIntent
是自动生成的类,在编辑意图定义文件时,我们为WidgetConfigIntent
添加了一个carControls
的属性,WidgetConfigIntent
的声明如下:
@available(iOS 12.0, macOS 11.0, watchOS 5.0, *) @available(tvOS, unavailable)
@objc(WidgetConfigIntent)
public class WidgetConfigIntent: INIntent {
@NSManaged public var carControls: [CarControl]?
}
4、MyWidget
使用IntentConfiguration
struct MyWidget: Widget {
let kind: String = "MyWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: Provider.Intent.self, provider: Provider()) { entry in
if #available(iOS 17.0, *) {
MyWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
MyWidgetEntryView(entry: entry)
.padding()
}
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
至此,自定义车控功能配置已全部完成,重新运行程序即可看到效果:
意图扩展也是独立进程,不能访问小组件的内存数据,数据通信需要使用AppGroup
- 每个小组件的配置是独立的,用户设置了小组件的配置,不会影响其他小组件
- 用户一旦设置了配置,代码里便不能更改
小组件和主App通信
小组件和主App是相互独立的进程,内存中的数据和方法不能直接访问。
App Group(共享数据)
两个进程间的数据共享依赖于AppGroup
, AppGroup
是iOS8增加的功能,关于AppGroup的配置可以参考配置AppGroup
可以通过UserDefaults
和FileManager
两种方式实现数据共享,小组件和主App都可以对共享数据进行读取
UserDefaults
UserDefaults(suiteName: "group.com.company.myApp")
FileManager
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.company.myApp")
let fileURL = groupURL?.appendingPathComponent("myApp.txt")
主App向小组件通信
主App活跃时,可以通过WidgetKit
提供的刷新时间线的方法通知小组件时间线刷新
// 刷新所有小组件
WidgetCenter.shared.reloadAllTimelines()
// 根据kind刷新小组件
WidgetCenter.shared.reloadTimelines(ofKind: "widgetKind")
小组件向主App通信
widgetURL和Link
widgetURL
和Link
可以在小组件点击时打开主App,并且将数据通过URL
传递给主App
CFNotificationCenter
CFNotificationCenter是进程间通信的一种方式,但必须使用CFNotificationCenterGetDarwinNotifyCenter
。
public struct NotifyCenter {
/// 发通知
static func post(name: String) {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(), // center
CFNotificationName(rawValue: "AppToWidget" as CFString), // name
nil, // object
nil, // userInfo
true // deliverImmediately
)
}
/// 监听通知
static func observe(name: String, callback: @escaping () -> Void) {
callbacks[name] = callback
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(), // center
nil, // observer
{ _, _, notificationName, _, _ in // callback
if let notificationName {
NotifyCenter.notifyFired(notificationName)
}
},
name as CFString, // name
nil, // object
.deliverImmediately // suspensionBehavior
)
}
private static var callbacks: [String: () -> Void] = [:]
private static func notifyFired(_ notifyName: CFNotificationName) {
let name = notifyName.rawValue as String
callbacks[name]?()
}
}
经测试,CFNotificationCenter
用于小组件和主App通信有以下特点:
- 小组件向主App发通知时,App在前后台都可以收到
- 小组件里也可以接收主App的通知,但是小组件必须在活跃时段
CFNotificationCenterGetDarwinNotifyCenter
不能传递数据,传递数据只能通过AppGroup
小组件点击交互
iOS17以下,点击小组件,只能打开主App,在主App完成交互;iOS 17及以上,可以直接在小组件上完成点击交互。
iOS17以下跳转主App完成交互
widgetURL和Link
默认情况下,点击小组件系统会打开主App。
对于小型组件,iOS17以下,可以为视图设置 widgetURL(_:)
,当点击小组件打开主App时,为主App传递URL
。
对于中、大型组件, 以及iOS17及以上的小型组件,除了widgetURL(_:)
,还可以添加Link
控件,点击Link
控件,同样会打开主App时,并为主App传递URL
点击widgetURL
和Link
打开主App时,会触发主App的回调方法:
- SwiftUI:
onOpenURL(perform:)
- SceneDelegate:
scene(_:, openURLContexts:)
- AppDelegate:
application(_:open:options:)
struct MyWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
Link(destination: URL(string: "myWidget://control?type=lock), label: {
Text("车锁")
})
Link(destination: URL(string: "myWidget://control?type=honkFlast), label: {
Text("寻车")
})
}
.widgetURL(URL(string: "geelycarwidget://"))
}
}
应该只在小组件的视图层次结构中添加一次
widgetURL
,如果添加多个,只有最后添加的才会生效
iOS17及以上在小组件内直接交互
iOS17及以上,AppIntents框架为SwiftUI的Button
和Toggle
这两种控件扩展了初始化方法,支持传入AppIntent
(意图)实例,通过点击控件触发AppIntent
的执行实现小组件上直接交互。
Button
/// SwiftUI
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct Button<Label> : View where Label : View {
/// ...
public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
/// ...
}
/// AppIntents
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension Button {
public init<I>(intent: I, @ViewBuilder label: () -> Label) where I : AppIntent
}
Toggle
/// SwiftUI
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct Toggle<Label> : View where Label : View {
/// ...
public init(isOn: Binding<Bool>, @ViewBuilder label: () -> Label)
/// ...
}
/// AppIntents
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension Toggle {
public init<I>(isOn: Bool, intent: I, @ViewBuilder label: () -> Label) where I : AppIntent
}
ToggleStyle
SwiftUI中对View
扩展了toggleStyle(_:)
,支持定制Toggle
样式:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
public func toggleStyle<S>(_ style: S) -> some View where S : ToggleStyle
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol ToggleStyle {
associatedtype Body : View
@ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
typealias Configuration = ToggleStyleConfiguration
}
View
调用toggleStyle(_:)
可以为子视图内多个Toggle
设置为相同的样式,Toggle
也可以通过toggleStyle(_:)
将自身设置为指定样式。
官方提供的样式
iOS系统上,官方提供了三个遵守ToggleStyle
的结构体:DefaultToggleStyle
、SwitchToggleStyle
、ButtonToggleStyle
,可以分别通过
向toggleStyle(_:)
传入.automatic
、.switch
、.button
.automatic
在不同平台会有不同表现:
Platform | Default style |
---|---|
iOS, iPadOS | switch |
macOS | checkbox |
tvOS | A tvOS-specific button style (see below) |
watchOS | switch |
自定义样式
如果官方提供的样式不满足需求,可以通过实现ToggleStyle
协议来自定义样式。
对于车控业务中的远控指令执行、车辆状态刷新这类需要一定时间的场景,交互效果上控件本身的UI需要切换为中间态来表示执行中。由于小组件页面更新只能来自于时间线刷新,从点击到时间线刷新这段时间内,可点击控件需要显示为中间态,只能选用Toggle
,并且需要自定义ToggleStyle
来实现。
车控指令交互实践
/// 车控按钮样式
struct ControlToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
// 根据Toggle状态true/false定制UI
if configuration.on {
Text("指令发送中")
} else {
Text("车锁")
}
}
}
/// 记录指令发送状态
var isCommandSending = false
/// 指令意图
struct CommandAppIntent: AppIntent {
static var title: LocalizedStringResource = "车控"
/// 默认在子线程执行,可以标注`@MainActor`派发到主线程执行
@MainActor
func perform() async throws -> some IntentResult {
if isCommandSending {
return .result()
}
isCommandSending = true
await executeCommand()
isCommandSending = false
return .result()
}
}
extension CommandAppIntent {
@MainActor
func executeCommand() async {
// 模拟指令发送
await withCheckedContinuation { continuation in
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
continuation.resume()
}
}
}
}
// 小组件页面中创建Toggle控件
Toggle(isOn: isCommandSending, intent: CommandAppIntent()) {
// 这里不需要创建任何视图
}.toggleStyle(ControlToggleStyle())
invalidatableContent
官方描述:
Use this modifier to annotate views that display values that are derived from the current state of your data and might be invalidated in response of, for example, user interaction.
The view will change its appearance when
RedactionReasons.invalidated
is present in the environment.In an interactive widget a view is invalidated from the moment the user interacts with a control on the widget to the moment when a new timeline update has been presented.
由于小组件不支持任何动图(gif、lottie)等, 车控按钮的中间态只能是静态UI。如果希望在点击交互执行过程中有一个动态的效果,可以让某个视图调用invalidatableContent()
来达到持续闪烁的效果,但这种方式有以下缺陷:
-
不能实现点击哪个控件就让哪个控件闪烁
时间线刷新时,某个控件一旦调用了
invalidatableContent()
, 无论哪个控件点击,该控件都会闪烁。因此,对于小组件上有多个车控按钮,不能做到点击哪个按钮,就只让哪个按钮闪烁。 -
点击交互过程中再次点击,闪烁效果会停止
AppIntent
button
和Toggle
的点击交互,都是触发AppIntent
的perform()
,并且AppIntent
支持接收参数。
AppIntent
除了可以应用在小组件上,也可以应用在Siri和快捷指令中,默认情况下,定义一个AppIntent
, 就可以在快捷指令App中添加操作时被找到,添加后,便可以通过快捷指令和Siri执行。
AppIntent
定义:
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public protocol AppIntent : PersistentlyIdentifiable, _SupportsAppDependencies, Sendable {
associatedtype PerformResult : IntentResult
/// 标题,不能为空字符串,小组件里用不上
static var title: LocalizedStringResource { get }
/// 意图执行时,是否自动将应用拉起到前台,默认为`false`。只有意图定义在主App时,设置为`true`才会生效,默认会将应用在后台拉起,如果我们需要应用程序进入前台,需要设置openAppWhenRun为true
static var openAppWhenRun: Bool { get }
/// 意图的执行权限,例如是否允许锁屏执行等
static var authenticationPolicy: IntentAuthenticationPolicy { get }
/// 是否能在快捷指令App和Spotlight中找到,默认为`true`,如果只应用在小组件,需要设置为`false`
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
static var isDiscoverable: Bool { get }
associatedtype SummaryContent : ParameterSummary
/// 参数描述,需要给意图传参时才用到
static var parameterSummary: Self.SummaryContent { get }
/// 描述,小组件里用不上
static var description: IntentDescription? { get }
/// 意图执行的方法体,返回遵守`IntentResult`协议的实例,小组件里`return .result()`即可,方法返回后,会触发时间线刷新
func perform() async throws -> Self.PerformResult
/// 创建AppIntent
init()
}
Siri和快捷指令
如果意图的isDiscoverable
没有设置为false
,那么意图一旦定义,在快捷指令App中添加操作时可以找到该意图。添加之后,可以在快捷指令App中点击执行,也可以通过Siri执行。
iOS17之后,可以实现AppShortcutsProvider
协议,向快捷指令App添加一些AppShortcut
(AppShortcut),用户无需手动添加就可以在快捷指令App里看到:
struct ShortcutsManager: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: CarControlAppIntent(),
phrases: [
"\(.applicationName)车锁"
],
shortTitle: "车锁"
)
AppShortcut(
intent: CarControlAppIntent(),
phrases: [
"\(.applicationName)寻车"
],
shortTitle: "寻车"
)
}
}
每个进程里AppShortcutsProvider
的appShortcuts
里最多放10个AppShortcut
,目前还没找到支持10个以上的方法。