iOS14 - Widget桌面小組件(看我就夠了!!!)


頂置:

這里感謝這兩位博主無私的奉獻!!

 

popfisher    https://www.cnblogs.com/popfisher/p/14719477.html
_SAW_     https://www.jianshu.com/p/55dce7a524f5

 

 
我把所有的內容都寫在這一篇里面了,除了SwiftUI 布局知識,看我這一篇就夠了,可以做出一個完整的小組件項目, 包含時間實時顯示,多組件展示,動態列表
 開始吧!!!

 

一、簡述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,不然不起作用

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM