背景介绍
小组件是App Extension的一种,前身为Today Extension,是iOS8中推出,添加到负一屏的小组件.
在iOS14 中,推出Widget Extension,相较而言,Widget Extension可以添加到桌面,并且支持了更多的尺寸,可以显示更多形态的内容.而且从 iOS17开始, 支持在小组件上点击交互,进一步丰富了小组件能力.
小组件开发依赖的主要官方框架是WidgetKit,界面开发需要使用SwiftUI.
本文将先简单介绍SwiftUI,使读者对小组件的UI开发有一个简单了解. 之后再对小组件的核心功能进行展开介绍.此外,使用 SwiftUI 进行布局设计,可以参考下一篇基于SwiftUI 页面布局设计.
1. SwiftUI 介绍
SwiftUI是苹果2019年推出的构建界面的声明式框架,相对UIKit具有更高的开发效率和更好的可读性等优点. 接下来以Xcode创建的工程模版为切入点,介绍一下SwiftUI开发知识.
1.1 SwiftUI 布局
在 SwiftUI 中进行界面的布局,先介绍几个基本的概念: Stack容器,Spacer(),alignment对齐,间隔spacing, padding
-
Stack
VStack:垂直堆栈,将子视图垂直排列。HStack:水平堆栈,将子视图水平排列。ZStack:层叠视图,将子视图层叠在一起。
-
Spacer()
Spacer()是一个非常有用的布局工具,它允许在水平或垂直堆叠的视图(Stack)中自动填充可用空间。
-
padding:
padding():在视图周围添加内边距。padding(.horizontal)、padding(.vertical):分别在水平或垂直方向添加内边距。padding([.top, .bottom]):在特定方向添加内边距。
-
容器视图中的对齐和间距
alignment:在Stack中对齐子视图。spacing:相邻子视图之间的距离。
在默认情况下
Stack 视图的宽高由子视图的最大宽高决定。
此外,还有一些常用属性和修饰符来调整视图布局:
-
frame:
frame(width:height:):为视图设置固定的宽度和高度。frame(maxWidth: maxHeight:):为视图设置最大宽度和高度。frame(minWidth: minHeight:):为视图设置最小宽度和高度。
-
offset:
offset(x:y:):将视图在父视图内偏移指定的x和y坐标。
-
cornerRadius:
.cornerradius(_:):为视图设置圆角。
-
border:
.border():为视图添加边框。
-
gesture:
.gesture(_:):为视图添加手势识别。
-
onTapGesture:
.onTapGesture():为视图添加点击手势。
2 widget 介绍
小组件里只能使用有限的SwiftUI控件,具体支持小组件的控件可以参考官方文档 SwiftUI views for widgets
2.1 新建小组件工程
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展示多种小组件 - MyWidget: 小组件核心代码从这里开始
4、Xcode中运行 App, 再手动启动一次App,长按桌面 -> 搜索小组件 -> 选择App即可添加小组件
用户必须在安装包含小组件的 App 后至少启动该 App 一次,才能找到小组件.
2.2 小组件框架介绍
2.2.1 WidgetBundle
WidgetBundle的body中返回遵守Widget协议的实例。 WidgetBundle最多可以支持放10个Widget,超过 10 个 widget 会编译报错.
@main
struct MyWidgetBundle: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
MyWidget()
}
}
WidgetBundle和Widget都可以标注@main作为程序入口,如果Widget标注@main,那么整个小组件扩展只有一种小组件.
2.2.2 Widget
Widget是小组件的核心入口,Widget中定义了两个变量 kind 和 body.
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.")
}
}
- kind: 用于标识小组件(主程序刷新小组件时可以根据
kind刷新指定小组件) - Body:
Widget的body中遵守WidgetConfiguration协议的实例。
系统为小组件body提供了三个遵守WidgetConfiguration协议的结构体:
StaticConfiguration:适用于没有用户配置(也就是没有编辑功能)的小组件IntentConfiguration: 适用于可以用户自定义配置的小组件,需要创建SiriKit意图定义文件和Intents ExtensionAppIntentConfiguration: iOS17推出,不需要添加意图定义文件和Intents Extension,可以纯代码实现用户自定义配置的小组件.
自定义配置的小组件: 允许用户编辑小组件。比如日历组件,当你长按小组件时,可以选择编辑小组件按钮.
此外,默认的小组件周围存在白色间距,也叫做安全区域.在布局时,可以配置.contentMarginsDisabled()来禁用该白色区域.
2.2.3 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)
}
}
2.3 小组件中用到的数据结构
介绍一下 TimeLine Provider中 用到的 struct
2.3.1 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
}
2.3.2 TimelineEntry
小组件的数据模型,包含一个必须实现的date属性,告诉WidgetKit何时渲染小组件。可以遵守TimelineEntry为小组件视图提供业务数据。
public protocol TimelineEntry {
var date: Date { get } // 必须实现
var relevance: TimelineEntryRelevance? { get } // 可选的
}
2.3.3 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:永不刷新
3 widget尺寸
3.1 系统支持的尺寸
iPhone上,小组件支持的尺寸包括:small、medium、large, 从 iOS 16 开始,锁屏界面支持 accessoryCircular 、 accessoryRectangular 和 accessoryInline 类型,这几种小尺寸也可以用在手表中。
更多小组件尺寸相关参考: Widget Design
3.2 配置尺寸
通过为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]) // 小、中
}
}
3.3 获取尺寸
小组件任何视图中,通过环境变量(@Environment)获取小组件的尺寸,例如:
struct TestView: View {
@Environment(.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
Text("small")
default:
Text("not small")
}
}
}
4 widget 数据刷新机制
小组件的数据刷新分为两种: 小组件内的刷新机制 , 与App交互的刷新机制
小组件内的刷新机制
在小组件内的刷新中,widgetKit 框架提供了 TimelineProvider ,基于时间线触发刷新。简单来说是系统提供了一个定时器的触发机制.
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// 静态时间线,不会定期更新
let entry = SimpleEntry(date: Date())
let timeline = Timeline(entries: [entry], policy: .never) // 不会自动更新
completion(timeline)
}
但是,系统调用下一次 getTimeLine 事件时间是不确定的,并不是严格按照我们写的时间.此外官方建议entry间隔时间至少5分钟,间隔时间太短需要创建很多entry,如果页面上元素也较多,会消耗大量内存,导致小组件整体页面显示不出来.
此外,在时间线getTimeline内,网络请求、开线程、定时器、收发通知(NotificationCenter)等iOS具备的能力基本上可以正常使用。
TimeLine 刷新策略:
| TimelineReloadPolicy | |
|---|---|
| atEnd | 当前Timeline里面的[entry]事件处理完成以后回再次执行 getTimeline方法 |
| never | 永远不主动请求, 当前entry执行完毕以后 就结束了 |
| after | 设定一个刷新时间,到点请求,可以设置定时触发 |
基于时间线触发刷新的限制:
- 动态分配刷新限制次数:
- 为节省系统资源以及减少电耗,系统会根据用户浏览小组件的频率和次数、小组件上次刷新时间等因素,为小组件动态分配刷新限制次数。
- 数量限制:
- 一个小组件24小时可以获得40-70次刷新,时间线刷新策略的时间设置大概是15-60分钟。
- 刷新时间限制:
getTimeLine到completion必须在28s以内完成,否则刷新超时.小组件的时间线刷新是串行的,下一次getTimeLine一定会在上一次completion之后,如果getTimeLine到completion超时,当次刷新会被丢弃,等待下一次刷新
- 多个组件的刷新限制次数是独立的:
- 如果添加了多个小组件,虽然这些小组件同属一个进程,但刷新限制次数是独立的。
- 不会记入刷新次数的情况:
- 主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 }
if let widget = widgets.first(
where: { widget in
let intent = widget.configuration as? SelectCharacterIntent
return intent?.character == characterThatReceivedHealingPotion
}
) {
WidgetCenter.shared.reloadTimelines(ofKind: widget.kind)
}
}
5 小组件与 App 通讯
小组件和主App是相互独立的进程,内存中的数据和方法不能直接访问。两个进程间的数据依赖于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")
5.1 小组件向主App通信
widgetURL和Link
widgetURL和Link可以在小组件点击时打开主App,并且将数据通过URL传递给主App
- widgetUrl 是针对整个小组件 点击小组件响应(如果有Link 就响应Link)
- LinK 给元素添加点击事件, Link 对 systemSmall样式的组件不生效(systemSmall 样式的小组件只响应widgetUrl)
点击widgetURL和Link打开主App时,会触发主App的回调方法:
- SwiftUI:
onOpenURL(perform:) - SceneDelegate:
scene(_:, openURLContexts:) - AppDelegate:
application(_:open:options:)
CFNotificationCenter
CFNotificationCenter是进程间通信的一种方式,但必须使用CFNotificationCenterGetDarwinNotifyCenter。CFNotificationCenterGetDarwinNotifyCenter不能传递数据,传递数据只能通过AppGroup.
5.2 主 App 向小组件通信
可参考 4 中的与App 交互的刷新机制,使用 AppGroup 和 WidgetCenter.shared.reloadTimelines,来实现主 App 向小组件通信.
6: iOS17及以上在小组件上直接交互
iOS17以下,点击小组件,只能打开主App,在主App完成交互.
iOS 17及以上,可以直接在小组件上完成点击交互。使用AppIntents框架,可以为SwiftUI的Button和Toggle 这两种控件扩展初始化方法,通过点击控件触发AppIntent的执行实现小组件上直接交互。
需要注意,可交互的组件仅支持 Button 和 Toggle 组件,其他控件都不起作用。
⚠️注意
现象: 如果删除小组件的代码, 更新完 app 时,会观察到老的widget 不会删除,而是变成白色背景.
1. 是否可以 programmable 移除小组件
总结:
- 修改widget UI 时,更新完 APP 后,widget 会更新成新的 UI
- 删除 target 时,老的widget 不会删除,而是变成白色背景.
- .supportedFamilies([])时,apple 会保留原来的 widget,但点击没有反应
2.supportedFamilies 支持的小组件变化(非空)
- .supportedFamilies([.systemMedium,.systemSmall]) --> .supportedFamilies([.systemSmall]) 系统会自动删除不支持的小组件