今天我們要來實作 iOS版本的 home widget
昨天我們已經有說過,flutter 並無法直接撰寫 home widget 的樣式,若要在iOS 上實現必須使用 Swift
才能完成,好險本人還會一點點所以還寫得出來XDD
讓我們複習一下流程,我們需要在應用程式中主動將欲顯示的資訊存至 UserDefaults
中,再由 widget 前去提取達成更新的作用。
不過將資料存入、提取的過程中需要認定一組 app group
的鍵值,請打開 xcode 來設定。請點擊 Runner
中左上角的 +Capability
按鈕,並加入 App Groups
。加入後請點擊 App Groups 下方的 + 來加入新的群組。
群組名稱請取一個易辨認的名稱,同樣也將 Target 切換至 Widget Extensio 加入 App Groups 並勾選同一個項目。
首先請先思考主頁的 widget 想要顯示的內容,這個專案我希望可以用於顯示數篇主頁的「焦點新聞」,因此更新 UserDefaults
的行為就會在主頁的時候觸發。請開啟 HomeScreen
的頁面,我們需要做以下幾個設定,請開啟 home_screen.dart
// xcode 設定的 group name
const String appGroupId = 'group.microNewsTutorial';
// xcode 設定的 widget name
const String iOSWidgetName = 'MicroNewsWidget';
// android studio 設定的 widget name
const String androidWidgetName = 'MicroNewsWidget';
class _HomeScreenState extends State<HomeScreen> {
// 省略 state
@override initState() {
super.initState();
// 綁定 app group id
HomeWidget.setAppGroupId(appGroupId);
}
}
因為我們想要更新數篇焦點新聞,但 widget 的空間有限,因此我們可以篩出新聞標題、新聞來源、新聞圖片即可。
void updateHomeWidget(List<NewsPost> posts) {
// 抽取我們想要的欄位內容 title, cover, source
final List<Map<String, String>> postsData = posts
.map((post) => {
'title': post.title,
'cover': post.cover,
'source': post.source.name,
})
.toList();
// 以 posts 當作 key 儲存至 ios 與 anroid 本機裝置
HomeWidget.saveWidgetData('posts', postsData);
HomeWidget.updateWidget(
iOSName: iOSWidgetName,
androidName: androidWidgetName,
);
}
這麼一來就大功告成拉!
接下來就是重點拉!請用 xcode 或是 vscode 開啟 ios/MicroNewsWidget/MicroNewsWidget.swift
檔案,讓我們先了解一下 ios widget extension 程式架構。
TimelineEntry
是定義於 Apple 推出的 WidgetKit
中的 protocal,其會根據 date
來決定何時進行更新。
// 定義單篇新聞的資料格式
struct NewsArticle {
let title: String
let source: String
let cover: String
}
struct NewsArticleEntry: TimelineEntry {
let date: Date
let showPlaceholder: Bool
// stories 表示要顯示的所有新聞
let stories: [NewsArticle]
}
表示欲顯示的小工具內容,這裡提供 View
的範例,Widget
則使用原始預設的即可。
// 表示實際要顯示的 widget 內容
struct MicroNewsWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
// 未有新聞資訊時,顯示 placeholder
if (entry.showPlaceholder) {
VStack(alignment: .leading) {
Text("焦點新聞").font(.system(size: 14)).foregroundColor(.pink)
Spacer()
Text(entry.stories[0].source).font(.system(size: 14)).foregroundStyle(.gray)
Text(entry.stories[0].title).font(.system(size: 16))
}.redacted(reason: .placeholder)
} else {
VStack(alignment: .leading) {
Text("焦點新聞").font(.system(size: 14)).foregroundColor(.pink)
Spacer()
Text(entry.stories[0].source).font(.system(size: 14)).foregroundStyle(.gray)
Text(entry.stories[0].title).font(.system(size: 16))
}
}
}
}
struct MicroNewsWidget: Widget {
let kind: String = "MicroNewsWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
if #available(iOS 17.0, *) {
MicroNewsWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
MicroNewsWidgetEntryView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
TimelineProvider
是完成顯示內容的核心,其會根據不同的 TimlineEntry
來告訴 widgetkit 何時要呈現什麼樣的工具。
首先執行 getTimeline
函式,接著進入到 getSnapshot
檢視當前時間軸需要顯示的項目,再進行判斷要顯示實際內容還是 placeholder (佔位用於暫時顯示的內容)。
因此我們就要在 getSnapshot
函式中從 UserDefaults
讀取出新聞內容,並呈現出來。
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> NewsArticleEntry {
NewsArticleEntry(date: Date(), showPlaceholder: true, stories: Array(
arrayLiteral: NewsArticle(title: "新聞標題", source: "新聞來源", cover: "")
))
}
func getSnapshot(in context: Context, completion: @escaping (NewsArticleEntry) -> ()) {
let entry: NewsArticleEntry
if context.isPreview{
entry = placeholder(in: context)
} else {
// 根據 app groups id 讀取 user defaults 內容
let userDefaults = UserDefaults(suiteName: "group.microNewsTutorial")
// 將存入的新聞列表取出 (當初以 posts 為鍵值存入,現在就怎麼取出)
let stories = userDefaults?.array(forKey: "posts")
// 將 posts 轉換為 NewsArticle 型態
let newsArticles = stories?.map { story -> NewsArticle in
let story = story as! [String: String]
return NewsArticle(title: story["title"]!, source: story["source"]!, cover: story["cover"]!)
}
entry = NewsArticleEntry(date: Date(), showPlaceholder: false, stories: newsArticles!)
}
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
getSnapshot(in: context) { (entry) in
let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
}
}
}
這時候請執行看看,不過切記 xcode 與 flutter 沒辦法同時都執行,因此擇一使用即可。
看起來很棒吧!美中不足的是受限於小 widget 空間限制,最多顯示一篇新聞已經很緊繃了... 別擔心!iOS 提供了三種樣式的 widget 可供選擇,我們可以根據不同的大小給予不同的顯示效果。
不過由於篇幅的關係,我就不在文章中分別講三種 widget 的樣式寫法,不過根據 widget 大小顯示不同的內容可以從以下部分切入:
struct MicroNewsWidgetEntryView : View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var widgetFamily
var body: some View {
switch widgetFamily {
case .systemSmall:
// 小 widget
MicroNewsSmallWidget(entry: entry)
case .systemMedium:
// 中 widget
MicroNewsMediumWidget(entry: entry)
case .systemLarge:
// 大 widget
MicroNewsLargeWidget(entry: entry)
default:
Text("Unsupported widget family")
}
}
}
詳細的 code 可以在這裡找到,各位可以自行參考。以下顯示成果,很棒對吧!:
我們今天完成了 iOS 版本的 home widget 實作,雖然今天的內容大多是在寫 swift
,不過至少讓我們稍微了解 swift 的語法以及運作模式~ 如果你是程式老手說不定看一下就自己可以魔改了XD 至於 Android 版本的實作,礙於研究時間與篇幅有限QQ 就只能等之後再回來更新了。
今天的內容真的相當困難,因為看起來是在寫 flutter,但卻要同時跨足原生語言才有辦法完整實現 home widget 的實作。然後中間哪個環節卡住了也很難除錯... 不過 home widget 對於使用者在應用程式上的體驗絕對是大大加分的!所以希望今天的內容可以對大家有所幫助😊
今天的參考程式碼:https://github.com/ChungHanLin/micro_news_tutorial/tree/day29/micro_news_app