?最近項(xiàng)目有開發(fā)iOS小組件的需求,開始調(diào)研到實(shí)現(xiàn)踩了很多坑撮慨,借此記錄下來竿痰。
?iOS14系統(tǒng)發(fā)布后,桌面添加的新的"入口模式"(很多產(chǎn)品把這個功能當(dāng)做了App的一個快捷入口)Widget
甫煞。Widget有幾個地方要說下
1.只支持SwiftUI
進(jìn)行界面開發(fā)(意味著你要開始學(xué)習(xí)SwiftUI)
- 小組件刷新機(jī)制
-
Configuration
讓小組件可配置菇曲。
1.創(chuàng)建Widget
File
->New
-> Target
->Widget Extension``->如果你的項(xiàng)目支持可配置的話需要勾選
include Configuration Intent`
IDE創(chuàng)建會一個默認(rèn)模板
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)
}
}
示例代碼主要包含三個重要點(diǎn):Entry
,EntryView
,Porvider
。類比MVC的話抚吠,Entry相當(dāng)于Model負(fù)責(zé)數(shù)據(jù)的轉(zhuǎn)換,EntryView相當(dāng)于view負(fù)責(zé)頁面UI渲染展示弟胀,Porvider相當(dāng)于控制器負(fù)責(zé)邏輯處理楷力。
@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
喊式,需要三個參數(shù)
- kind:widget的唯一標(biāo)識,類似于id
- intent:ConfigurationIntent類型,支持widget配置項(xiàng)
- provider:繼承自
IntentTimelineProvider
的子類
supportedFamilies:小組件有默認(rèn)有Large萧朝,Small岔留,Medium三種樣式,可單獨(dú)指定某種模式检柬,筆者項(xiàng)目里設(shè)置的只支持systemMedium
IntentTimelineProvider
Widget通過provider處理業(yè)務(wù)邏輯
getTimeline
:獲取時間線處理業(yè)務(wù)邏輯献联,通過completion回調(diào)timeline給系統(tǒng),系統(tǒng)重新繪制頁面何址。
timeline支持entryies集合里逆,如果我們知道自己小組件在未來哪些時刻需要刷新頁面,那我們可以事先定義好時間點(diǎn)集合回調(diào)給系統(tǒng)用爪,系統(tǒng)會在對應(yīng)時間進(jìn)行刷新(官方demo就是定義一個每隔5小時進(jìn)行頁面繪制的timeline)
當(dāng)然在這個函數(shù)里可以進(jìn)行數(shù)據(jù)請求或者其他業(yè)務(wù)處理原押。
SwiftUI構(gòu)建界面
以一個例子來簡單介紹下SwiftUI開發(fā)小組件
大致需要
- 背景紅色
- 第一行文字和圖片
- 四行水平文字(widget不支持scroll,所以不能用List偎血,可以根據(jù)數(shù)據(jù)源創(chuàng)建對應(yīng)個數(shù)的)
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設(shè)置子元素之間的距離
HStack(alignment: .center, spacing: 10, content: {
//Spacer().frame(width: 10)占據(jù)10個像素點(diǎn)的位置類似于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)占據(jù)10個像素點(diǎn)的位置類似于right=10的操作
Spacer().frame(width: 10)
})
}
}
var body: some View {
GeometryReader(content: { geometry in
ZStack {
bgColor
// 豎直方向填充4個元素spacing設(shè)置每個元素之間的距離
VStack(alignment: .leading, spacing: 10, content: {
Item()
Item()
Item()
Item()
})
}
})
var body: some View {
GeometryReader(content: { geometry in
ZStack {
bgColor
// VStack將標(biāo)題和列表包裝起來
VStack{
Spacer().frame(height: 10)
// HStack包裝標(biāo)題和副標(biāo)題
HStack{
Spacer().frame(width: 10)
Text("標(biāo)題")
Image("icon")
.frame(width: 20, height: 20)
.clipped()
Spacer()
Text("副標(biāo)題")
Spacer().frame(width: 10)
}
Spacer()
VStack(alignment: .leading, spacing: 10, content: {
Item()
Item()
Item()
Item()
})
Spacer()
}
}
})
}
布局小tips:類似于筆者這樣的界面我比較喜歡用Spacer
填充控件在控件之間,撐滿剩余空間颇玷。靈活使用發(fā)現(xiàn)在屏幕適配方面還是挺好用的笨农,還是要小小的吐槽一下HStack和VStack這樣的控件有借鑒前端FlexBox的布局思想,但是HStack對其方式是VerticalAlignment
帖渠,VStack對其方式是HorizontalAlignment
,最開始開發(fā)的時候讓我很不習(xí)慣磁餐。
網(wǎng)絡(luò)請求
getTimeline方法里進(jìn)行網(wǎng)絡(luò)請求
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
// 定義返回?cái)?shù)據(jù)模型
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
// 請求回來的數(shù)據(jù)包裝成timeline,completion回調(diào)給系統(tǒng)阿弃,小組件界面進(jìn)行刷新操作
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結(jié)束了才會去請求一個新的timeline -
never
:展示一個靜態(tài)的timeline诊霹,不再去主動請求 -
after
:在指定的刷新時間去請求新的timeLine
筆者的項(xiàng)目里用的是after模式設(shè)置1個小時去主動刷新一次timeline,同時宿主app業(yè)務(wù)邏輯變動會手動調(diào)用WidgetCenter.shared.reloadTimelines(ofKind: <#T##String#>)觸發(fā)的因?yàn)閃idgetCenter不支持OC語言直接調(diào)用渣淳,如果宿主APP是OC開發(fā)的脾还,需要添加一個Swift文件進(jìn)行間接調(diào)用
open class WidgetTool: NSObject {
//
@available(iOS 14, *)
@objc open func refreshWidget () {
WidgetCenter.shared.reloadTimelines(ofKind: "你的組件kind")
}
}
數(shù)據(jù)共享
支持Usedefault和FileManager2種方式實(shí)現(xiàn)宿主APP和widget數(shù)據(jù)共享
宿主工程Target和widget中的target添加App Group,Group id保持一致
self.userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.xxxxxxxx"];
[self.userDefaults setObject:value forKey:@"key"];
// widget里面調(diào)用實(shí)現(xiàn)數(shù)據(jù)同步
let userDefault = UserDefaults.init(suiteName: "group.com.xxxxxxxx")
userDefault?.object(forKey: "key")
頁面跳轉(zhuǎn)
- Link(destination: <#URL#>, label: <#() -> _#>)
- widgetURL()
Link(destination: URL(string: "跳轉(zhuǎn)鏈接")) {
Text("標(biāo)題")
}
Text("標(biāo)題").widgetURL( URL(string: "跳轉(zhuǎn)鏈接"))
// 宿主工程openURL方法中進(jìn)行處理
- (BOOL)application:(UIApplication *)app
openURL:(NSURL *)url
options:(NSDictionary<NSString *, id> *)options {
// 在這里處理跳轉(zhuǎn)邏輯入愧,跳轉(zhuǎn)對應(yīng)頁面
}
自定義配置
.intentdefinition文件
-->> Configuration
-->>+按鈕系統(tǒng)分配了很多類型鄙漏,也可以添加自定義的枚舉類型等
configuration里可以拿到具體回調(diào)值
Light和Dark mode適配與控制
我們產(chǎn)品提出了一個需求:支持用戶選擇2種模式
[模式1]官方模式:正常和暗黑模式背景色為白色,字體為黑色
[模式2]系統(tǒng)模式:系統(tǒng)正常模式背景色為白色棺蛛,字體為黑色怔蚌;暗黑模式背景色為黑色,字體為白色
@Environment(\.colorScheme) var colorScheme
可以監(jiān)聽到light和darkmodel的改變旁赊,可以根據(jù)不同的模式定義不同的UI踩身。widget會自動選擇當(dāng)前模式的UI進(jìn)行刷新
var bgColor: some View {
// .both表示當(dāng)前用戶選擇的[模式2]
// [模式1]背景色一直為白色
(theme == .both && colorScheme == .dark) ? Color.black : Color.white
}
github小demo
采坑集錦:
1.創(chuàng)建widget工程名的時候有工程前綴導(dǎo)致報錯
- Widget不支持Scroll宗弯,所以如果要創(chuàng)建類似于列表的界面不能使用
List
,可以通過數(shù)據(jù)源使用HStack
或VStack
容器包裝
3.添加App Groups時品洛,證書簽名選擇的是Automic
,xcode會默認(rèn)自動生成以XC開頭的證書,無法匹配到我們自己手動創(chuàng)建的證書
4.Widget沒有類似于viewWillAppear
方法,不能做到每次出現(xiàn)widget頁面進(jìn)行數(shù)據(jù)更新,可以通過WidgetCenter.shared.reloadTimelines(ofKind: <#T##String#>)
手動刷新
5.Configuration定義的時候炼蛤,key不能有空格,我這里Date Component中間有個空格
- lazy symbol binding failed: can't resolve symbol
給同事的iOS13的手機(jī)打包直接崩潰蝶涩,報錯原因是 不能打開某個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
}