「Widget Extension」等到 iOS 14 才姍姍來遲的 Widget 小工具

Y.H.H
在程式與旅行的路上
16 min readMay 5, 2021

iOS 系列 #7

Photo credit: Alvaro Perez on Unsplash

Widget 這個東西從 Android 不知道幾代的時候就有了,在兩大系統總愛互抄的情況下,過了好幾年,屬於 iOS 的 Widget 才姍姍來遲呀。

那就話不多說,本輯將實作 3 種尺寸的 Widget Family。

Widgets

Apple 有寫了一份很詳細、關於「Widgets」的使用 Guidelines:

Human Interface Guidelines

洋洋灑灑的列了整整一頁,我找出幾個比較需要注意的點:

  1. 只能支援 iOS 14 或以上的版本,同樣地想要開發 Widget,Xcode 版本也必須要在 12 以上(而 Xcode 11 則可以跑模擬器但不能開發)。
  2. 只能用 SwiftUI 開發!!!
  3. 因為 widget 的視窗很小,所以資料最好只有一個主軸,並且清楚、簡單的呈現給使用者看。舉例:不要只有將最小尺寸的 widget 點開後變成大的 widget,讓一筆資料變成滿版;而是要讓它在大的 widget 上呈現更多的資料。
  4. 盡量使用動態資料(資料會依照你設定的時間變更),然後使用者點擊該筆資料會帶到對應的頁面。
  5. 可透過系統更新的時間來更新 widget 裡的資料。
  6. 設計漂亮的 widget,但是不要在上面擺 Logo、商標、App Icon···等。
  7. 因為 Widget 自帶圓角,在設計時要注意邊距及圓角問題。
  8. Widget 的標題及介紹盡可能簡單化且清楚明瞭。
  9. 在不同機型正確顯示 widget 尺寸大小,Apple 有附上表格(圖 1):
圖 1

事前準備工作

猶如一般的專案開發,可以找一份平常在做的專案(有資料的那種),直接將 widget 加入。因為 Widget 是一個外掛的小工具附屬在你的專案上。

本輯透過「mockable.io」建立 3 筆股票 API,先讓表格可以在模擬器正常顯示後就可以開始加入 widget。

Creating a Widget Extension

步驟1:新增「Widget Extension」,點 File > New > Target

圖 2

步驟2:找到 Widget Extension,創建。

圖 3

步驟3:輸入你想要的名稱,「Include Configuration Intent」先不要勾選,這是關於 Widget 是使用 StaticConfiguration 還是使用者可配置的IntentConfiguration,這邊先從基礎開始。

圖 4

這時會彈出第一次建立的視窗,點選「Active」即可。

建立完成後來到左邊的項目,可以看到剛才建立的「Widget Extension」資料夾,以及系統幫你寫好一頁的「.swift」檔案。

圖 5

步驟4:回到專案的「Model」檔、以及會需要下載資料的檔案(取決你自己怎麼放怎麼寫),開啟右邊畫面,找到「Target Membership」勾選你剛才建立的 Widget extension,以及模擬器開啟方式要換成 Widget extension。

圖 6

這時候啟動模擬器,就可以看到系統為你寫好最初的 Widget 了(就是一個時間的顯示)。

Main Components of A Widget

Widget 的組成需要三大元素:「configuration」、「timeline provider」、以及「content closure」。

Photo credit: WidgetKit for iOS — Getting Started

Configuration 可以是 static 或是 intent,且包含了一個獨一無二的「Identifier(kind)」、「timeline provider」、與「content closure」。在 Widget 中,configuration 可以用作設置標題、內容、widget 尺寸、以及背景多工下載資料等工作。

Timeline Provider 把它當作是一個時間的戳記,用於記錄時間,並讓「WidgetKit」進行渲染時讓「Widget」取得時間。

Content closure 則是將畫面返回到 SwiftUI 中,最後再以 Widget 畫面呈現。

