天天看點

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展示在主螢幕上的示例:

iOS14新特性探索之二: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模闆進行建立,如下圖:

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

建立完成後,Xcode會自動幫我們建立和配置的檔案的工作都完成,預設的模闆為我們建立了一個顯示目前時間的元件,我們可以直接在真機上運作它(Bate版本的Xcode模拟器運作會有些異常),之後,我們就可以将這個顯示時間的小元件放置在主螢幕的任意位置,并且,預設提供了3種尺寸供使用者選擇,如下圖所示:

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

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可以了解為定義了一條時間線,配合官方文檔中的一張圖檔來了解時間線的作用會比較容易:

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

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

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

上圖描述了這樣一種邏輯,首先請求的時間線定義在未來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的檔案,如下圖所示:

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

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

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

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

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

之後,重新運作Widget,我們的小元件就以支援使用者配置功能,使用者可以編輯小元件進行設定,如下圖所示:

iOS14新特性探索之二:App 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開發所使用語言的最終方向。