iOS14新特性探索之二:App Widget小組件應用

iOS14新特性探索之二:App Widget小組件應用

        iOS 14除了引入了亮眼的App Clips功能外。還有一個也非常惹爭議的功能就是App Widget。App Widget可以理解爲小組件,在非常早的Android版本中就有了Widget的概念,應用開發者可以爲系統開發自己應用相契合的Widget來讓用戶更加方便的使用應用提供的功能。例如Android早期系統中非常常見的鐘表時間組件、快捷設置組件等。用戶可以將這些小組件根據自己的喜好放在屏幕的指定位置。從這點看,iOS 14提供的App Widget功能的確不能算是一種創新,最多算是一種增強。

        其實,iOS Widget的概念並非是iOS 14突然引入的,在iOS 10發佈時,iOS系統就引入了Extension相關功能,其中有一種Extension叫做Today Extension,這就是iOS 14中Widget的前身。Today Extension允許開發者爲負一屏開發快捷功能入口。關於Today Extension的應用,如下博客有詳細的介紹:

iOS8新特性擴展(Extension)應用之一——Today擴展:https://my.oschina.net/u/2340880/blog/485533

iOS中Today擴展插件與宿主APP的交互:https://my.oschina.net/u/2340880/blog/711807

需要注意,在iOS 14中,Today Extension相關的接口都已經被廢棄,我們需要使用新的WidgetKit框架提供的小組件接口開發Widget。在iOS 14上,Today Extension依然可以使用,但是其功能受限,只能在負一屏展示它,用戶不能隨意的將其放在指定屏的指定位置。

1. 關於App Widget

        Widget爲應用程序提供了這樣一種功能:其可以讓用戶在主屏幕上展示App中用戶所關心的信息。例如一款天氣軟件,其可以附帶一個Widget讓用戶在主屏幕就可查看今日的天氣情況,例如股票相關的軟件,用戶將自己感興趣的股票收藏,無需打開App,在主屏幕即可查到對應的股價信息。如下圖所示,是系統提供的電池Widget展示在主屏幕上的示例:

一個App也可以提供多個Widget組件,用戶可以選擇將其最關心的放置在最重要的位置上,以便最方便的獲取信息。對於同一種Widget組件,開發者也可以提供不同的尺寸或不同的佈局,這可以提供給用戶更多的選擇以滿足不同用戶的偏好。

        爲應用程序添加一個Widget組件並不複雜,但是有一點需要注意,小組件的UI部分只能夠使用SwiftUI來開發,因此如果你要開發Widget組件,必須有一些Swift的基礎並對SwiftUI有一定的瞭解。對於Swift與SwiftUI的相關內容,本篇博客就不再做過多贅述。

2. 創建App Widget

        與其他的Extension擴展類似,App Widget本身也是一種擴展,因此其只能依賴一個宿主App而存在,首先向已有的App中添加App Widget非常簡單,爲項目創建一個新的Target,選擇其中的Widget Extension模板進行創建,如下圖:

        

創建完成後,Xcode會自動幫我們創建和配置的文件的工作都完成,默認的模板爲我們創建了一個顯示當前時間的組件,我們可以直接在真機上運行它(Bate版本的Xcode模擬器運行會有些異常),之後,我們就可以將這個顯示時間的小組件放置在主屏幕的任意位置,並且,默認提供了3種尺寸供用戶選擇,如下圖所示:

Xcode爲我們創建的這個模板雖然簡單,但是五臟俱全。Widget加載的入口是@main標記的結構體,代碼如下:

