頂置:
這里感謝這兩位博主無私的奉獻!!
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,不然不起作用
