頂置:
這里感謝這兩位博主無私的奉獻!!
popfisher https://www.cnblogs.com/popfisher/p/14719477.html
_SAW_ https://www.jianshu.com/p/55dce7a524f5
一、簡述iOS14桌面小組件
1、只在iOS14及以上版本上支持,只支持SwiftUI來繪制頁面;
2、只提供三種尺寸,大中小;
二、開發不可配置屬性的小組件
1、創建一個APP,OC或者Swift均可;
2、打開新建項目,File > New > Target。選擇 Widget Extension
點擊Next
取好項目名字,這里的配置屬性選項我們暫時不勾。
這樣一個簡單的小組件項目就建好了。
3、代碼解析
// // Widget1.swift // Widget1 // import WidgetKit import SwiftUI // 時間線刷新策略控制 struct Provider: TimelineProvider { // 窗口首次展示的時候,展示默認數據 func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date()) } // 添加組件時的預覽數據,在桌面滑動選擇的時候展示數據 func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date()) completion(entry) } // 時間線刷新策略控制邏輯 func getTimeline(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) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) } } struct SimpleEntry: TimelineEntry { // 默認帶了一個日期參數 let date: Date } struct Widget1EntryView : View { // 組件數據 var entry: Provider.Entry // 這個 body 中就是自己需要實現的組件布局 var body: some View { Text(entry.date, style: .time) } } // 小組件入口 @main struct Widget1: Widget { // 小組件的唯一ID let kind: String = "Widget1" var body: some WidgetConfiguration { // 創建時不勾選 “Include Configuration Intent”,這里使用 StaticConfiguration StaticConfiguration(kind: kind, provider: Provider()) { entry in Widget1EntryView(entry: entry) // 小組件UI } .supportedFamilies([.systemSmall, .systemLarge]) // 配置該組件支持的尺寸,如果不配置,默認是大中小都支持 .configurationDisplayName("組件標題") // 在添加組件預覽界面顯示 .description("組件描述") // 在添加組件預覽界面顯示 } } // 調試預覽 struct Widget1_Previews: PreviewProvider { static var previews: some View { Widget1EntryView(entry: SimpleEntry(date: Date())) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }
基本上不可配置組件就這么多了,在返回的View里面用SwiftUI 來自定義UI就能實現效果了。
三、實現自動每秒刷新的時間UI
先說下小組件的時間刷新機制,叫時間軸
官網解析:https://developer.apple.com/documentation/widgetkit/displaying-dynamic-dates
小組件每天的刷新次數是有上限的,每日預算通常包括四十到六十次刷新,該速率大致可以換算成沒15到60分鍾可以刷新一次,所以如果按照設置時間軸的方式來刷新是不可行的
另辟蹊徑吧,看看蘋果給我們提供的Text顯示的幾個內置方法
1、顯示相對時間,使用該relative
樣式顯示當前日期和時間與指定日期之間的絕對差異,無論日期是未來還是過去。該offset
樣式顯示當前日期和時間與指定日期之間的差異,用減號 ( -
) 前綴表示未來的日期,用加號 ( +
) 前綴表示過去的日期。
let components = DateComponents(minute: 11, second: 14) let futureDate = Calendar.current.date(byAdding: components, to: Date())! Text(futureDate, style: .relative) // Displays: // 11 min, 14 sec Text(futureDate, style: .offset) // Displays: // -11 minutes
2、顯示持續自動更新的計時器,對於未來的日期,timer
樣式會倒計時直到當前時間到達指定的日期和時間,並在日期過去時向上計數。
let components = DateComponents(minute: 15) let futureDate = Calendar.current.date(byAdding: components, to: Date())! Text(futureDate, style: .timer) // Displays: // 15:00
3、要顯示絕對日期或時間:
let components = DateComponents(year: 2020, month: 4, day: 1, hour: 9, minute: 41) let aprilFirstDate = Calendar.current(components)! Text(aprilFirstDate, style: .date) Text("Date: \(aprilFirstDate, style: .date)") Text("Time: \(aprilFirstDate, style: .time)") // Displays: // April 1, 2020 // Date: April 1, 2020 // Time: 9:41AM
4、要顯示兩個日期之間的時間間隔:
let startComponents = DateComponents(hour: 9, minute: 30) let startDate = Calendar.current.date(from: startComponents)! let endComponents = DateComponents(hour: 14, minute: 45) let endDate = Calendar.current.date(from: endComponents)! Text(startDate ... endDate) Text("The meeting will take place: \(startDate ... endDate)") // Displays: // 9:30AM-2:45PM // The meeting will take place: 9:30AM-2:45PM
綜上,我們可以利用 .timer 這個特性來實現按秒刷新,如果當前的時間比指定的時間大,則時間就會累計。
基於這個原理,我們只需要把時間起點定在每天的0點即可,根據當前的時間計算出今天的開始時間。
代碼如下:
public extension Date { var calendar: Calendar { return Calendar(identifier: Calendar.current.identifier) } //年:2020 var year: Int { get { return calendar.component(.year, from: self) } set { guard newValue > 0 else { return } let currentYear = calendar.component(.year, from: self) let yearsToAdd = newValue - currentYear if let date = calendar.date(byAdding: .year, value: yearsToAdd, to: self) { self = date } } } //月份:2 var month: Int { get { return calendar.component(.month, from: self) } set { let allowedRange = calendar.range(of: .month, in: .year, for: self)! guard allowedRange.contains(newValue) else { return } let currentMonth = calendar.component(.month, from: self) let monthsToAdd = newValue - currentMonth if let date = calendar.date(byAdding: .month, value: monthsToAdd, to: self) { self = date } } } //天:10 var day: Int { get { return calendar.component(.day, from: self) } set { let allowedRange = calendar.range(of: .day, in: .month, for: self)! guard allowedRange.contains(newValue) else { return } let currentDay = calendar.component(.day, from: self) let daysToAdd = newValue - currentDay if let date = calendar.date(byAdding: .day, value: daysToAdd, to: self) { self = date } } } } //小組件時間刷新相關 public extension Date { //獲取完整時間,2011:07:13 func getCurrentDayStartHour(_ isDayOf24Hours: Bool)-> Date { let components = DateComponents(year: self.year, month: self.month, day: self.day, hour: 0, minute: 0, second: 0) return Calendar.current.date(from: components)! } } //使用 Text(Date().getCurrentDayStartHour(true), style: .timer)
如果只想顯示時分的話,我這邊的處理方式是通過UI布局的方式隱藏掉秒,遮住一部分Text視圖,效果如圖:
四、實現可配置小組件(靜態)
1、在新建小組件項目的時候,勾選上 可配置屬性
2、分析代碼
多了一個intentdefinition 配置文件,在swift文件里面實現的協議也變成了 IntentTimelineProvider,基本上每個協議方法里面都新增了一個 configuration 參數
新增一個title屬性
項目代碼:
import WidgetKit import SwiftUI import Intents 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 MCHeroEntryView : View { var entry: Provider.Entry @Environment(\.widgetFamily) var family // 尺寸環境變量 @ViewBuilder var body: some View { switch family { case .systemSmall: ZStack{ Image(uiImage: UIImage(named: "runwen")!) .resizable() .scaleEffect() .edgesIgnoringSafeArea(.all) .aspectRatio(contentMode: .fill) Text(entry.configuration.title == nil ? "英雄聯盟" : entry.configuration.title!) .foregroundColor(entry.configuration.isNight == true ? .white : .blue) .offset(x: 50, y: 60) } case .systemMedium: // 中尺寸 Text(entry.date, style: .time) default: Text(entry.date, style: .time) } } } struct MCHero: Widget { let kind: String = "MCHero" var body: some WidgetConfiguration { IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in MCHeroEntryView(entry: entry) } .configurationDisplayName("My Widget") .description("This is an example widget.") } } struct MCHero_Previews: PreviewProvider { static var previews: some View { MCHeroEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent())) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }
有一個要注意的,我這里寫了多個組件,多個組件的實現方式:
其實就是根據@main這個關鍵詞來控制的
效果
3、注意點,如果是先創建了一個不可配置的小組件文件,想為了它加個配置的話,也很簡單,先新建配置文件,右鍵-New File...
點擊底部的+號,新建 New Intent,取好名字,然后配置按照上面復制一份就好了,代碼里面也參照着加上這個字段就好了
值得注意的是項目內的名字是以內部的名字為准
如上圖,就是以右邊的名字為准,代碼里面的也是這個
在我們新增屬性的時候,Xcode會給我們自動生成代碼文件,可以從屬性點進去,新版Xcode很坑,有時候你會發現這個屬性點不出來,因為它沒有自動給我們生成代碼文件,所以需要重啟Xcode才能生效!!!
五、實現可配置小組件(動態)
先按照上面的方式配置一份靜態可配置組件文件
然后在項目中添加”Intens Extension“
1、選擇“File”>“New”>“Target”,然后選擇"Intens Extension"。
2、點擊Next
注意,這里不需要勾選UI選項
3、在新Target的屬性的“Gneral”選項卡中,在“Supported Intents”部分中添加一個條目,並將“Class Name”設置為 "配置文件名". Xcode會自動把xxx.intentdefinition中的配置轉為代碼,放到這個文件中。
這里的名字就是我在上面強調的配置文件的實際名字,這步操作你可以理解為在這里注冊!
4、選中配置文件,在支持文件里面勾選tag
全部勾上
4、選中配置文件,點擊底部➕號,從“類型”彈出菜單中,選擇“New Type”。Xcode在編輯器的“TYPES”中添加了一個新類型
然后再在配置列表里面新增一個該類型的屬性
經過上面的步驟,我們准備好了所有的配置信息,這時候我們編譯一下項目,Xcode會根據xxx.intentdefinition文件生成對應的代碼。
加載列表,打開我們剛剛新建的列表Tag,
import Intents class IntentHandler: INExtension, MCSmallConfigIntentHandling, MCMediumConfigIntentHandling, MCLagreConfigIntentHandling { func provideSmallTypeListOptionsCollection(for intent: MCSmallConfigIntent, with completion: @escaping (INObjectCollection<WidgetTypeSmall>?, Error?) -> Void) { let type_1 = WidgetTypeSmall(identifier: "進度#1", display: "進度#1") type_1.keyName = "進度#1" let type_2 = WidgetTypeSmall(identifier: "進度#2", display: "進度#2") type_2.keyName = "進度#2" let allTimeType = [type_1, type_2] completion(INObjectCollection(items: allTimeType), nil) } func provideMediumTypeListOptionsCollection(for intent: MCMediumConfigIntent, with completion: @escaping (INObjectCollection<WidgetTypeMedium>?, Error?) -> Void) { let type_1 = WidgetTypeMedium(identifier: "進度#1", display: "進度#1") type_1.keyName = "進度#1" let type_2 = WidgetTypeMedium(identifier: "進度#2", display: "進度#2") type_2.keyName = "進度#2" let allTimeType = [type_1, type_2] completion(INObjectCollection(items: allTimeType), nil) } func provideLagreTypeListOptionsCollection(for intent: MCLagreConfigIntent, with completion: @escaping (INObjectCollection<WidgetTypeLagre>?, Error?) -> Void) { let type_1 = WidgetTypeLagre(identifier: "進度#1", display: "進度#1") type_1.keyName = "進度#1" let type_2 = WidgetTypeLagre(identifier: "進度#2", display: "進度#2") type_2.keyName = "進度#2" let allTimeType = [type_1, type_2 ] completion(INObjectCollection(items: allTimeType), nil) } override func handler(for intent: INIntent) -> Any { return self; } }
注意:多個列表的話只需要在tag里面注冊多個配置,實現多個協議方法就行
然后在代碼里面使用
注意事項:在列表里面的配置文件都要選中列表的Tag!
這樣就實現了動態加載列表,效果
六、點擊交互
點擊Widget窗口喚起APP進行交互指定跳轉支持兩種方式:
- widgetURL:點擊區域是Widget的所有區域,適合元素、邏輯簡單的小部件
- Link:通過Link修飾,允許讓界面上不同元素產生點擊響應
Widget支持三種顯示方式,分別是systemSmall、 systemMedium、systemLarge,其中:
- systemSmall只能用widgetURL修飾符實現URL傳遞接收
- systemMedium、systemLarge可以用Link或者 widgetUrl處理
widgetURL和Link使用特點
- widgetURL一個布局中只有一個生效
- Link一個布局中可以有多個
- Link可以嵌套widgetURL, widgetURL可以簽到Link
- Link可以嵌套Link
.widgetURL(URL(string: "medium/widgeturl_root"))
在APPDelegate中接收
//swift func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { } //OC -(BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options{ if ([url.scheme isEqualToString:@"NowWidget"]){ //執行跳轉后的操作 } return YES; }
scheme要在Geniens里面注冊
七、APP與小組件數據共享
1、文件共享
在需要共享的文件,選中相關Tag就好了,如果是OC項目的話,會自動創建橋接文件的,把相關類名放進橋接文件里面導入就好了
2、圖片共享
和文件一樣,把Assets文件共享一下就可以了
3、數據傳遞
通過新建Group的方式,選中主項目的Tag,新建app Groups,勾選上其中一個列表就行,選中小組件項目Tag,同樣創建一個Groups,勾選同樣的一個列表。
使用:
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.bible.gospel.group"]; NSString *content = [userDefaults objectForKey:@"widget"]; [userDefaults setObject:cashStr forKey:@"widget"];
let sharedDefaults = UserDefaults(suiteName: "group.com.bible.gospel.group") let content: String = sharedDefaults?.string(forKey: "widget") ?? ""
差不多了,以后有新的東西再更新吧,看到這里應該可以完成大部分功能了!
總結幾個坑點吧:
1、時間軸概念,要另辟蹊徑實現每秒刷新機制顯示
2、配置文件屬性代碼Xcode沒有自動創建,需要重啟Xcode
3、配置動態列表,注冊完需要Run一下,不然有些協議文件也沒有自動創建
4、多個列表的實現方式,網上基本上找不到資料,自己摸索出來的
5、數據共享的時候,要在兩個項目的Tag里面同時創建Groups,不然不起作用