@main
struct WidgetExt: Widget {
    private let kind: String = "WidgetExt"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider(), placeholder: PlaceholderView()) { entry in
            WidgetExtEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

WidgetExt是我們爲組件target項目設置的名字,模板自動使用這個名字幫我們生成了一個實現了Widget協議的結構體。結構體中實現了兩個屬性,其實Widget協議提供的核心只讀屬性只有一個body,將上面的代碼改寫如下也是一樣的:

@main
struct WidgetExt: Widget {
    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: "WidgetExt", provider: Provider(), placeholder: PlaceholderView()) { entry in
            WidgetExtEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

上面代碼的核心在於body只讀屬性的實現,其需要返回一個實現了WidgetConfiguration協議的示例。這個協議描述了組件的配置信息,StaticConfiguration是系統提供的組件配置結構體,其用來對靜態類型的組件提供配置。StaticConfiguration完整的構造方法如下:

public init<Provider, PlaceholderContent>(
kind: String, 
provider: Provider, 
placeholder: PlaceholderContent, 
content: @escaping (Provider.Entry) -> Content) 
where Provider : TimelineProvider, PlaceholderContent : View

可以看到,上面構造方法中的Provider和PlaceholderContent實際上是兩個泛型,我們後面再介紹。目前,我們先關注下構造方法需要傳的幾個參數。

  • kind:這個參數是一個字符號,我們可以任意提供,用來標識這個Widget組件。
  • provider:簡單理解,這是一個數據提供對象,用來爲小組件提供渲染數據,其必須實現TimelineProvider協議,即是基於時間線來驅動小組件的渲染。
  • placeholder:提供一個佔位的視圖,當小組件沒有數據或者在鎖屏狀態時,會顯示這個佔位視圖。
  • content:爲小組件提供內容,是一個閉包,其中會把Provider的entry屬性傳入,因此小組件的視圖渲染實際是由Provider驅動的。

    明白了上面幾個參數的意義,開發小組件就非常輕鬆了。首先,需要創建一個合適的Provider來爲小組件提供數據支持,以模板中的代碼爲例,如下:

struct Provider: TimelineProvider {
    public typealias Entry = SimpleEntry

    public func snapshot(with context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

    public func timeline(with 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 {
    public let date: Date
}

如上代碼所示,Provider結構體實現了TimelineProvider協議,這個協議中只定義了兩個方法,分別是上面實現的snapshot方法和timeline方法。

        其中snapshop方法在小組件啓動時會被調用一次,用來爲小組件提供首屏渲染所需要的數據,其通常用來提供一些初始化的數據。調用完snapshot方法後,會調用timeline方法來定義要更新組件的時間線,這個方法的回調中需要傳入一組Timeline對象,如上代碼所示,其定義當前時刻開始,每隔一個小時進行一次刷新,將當前組件顯示的時間刷新成最新的時刻,當最後一次刷新任務結束後,會再次調用timeline函數重新設置一組更新的時間線。關於時間線的詳細介紹,後面會提及。

        有了Provider來對組件的更新提供驅動後,就是小組件頁面的渲染了,在StaticConfiguration構造方法的閉包中,我們需要返回一個View作爲小組件的內容,模板提供的示例代碼如下:

struct WidgetExtEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

        在向主屏幕添加小組件時,用戶可以選擇不同尺寸的小組件進行添加,在小組件的渲染布局時,開發者也可以根據不同的環境尺寸配置不同的渲染策略,例如下面代碼:

struct WidgetExtEntryView : View {
    @Environment(\.widgetFamily) var family: WidgetFamily
    
    var entry: Provider.Entry
    
    @ViewBuilder
    var body: some View {
        switch family {
        case .systemSmall: Text(entry.date, style: .time)
        case .systemMedium: Text(entry.date, style: .date)
        case .systemLarge: Text(entry.date, style: .relative)
        default: Text(entry.date, style: .time)
        }
    }
}

其中通過Enviroment用來判斷當前組件的環境情況,即組件的尺寸信息,上面代碼根據不同的尺寸渲染了不同格式的時間。

      現在,我們對小組件的創建流程已經有了初步的瞭解,需要注意,小組件只能用來展示靜態的信息,並能支持可交互的組件,例如選擇器或滾動視圖,當用戶點擊小組件時,會喚起App本身,並傳遞一個特殊的URL用來給宿主App做邏輯處理。一個App只能創建一個App Widget,但這並不是說我們只能有一種功能類型的組件,可以通過定義組件包,來提供多個小組件供用戶進行使用,示例如下:

struct WidgetExt: Widget {
    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: "WidgetExt", provider: Provider(), placeholder: PlaceholderView()) { entry in
            WidgetExtEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct WidgetExt2: Widget {
    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: "WidgetExt2", provider: Provider(), placeholder: PlaceholderView()) { entry in
            PlaceholderView()
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

@main
struct WidgetsExt: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        WidgetExt()
        WidgetExt2()
    }
}

需要注意,不同的小組件定義的kind參數要有差異。

3. App Widget 的更新機制

        通過前面的Widget初體驗,我們知道App Widget可以通過定義時間線來實現視圖的動態更新。App Widget使用SwiftUI來進行視圖的渲染。Widget有單獨的系統進程進行維護,因此即便小組件已經顯示在屏幕上,其也並不是一直都是活躍的,開發者可以定義一些時機來對小組件的內容進行更新。

        首先,在開發小組件時,我們要清楚所需要的更新時機。例如對於天氣類小組件,可能需要每3小時對組件進行一次更新。當我們定義小組件Widget時,需要指定一個TimelineProvider來對其更新進行驅動,TimelineProvider可以理解爲定義了一條時間線,配合官方文檔中的一張圖片來理解時間線的作用會比較容易:

如上圖中所示,其定義時間線爲之後每小時進行刷新,由於將時間線的Refresh機制設置爲了atEnd,3小時後系統會重新請求新的Timeline策略,上圖中將第2次請求Timeline策略是設置爲了立即刷新一次,之後由於時間線的Refresh機制設置爲了never,之後不會再嘗試請求時間線進行組件更新。時間軸的Refresh選項實際上是設置了當已經定義的時間軸執行完成後,系統將採用怎樣的策略(是重新請求還是從此結束更新)。例如下圖:

上圖描述了這樣一種邏輯,首先請求的時間線定義在未來3個小時,每小時更新一次,並在2小時候重新請求時間線,2小時後新請求的時間線定義2小時後刷新Widget並指定了2小時候重新請求時間線,再2小時之後,重新請求的時間線定義立即刷新組件,並指定之後不再請求新的時間線,組件刷新從此結束。

        除了通過設置Timeline的Refresh機制讓Widget請求時間線來進行刷新機制的定義外,宿主App也可以對Widget的刷新機制進行定義。宿主App可以使用WidgetCenter來觸發指定Widget的刷新機制更新,如下:

WidgetCenter.shared.reloadTimelines(ofKind: "指定的widget的kind")

同樣,WidgetCenter目前也只能使用Swift來調用。

        順便提一下,關於WidgetCenter,其本身非常簡單,提供的接口非常精簡,如下:

// 獲取單例對象
static let shared: WidgetCenter 
// 獲取當前Widgets的用戶自定義配置
/*
struct WidgetInfo {
    public let configuration: INIntent?
    public let family: WidgetFamily
    public let kind: String
}
*/
func getCurrentConfigurations((Result<[WidgetInfo], Error>) -> ())
// 重新刷新某個Widget的時間線
func reloadTimelines(ofKind: String)
// 刷新所有Widget的時間線
func reloadAllTimelines()

4. 可配置的Widget組件

        前面我們所介紹的構建小組件的方式,雖然可以通過時間線做部分更新邏輯,但對用戶來說,依然是靜態的。用戶不能夠根據自己的偏好對組件進行配置,還以天氣類組件爲例,有些用戶可能關心的是空氣質量,溼度等信息,有些用戶可能只關心陰天雨天的信息,由於小組件的顯示空間有限,有時候你無法將所有的信息都展示在組件內,因此讓用戶選擇他感興趣的信息進行小組件的配置非常重要。

        首先,如果要讓我們開發的Widget可以支持用戶配置,需要在Widget的target工程中添加一個配置屬性表文件,使用Xcode新建一個SiriKit Intent Definition File的文件,如下圖所示:

之後,需要創建一個新的Intent配置,如下圖所示:

之後,我們可以添加一系列的用戶配置項,系統提供了各種類型的配置項,如讓用戶傳入字符串信息的配置項,開關配置項,日期配置項等等,如下圖:

之後,重新運行Widget,我們的小組件就以支持用戶配置功能,用戶可以編輯小組件進行設置,如下圖所示:

當用戶修改了配置項後,組件會重新請求Timeline時間線,在timeline回調方法中,會傳入configuration對象,用來存儲用戶的配置信息,如下:

    public func timeline(for configuration: ConfigurationIntent, with context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        // configuration中存放用戶配置信息
        var entries: [SimpleEntry] = []
        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)
    }

上面演示的這種配置方式,適用於當配置項固定的場景,更多時候,可能連配置項都是動態的,比如我們的應用會根據服務端的狀態來提供不同的服務,這時可提供給用戶開啓的服務項目就是動態的。Widget的配置項也支持動態進行配置,這需要使用到Intents Extension的相關功能,本篇博客就不再過多介紹。

結語:

        App Widgets本身並沒有什麼新意,只是擴大了iOS系統中組件的能力,這從一定程度上可以帶給用戶更好的服務和更多元的交互體驗。脫離App Widgets這個功能的產品意義本身,iOS 14推出這個功能還有一點非常令人驚訝,就是App Widgets只能使用SwiftUI進行開發,這或許從另一個角度暗示了Swift在未來的推廣力度,與iOS開發所使用語言的最終方向。

專注技術,熱愛生活,交流技術,也做朋友。

——琿少 QQ羣:805263726

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章