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