由於系統在你創建完 widget 後會自動生成一整頁的 code,所以我將「TimelineEntry」、「TimelineProvider」、還有「Widget Family」拆成三份來寫,這樣子也比較好整理 code。

TimelineEntry

A type that specifies the date to display a widget, and, optionally, indicates the current relevance of the widget’s content. —— by APPLE.

TimelineEntry

這份是用於記錄時間的戳記,只需將日期及你的 data 宣告好即可;這邊另寫了一個 func,裡面先塞好了假資料,之後會用於「provider」檔及主檔的 SwiftUI 顯示。

建立一份「xxxEnrty.swift」檔,這邊要記得把原本的「Target 專案」取消勾選,並勾選「Extension」,建立完後再記得「import WidgetKit」。

圖 7

將系統原本的 code 剪下 > 貼到新的檔案中。

圖 8

寫一個 func,便完成 Entry 檔的建立。

import WidgetKitstruct StockEntry: TimelineEntry {
let date: Date
let stock : [Stock]
static func mockTravelEntry() -> StockEntry{
return StockEntry(date: Date(), stock: [Stock(id: 1, stockName: "台積電", stockNumber: "2330", price: "620", changepct: "5%", volume: "11247")])
}
}

TimelineProvider

A type that advises WidgetKit when to update a widget’s display. —— by APPLE.

TimelineProvider

這個 Protocol 負責了重要的任務,它將控制 widget 重新整理資料、控制資料的重新整理相關方法,裡面贈送了 3 個實作 func。

一樣建立「Provider.swift」,記得把原本的「Target 專案」取消勾選,並勾選「Extension」,建立完後再記得「import WidgetKit」。

將原本主檔裡面的 code 剪下,貼到新的檔案中:

圖 9

placeholder(in:)

這個 func 用於給 widget 提供一個佔位資料,在最一開始的時候假如沒有資料,widget 上會顯示一條灰色的 bar。此外,呼叫此 func 時,WidgetKit 會同步作業,所以需立即回傳一個 timeline entry,因此我們可以在這個 func 裡面先回傳一筆剛才寫的假資料用於 placeholder。

func placeholder(in context: Context) -> StockEntry {
StockEntry.mockTravelEntry()
}

getSnapshot(for:in:completion:)

可理解為用於 Widget 初次啟動渲染時,所要呼叫的資料,可以視為一種初始化資料的動作。因此,我們可以直接將假資料塞回「completion」中。

func getSnapshot(in context: Context, completion: @escaping (StockEntry) -> ()) {
let entry = StockEntry.mockTravelEntry()
completion(entry)
}

getTimeline(in:completion:)

這個 func 用作於讓 WidgetKit 取得 timeline entries 上的時間,取得之後可以接收 entry 陣列,並更新資料及重新整理 timeline 的間隔。在此 func 中有點類似我們下載資料然後再設定一個區間讓它 refresh。

使用 loader 物件來取得 API 資料,並設定一個更新的日期間隔,最後放入 completion 裡面。

Refresh policy 又分為三種方式更新:

  • atEnd: 過了 timeline 中的最後一個時間後,才讓 WidgetKit 重新去請求一個新的 timeline 來更新。
  • after(Date): 指定一個未來的日期讓 WidgetKit 請求一個新的 timeline 來更新。
  • never: 當新的 timeline 可以被請求時,系統會叫 WidgetKit 來進行請求更新。
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {

loader.getStockDetails { (response) in
if(response != nil && response?.result != nil)
{
let currentDate = Date()
let entry = StockEntry(date: currentDate, stock: (response?.result)!)
let refreshDate = Calendar.current.date(byAdding: .minute, value: 60, to: currentDate)!
let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
completion(timeline)
}
}
}

Widget & EntryView

接下來回到一開始系統幫你建立最初的檔案,裡面只會剩下三個 Struct。

