最近项目有开发iOS小组件的需求,开始调研到实现踩了很多坑,借此记录下来。
iOS14系统发布后,桌面添加的新的"入口模式"(很多产品把这个功能当做了App的一个快捷入口)Widget
。Widget有几个地方要说下
1.只支持SwiftUI
进行界面开发(意味着你要开始学习SwiftUI)
- 小组件刷新机制
-
Configuration
让小组件可配置。
1.创建Widget
File
->New
-> Target
->Widget Extension``->如果你的项目支持可配置的话需要勾选
include Configuration Intent`
image.png
image.png
IDE创建会一个默认模板
struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationIntent())
}
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), configuration: configuration)
completion(entry)
}
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
}
struct MSWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
示例代码主要包含三个重要点:Entry
,EntryView
,Porvider
。类比MVC的话,Entry相当于Model负责数据的转换,EntryView相当于view负责页面UI渲染展示,Porvider相当于控制器负责逻辑处理。
@main
struct MSWidget: Widget {
let kind: String = "MSWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
MSWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
.supportedFamilies([,.systemMedium])
}
}
@main说明是小组件的入口
IntentConfiguration
,需要三个参数
- kind:widget的唯一标识,类似于id
- intent:ConfigurationIntent类型,支持widget配置项
- provider:继承自
IntentTimelineProvider
的子类
supportedFamilies:小组件有默认有Large,Small,Medium三种样式,可单独指定某种模式,笔者项目里设置的只支持systemMedium
IntentTimelineProvider
Widget通过provider处理业务逻辑
getTimeline
:获取时间线处理业务逻辑,通过completion回调timeline给系统,系统重新绘制页面。
timeline支持entryies集合,如果我们知道自己小组件在未来哪些时刻需要刷新页面,那我们可以事先定义好时间点集合回调给系统,系统会在对应时间进行刷新(官方demo就是定义一个每隔5小时进行页面绘制的timeline)
当然在这个函数里可以进行数据请求或者其他业务处理。
SwiftUI构建界面
以一个例子来简单介绍下SwiftUI开发小组件
image.png
大致需要
- 背景红色
- 第一行文字和图片
- 四行水平文字(widget不支持scroll,所以不能用List,可以根据数据源创建对应个数的)
struct Page1: View {
var bgColor : some View {
Color.red
}
var body: some View {
// 获取屏幕自身尺寸
GeometryReader(content: { geometry in
// ZStack叠加背景色和文字
ZStack {
bgColor
Item()
}
})
}
}
struct Item : View {
var body: some View {
// HStack水平方向集合包装容器,spacing设置子元素之间的距离
HStack(alignment: .center, spacing: 10, content: {
//Spacer().frame(width: 10)占据10个像素点的位置类似于left=10的操作
Spacer().frame(width: 10)
Text("第1个Text")
.font(.system(size: 14))
.foregroundColor(.white)
Text("第2个Text")
.font(.system(size: 14))
.foregroundColor(.white)
Text("第3个Text")
.font(.system(size: 14))
.foregroundColor(.white)
// Spacer()填充水平方向剩余空间
Spacer()
Text("第4个Text")
.font(.system(size: 14))
.foregroundColor(.white)
//距离Spacer().frame(width: 10)占据10个像素点的位置类似于right=10的操作
Spacer().frame(width: 10)
})
}
}
var body: some View {
GeometryReader(content: { geometry in
ZStack {
bgColor
// 竖直方向填充4个元素spacing设置每个元素之间的距离
VStack(alignment: .leading, spacing: 10, content: {
Item()
Item()
Item()
Item()
})
}
})
image.png
var body: some View {
GeometryReader(content: { geometry in
ZStack {
bgColor
// VStack将标题和列表包装起来
VStack{
Spacer().frame(height: 10)
// HStack包装标题和副标题
HStack{
Spacer().frame(width: 10)
Text("标题")
Image("icon")
.frame(width: 20, height: 20)
.clipped()
Spacer()
Text("副标题")
Spacer().frame(width: 10)
}
Spacer()
VStack(alignment: .leading, spacing: 10, content: {
Item()
Item()
Item()
Item()
})
Spacer()
}
}
})
}
image.png
布局小tips:类似于笔者这样的界面我比较喜欢用Spacer
填充控件在控件之间,撑满剩余空间。灵活使用发现在屏幕适配方面还是挺好用的,还是要小小的吐槽一下HStack和VStack这样的控件有借鉴前端FlexBox的布局思想,但是HStack对其方式是VerticalAlignment
,VStack对其方式是HorizontalAlignment
,最开始开发的时候让我很不习惯。
网络请求
getTimeline方法里进行网络请求
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
// 定义返回数据模型
let response : Any
}
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let currentDate = Date()
let session = URLSession.shared
let url = URL(string: "https://")
guard let u = url else { return }
var request = URLRequest(url: u)
request.httpMethod = "GET"
request.timeoutInterval = 20
let dataTask = session.dataTask(with: request) { (data, response, error) in
// 请求回来的数据包装成timeline,completion回调给系统,小组件界面进行刷新操作
let currentDate = Date()
let updateDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!
let entry = SimpleEntry(date: currentDate, configuration: configuration,response: data)
let timeline = Timeline(entries: [entry], policy:.after(updateDate))
completion(timeline)
}
dataTask.resume()
}
官方提供了3中刷新策略
-
atEnd
:最近的timeline结束了才会去请求一个新的timeline -
never
:展示一个静态的timeline,不再去主动请求 -
after
:在指定的刷新时间去请求新的timeLine
笔者的项目里用的是after模式设置1个小时去主动刷新一次timeline,同时宿主app业务逻辑变动会手动调用WidgetCenter.shared.reloadTimelines(ofKind: <#T##String#>)触发的因为WidgetCenter不支持OC语言直接调用,如果宿主APP是OC开发的,需要添加一个Swift文件进行间接调用
open class WidgetTool: NSObject {
//
@available(iOS 14, *)
@objc open func refreshWidget () {
WidgetCenter.shared.reloadTimelines(ofKind: "你的组件kind")
}
}
数据共享
支持Usedefault和FileManager2种方式实现宿主APP和widget数据共享
宿主工程Target和widget中的target添加App Group,Group id保持一致
image.png
self.userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.xxxxxxxx"];
[self.userDefaults setObject:value forKey:@"key"];
// widget里面调用实现数据同步
let userDefault = UserDefaults.init(suiteName: "group.com.xxxxxxxx")
userDefault?.object(forKey: "key")
页面跳转
- Link(destination: <#URL#>, label: <#() -> _#>)
- widgetURL()
Link(destination: URL(string: "跳转链接")) {
Text("标题")
}
Text("标题").widgetURL( URL(string: "跳转链接"))
// 宿主工程openURL方法中进行处理
- (BOOL)application:(UIApplication *)app
openURL:(NSURL *)url
options:(NSDictionary<NSString *, id> *)options {
// 在这里处理跳转逻辑,跳转对应页面
}
自定义配置
image.png.intentdefinition文件
-->> Configuration
-->>+按钮系统分配了很多类型,也可以添加自定义的枚举类型等
image.png
configuration里可以拿到具体回调值
image.png
Light和Dark mode适配与控制
我们产品提出了一个需求:支持用户选择2种模式
[模式1]官方模式:正常和暗黑模式背景色为白色,字体为黑色
[模式2]系统模式:系统正常模式背景色为白色,字体为黑色;暗黑模式背景色为黑色,字体为白色
@Environment(\.colorScheme) var colorScheme
可以监听到light和darkmodel的改变,可以根据不同的模式定义不同的UI。widget会自动选择当前模式的UI进行刷新
var bgColor: some View {
// .both表示当前用户选择的[模式2]
// [模式1]背景色一直为白色
(theme == .both && colorScheme == .dark) ? Color.black : Color.white
}
github小demo
采坑集锦:
1.创建widget工程名的时候有工程前缀导致报错
image.png
- Widget不支持Scroll,所以如果要创建类似于列表的界面不能使用
List
,可以通过数据源使用HStack
或VStack
容器包装
image.png
3.添加App Groups时,证书签名选择的是Automic
,xcode会默认自动生成以XC开头的证书,无法匹配到我们自己手动创建的证书
image.png
4.Widget没有类似于viewWillAppear
方法,不能做到每次出现widget页面进行数据更新,可以通过WidgetCenter.shared.reloadTimelines(ofKind: <#T##String#>)
手动刷新
5.Configuration定义的时候,key不能有空格,我这里Date Component中间有个空格
image.png
image.png - lazy symbol binding failed: can't resolve symbol
给同事的iOS13的手机打包直接崩溃,报错原因是 不能打开某个dylid,查了很多原因后来突然想到小组件只支持iOS14以后,需要添加iOS14的版本判断
// swift
if #available(iOS 14.0, *) {
} else {
// Earlier version of iOS
}
// OC
if (@available(iOS 14.0, *)) {
}else {
// Earlier version of iOS
}
网友评论