struct StockWidgetEntryView : Viewstruct StockWidget: Widgetstruct StockWidget_Previews: PreviewProvider

xxxWidget: Widget

這裡是 Widget 進入的入口,由於我們當初的 Widget 沒有勾選「intent」,因此系統會自動幫我們生成「StaticConfiguration」。

struct StockWidget: Widget {
let kind: String = "StockWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
StockWidgetEntryView(entry: entry)
}
.configurationDisplayName("我的自選")
.description("自選股小工具")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}

將建立的「Provider」檔放入,這邊有 3 個 func 需要呼叫。

  • .configurationDisplayName(“我的自選”) — 顧名思義就是在 Widget 中會顯示的名稱,如圖 10。
  • .description(“自選股小工具”) — 顧名思義就是在 Widget 中會顯示的副標題,如圖 10。
圖 10
  • .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])——這個算是重要項目,關係到你的 App 可以取用哪種大小的 Widget。分別為:Small、Medium、Large,並注意他們是放在陣列中的!

_Previews: PreviewProvider

這個 func 透過呼叫 Entry 裡面 func 假資料,把資料帶入後,在右邊的視窗就可立即用瀏覽模式看你所建立的 widget 長什麼樣。

struct StockWidget_Previews: PreviewProvider {
static var previews: some View {
StockWidgetEntryView(entry: StockEntry.mockTravelEntry())
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}

其中,.previewContext(WidgetPreviewContext(family: .systemSmall)),這裡會要你選擇預覽模式下你要看什麼樣的 Widget 大小。

圖 11

WidgetEntryView : View

回到頂部的 View,因為我想要三種大小的 widget 顯示不同的樣式,所以會在建立三份檔案。

使用 SwiftUI 提供的「@Environment(\.widgetFamily) var family」來組成一個「family」物件,它會去找尋所對應 view。

在 closure 裡用參數「@ViewBuilder」建立對應的 view,這裡我們有三種大小,所以用 switch。

@ViewBuilder
var body: some View {
switch family {
case .systemSmall:
StockSmallView(_stock: entry.stock.first!)
case .systemMedium:
StockMediumView(_stock: entry.stock)

case .systemLarge:
StockLargeView(_stock: entry.stock)
default:
fatalError()
}
}

建立三種大小的 Widget View 檔

New > file > 這邊記得要選「SwiftUI View」檔,不要選到一般的 swift 檔,然後一樣勾選 Extension,Target 不用勾選。

圖 12

建立好之後,import WidgetKit。

這裡一樣會給你「_preview」讓你可以立即見到樣子,一樣先放假資料進去,立即顯示的時候會用裡面的資料作為顯示資料。

struct StockSmallView_Previews: PreviewProvider {
static var previews: some View {
StockSmallView(_stock: Stock(id: 1, stockName: "台積電", stockNumber: "2330", price: "602", changepct: "5%", volume: "21387"))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}

var body: some View { }

這邊就是以往的 hard code 或是 storyboard 所建立的頁面樣式及資料了,那設計這邊就見仁見智,只要記得 SwiftUI 大量使用「VStack」&「HStack」&「ZStack」來包物件,順序是從上至下,從左至右的顯示,這邊就看圖,顯示「mediumSize」的 widget 樣式吧。

第一次碰 SwiftUI 卡了一陣子,但這是以後的趨勢,還是早點學起來吧。

圖 13

另外,由於「medium」&「large」會呈現多筆資料,因此可用「SwiftUI 的 ForEach」來讀取資料,要記得先跑 Foreach,再來建立 View。

建立完 SmallView 後,可直接複製貼上到 Medium、Large,在修改一下就完成本輯的 Widget Extension了。

左邊的小工具
主畫面加入小工具

同步更新於 Git:

--

--

Y.H.H
在程式與旅行的路上

If you walk the footsteps of a stranger, you’ll learn things you never knew you never knew.