在 Flutter 應用程式中加入主畫面小工具

1. 簡介

什麼是小工具?

對 Flutter 開發人員而言,widget 的常見定義是指使用 Flutter 架構建立的 UI 元件。在本程式碼研究室的情境中,小工具是指應用程式的迷你版本,可讓您在「不開啟」應用程式的情況下查看應用程式的資訊。在 Android 裝置上,小工具會顯示在主畫面上。將 iOS 裝置新增到主畫面、螢幕鎖定畫面或今日檢視畫面。

f0027e8a7d0237e0.png b991e79ea72c8b65.png

小工具的複雜程度為何?

大多數的主畫面小工具都相當簡單。可能包含基本文字、簡單的圖像或 Android 裝置的基本控制項。Android 和 iOS 都會限制您可使用的 UI 元件和功能。

819b9fffd700e571.png 92d62ccfd17d770d.png

建立小工具的 UI

基於這些 UI 限制,您無法使用 Flutter 架構直接繪製主畫面小工具的 UI。您可以在 Flutter 應用程式中加入透過 Jetpack Compose 或 SwiftUI 等平台架構建立的小工具。本程式碼研究室會探討如何在應用程式和小工具之間共用資源的範例,避免重寫複雜的 UI。

建構項目

在本程式碼研究室中,您將使用 home_widget 套件在 Android 和 iOS 上為簡易的 Flutter 建構主畫面小工具,讓使用者能夠閱讀文章。您的小工具將:

  • 顯示 Flutter 應用程式的資料。
  • 使用從 Flutter 應用程式共用的字型素材資源顯示文字。
  • 顯示轉譯的 Flutter 小工具圖片。

a36b7ba379151101.png

這個 Flutter 應用程式包含兩個畫面 (或路徑):

  • 第一項會顯示包含標題和說明的新聞報導清單,
  • 第二則顯示完整文章和使用 CustomPaint 建立的圖表。

9c02f8b62c1faa3a.png d97d44051304cae4.png

學習目標

  • 如何在 iOS 和 Android 上建立主畫面小工具。
  • 如何使用 home_widget 套件在主畫面小工具和 Flutter 應用程式之間共用資料。
  • 如何減少需要重新編寫的程式碼數量。
  • 如何從 Flutter 應用程式更新主畫面小工具。

2. 設定開發環境

這兩種平台都需要 Flutter SDKIDE。您可以使用偏好的 IDE 來執行 Flutter。這可以是含有 Dart Code 和 Flutter 擴充功能的 Visual Studio Code,或已安裝 Flutter 和 Dart 外掛程式的 Android Studio 或 IntelliJ。

如何建立 iOS 主畫面小工具:

  • 您可以在實體 iOS 裝置或 iOS 模擬器上執行本程式碼研究室。
  • 您必須使用 Xcode IDE 設定 macOS 系統。這會安裝建構 iOS 版本應用程式所需的編譯器。

如何建立 Android 主畫面小工具:

  • 您可以在實體 Android 裝置或 Android 模擬器上執行本程式碼研究室。
  • 您必須使用 Android Studio 設定開發系統。這會安裝建構 Android 版本應用程式所需的編譯器。

取得範例程式碼

從 GitHub 下載專案的初始版本

在指令列中,將 GitHub 存放區複製到 flutter-codelabs 目錄:

$ git clone https://github.com/flutter/codelabs.git flutter-codelabs

複製存放區後,您可以在 flutter-codelabs/homescreen_codelab 目錄中找到本程式碼研究室的程式碼。這個目錄包含程式碼研究室每個步驟中完成的專案程式碼。

開啟範例應用程式

開啟 flutter-codelabs/homescreen_codelab/step_03 目錄到想用的 IDE。

安裝套件

所有必要套件都已新增至專案的 pubspec.yaml 檔案。如要擷取專案依附元件,請執行下列指令:

$ flutter pub get

3. 新增基本的主畫面小工具

首先,請使用原生平台工具新增主畫面小工具。

建立基本的 iOS 主畫面小工具

在 Flutter iOS 應用程式中加入應用程式額外資訊,類似在 SwiftUI 或 UIKit 應用程式中新增應用程式額外資訊:

  1. 從 Flutter 專案目錄的終端機視窗中執行 open ios/Runner.xcworkspace。您也可以在 VSCode 中的「ios」資料夾上按一下滑鼠右鍵,然後選取「Open in Xcode」。系統隨即會開啟 Flutter 專案中的預設 Xcode 工作區。
  2. 從選單中依序選取「File」→「New」→「Target」。這樣就會在專案中新增目標。
  3. 系統隨即會顯示範本清單。選取「小工具擴充功能」
  4. 輸入「NewsWidgets」貼到這個小工具的「產品名稱」方塊中。取消勾選「Include Live Activity」和「Include Configuration Intent」核取方塊。

檢查程式碼範例

新增目標時,Xcode 會根據您選取的範本產生程式碼範例。如要進一步瞭解產生的程式碼和 WidgetKit,請參閱 Apple 應用程式額外資訊說明文件。

偵錯及測試範例小工具

  1. 首先,請更新 Flutter 應用程式設定。在 Flutter 應用程式中新增套件,並計劃透過 Xcode 在專案中執行目標時,請務必完成此操作。如要更新應用程式設定,請在 Flutter 應用程式目錄中執行下列指令:
$ flutter build ios --config-only
  1. 按一下「執行器」,開啟目標清單。選取剛才建立的小工具目標,NewsWidgets,然後按一下「Run」。變更 iOS 小工具程式碼時,請從 Xcode 執行小工具目標。

bbb519df1782881d.png

  1. 模擬器或裝置螢幕應會顯示基本的主畫面小工具。如果沒有看到這個圖示,請將該圖示新增到畫面上。按住主畫面,然後點選左上角的 +

18eff1cae152014d.png

  1. 搜尋應用程式名稱。在本程式碼研究室中,請搜尋「主畫面小工具」

a0c00df87615493e.png

  1. 新增主畫面小工具後,系統會顯示簡單的文字來提供時間。

建立基本的 Android 小工具

  1. 如要在 Android 中新增主畫面小工具,請在 Android Studio 中開啟專案的建構檔案。您可以在 android/build.gradle 中找到這個檔案。或者,您也可以在 VSCode 中的「android」android資料夾上按一下滑鼠右鍵,然後選取「Open in Android Studio」android
  2. 專案建構完成後,請在左上角找出應用程式目錄。將新的主畫面小工具新增至這個目錄。在目錄上按一下滑鼠右鍵,選取「New」->。小工具 ->應用程式小工具

f19d8b7f95ab884e.png

  1. Android Studio 會顯示新表單。新增主畫面小工具的基本資訊,包括類別名稱、位置、大小和原文語言

在本程式碼研究室中,請設定下列值:

  • NewsWidget 的「Class Name」方塊
  • 最小寬度 (儲存格) 下拉式選單至 3
  • Minimum Height (cells) 下拉式選單為 3

檢查程式碼範例

提交表單時,Android Studio 會建立並更新多個檔案。下表為本程式碼研究室的相關變更

動作

目標檔案

變更

更新

AndroidManifest.xml

新增註冊 NewsWidget 的新接收器。

建立

res/layout/news_widget.xml

定義主畫面小工具 UI。

建立

res/xml/news_widget_info.xml

定義主畫面小工具設定。您可以在這個檔案中調整小工具的尺寸或名稱。

建立

java/com/example/homescreen_widgets/NewsWidget.kt

包含 Kotlin 程式碼,為主畫面小工具新增功能。

您可以在本程式碼研究室中進一步瞭解這些檔案。

偵錯及測試範例小工具

現在,請執行應用程式,並查看主畫面小工具。建立應用程式後,前往 Android 裝置的應用程式選取畫面,然後長按這個 Flutter 專案的圖示。從彈出式選單中選取「小工具」

dff7c9f9f85ef1c7.png

Android 裝置或模擬器會顯示 Android 的預設主畫面小工具。

4. 將 Flutter 應用程式的資料傳送到主畫面小工具

您可以自訂自己建立的基本主畫面小工具。更新主畫面小工具,顯示新聞報導的標題和摘要。以下螢幕截圖是主畫面小工具範例,顯示標題和摘要。

acb90343a3e51b6d.png

如要在應用程式和主畫面小工具之間傳遞資料,您必須編寫 Dart「和」原生程式碼。本節將這項程序分為三個部分:

  1. 在 Flutter 應用程式中編寫 Dart 程式碼,以供 Android 和 iOS 使用
  2. 新增原生 iOS 功能
  3. 新增原生 Android 功能

使用 iOS 應用程式群組

如要在 iOS 上層應用程式和小工具額外資訊之間共用資料,這兩個目標都必須屬於同一個應用程式群組。如要進一步瞭解應用程式群組,請參閱 Apple 應用程式群組說明文件

更新軟體包 ID:

在 Xcode 中,前往目標的設定。在 Signing &功能分頁,檢查團隊和軟體包 ID 是否已設定完成。

將 App Group 新增至 Xcode 中的 Runner 目標和 NewsWidgetExtension 目標同時

選取「+ 功能」-> 和 新增應用程式群組。針對執行者 (上層應用程式) 目標和小工具目標重複執行此步驟。

135e1a8c4652dac.png

新增 Dart 程式碼

iOS 和 Android 應用程式可以透過幾種不同方式與 Flutter 應用程式共用資料。如要與這些應用程式通訊,請使用裝置的本機 key/value 商店。iOS 呼叫這個商店 UserDefaults,Android 則稱之為 SharedPreferenceshome_widget 套件會包裝這些 API,簡化將資料儲存至任一平台,並讓主畫面小工具提取更新後的資料。

707ae86f6650ac55.png

廣告標題和說明資料來自 news_data.dart 檔案。這個檔案包含模擬資料和 NewsArticle 資料類別。

lib/news_data.dart

class NewsArticle {
  final String title;
  final String description;
  final String? articleText;

  NewsArticle({
    required this.title,
    required this.description,
    this.articleText = loremIpsum,
  });
}

更新廣告標題和說明值

如要新增透過 Flutter 應用程式更新主畫面小工具的功能,請前往 lib/home_screen.dart 檔案。使用下列程式碼取代檔案內容。然後將 <YOUR APP GROUP> 替換成應用程式群組的 ID。

lib/home_screen.dart

import 'package:flutter/material.dart';
import 'package:home_widget/home_widget.dart';             // Add this import

import 'article_screen.dart';
import 'news_data.dart';

// TODO: Replace with your App Group ID
const String appGroupId = '<YOUR APP GROUP>';              // Add from here
const String iOSWidgetName = 'NewsWidgets';
const String androidWidgetName = 'NewsWidget';             // To here.

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

void updateHeadline(NewsArticle newHeadline) {             // Add from here
  // Save the headline data to the widget
  HomeWidget.saveWidgetData<String>('headline_title', newHeadline.title);
  HomeWidget.saveWidgetData<String>(
      'headline_description', newHeadline.description);
  HomeWidget.updateWidget(
    iOSName: iOSWidgetName,
    androidName: androidWidgetName,
  );
}                                                          // To here.

class _MyHomePageState extends State<MyHomePage> {

  @override                                                // Add from here
  void initState() {
    super.initState();

    HomeWidget.setAppGroupId(appGroupId);

    // Mock read in some data and update the headline
    final newHeadline = getNewsStories()[0];
    updateHeadline(newHeadline);
  }                                                        // To here.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: const Text('Top Stories'),
            centerTitle: false,
            titleTextStyle: const TextStyle(
                fontSize: 30,
                fontWeight: FontWeight.bold,
                color: Colors.black)),
        body: ListView.separated(
          separatorBuilder: (context, idx) {
            return const Divider();
          },
          itemCount: getNewsStories().length,
          itemBuilder: (context, idx) {
            final article = getNewsStories()[idx];
            return ListTile(
              key: Key('$idx ${article.hashCode}'),
              title: Text(article.title!),
              subtitle: Text(article.description!),
              onTap: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (context) {
                      return ArticleScreen(article: article);
                    },
                  ),
                );
              },
            );
          },
        ));
  }
}

updateHeadline 函式會將鍵/值組合儲存至裝置的本機儲存空間。headline_title 鍵會保留 newHeadline.title 的值。headline_description 鍵會保留 newHeadline.description 的值。這個函式也會通知原生平台,指出可擷取並顯示主畫面小工具的新資料。

修改 floatActionButton

按下 floatingActionButton 時呼叫 updateHeadline 函式,如下所示:

lib/article_screen.dart

// New: import the updateHeadline function
import 'home_screen.dart';

...

floatingActionButton: FloatingActionButton.extended(
        onPressed: () {
          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
            content: Text('Updating home screen widget...'),
          ));
          // New: call updateHeadline
          updateHeadline(widget.article);
        },
        label: const Text('Update Homescreen'),
      ),
...

本次異動後,當使用者按下文章頁面上的「Update Headline」按鈕,主畫面小工具詳細資料就會更新。

更新 iOS 程式碼以顯示文章資料

如要更新 iOS 專用的主畫面小工具,請使用 Xcode。

在 Xcode 中開啟 NewsWidgets.swift 檔案:

設定 TimelineEntry

使用下列程式碼取代 SimpleEntry 結構:

ios/NewsWidgets/NewsWidgets.swift

// The date and any data you want to pass into your app must conform to TimelineEntry
struct NewsArticleEntry: TimelineEntry {
    let date: Date
    let title: String
    let description:String
}

這個 NewsArticleEntry 結構會定義在更新時要傳入主畫面小工具的傳入資料。TimelineEntry 類型需要日期參數。如要進一步瞭解 TimelineEntry 通訊協定,請參閱 Apple 的 TimelineEntry 說明文件

編輯顯示內容的 View

修改主畫面小工具,顯示新聞報導標題和說明而非日期。如要以 SwiftUI 顯示文字,請使用 Text 檢視畫面。如要在 SwiftUI 中將檢視畫面互相堆疊,請使用 VStack 檢視畫面。

使用下列程式碼取代產生的 NewsWidgetEntryView 檢視畫面:

ios/NewsWidgets/NewsWidgets.swift

//View that holds the contents of the widget
struct NewsWidgetsEntryView : View {
    var entry: Provider.Entry

    var body: some View {
      VStack {
        Text(entry.title)
        Text(entry.description)
      }
    }
}

編輯供應商,告知主畫面小工具更新的時機和方式

使用下列程式碼取代現有的 Provider。接著,替換 <YOUR APP GROUP> 的應用程式群組 ID:

ios/NewsWidgets/NewsWidgets.swift

struct Provider: TimelineProvider {

// Placeholder is used as a placeholder when the widget is first displayed
    func placeholder(in context: Context) -> NewsArticleEntry {
//      Add some placeholder title and description, and get the current date
      NewsArticleEntry(date: Date(), title: "Placeholder Title", description: "Placeholder description")
    }

// Snapshot entry represents the current time and state
    func getSnapshot(in context: Context, completion: @escaping (NewsArticleEntry) -> ()) {
      let entry: NewsArticleEntry
      if context.isPreview{
        entry = placeholder(in: context)
      }
      else{
        //      Get the data from the user defaults to display
        let userDefaults = UserDefaults(suiteName: <YOUR APP GROUP>)
        let title = userDefaults?.string(forKey: "headline_title") ?? "No Title Set"
        let description = userDefaults?.string(forKey: "headline_description") ?? "No Description Set"
        entry = NewsArticleEntry(date: Date(), title: title, description: description)
      }
        completion(entry)
    }

//    getTimeline is called for the current and optionally future times to update the widget
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
//      This just uses the snapshot function you defined earlier
      getSnapshot(in: context) { (entry) in
// atEnd policy tells widgetkit to request a new entry after the date has passed
        let timeline = Timeline(entries: [entry], policy: .atEnd)
                  completion(timeline)
              }
    }
}

上述程式碼的 Provider 符合 TimelineProviderProvider 有三種不同方法:

  1. placeholder 方法會在使用者首次預覽主畫面小工具時產生預留位置項目。

45a0f64240c12efe.png

  1. getSnapshot 方法會從使用者預設值讀取資料,並產生目前時間的項目。
  2. getTimeline 方法會傳回時間軸項目。當您能預測的時間點更新內容時,這種做法特別有效率。本程式碼研究室會使用 getSnapshot 函式取得目前狀態。.atEnd 方法會通知主畫面小工具在目前時間過後重新整理資料。

NewsWidgets_Previews 發表留言

使用預覽不在本程式碼研究室的說明範圍內。如要進一步瞭解如何預覽 SwiftUI 主畫面小工具,請參閱 Apple 的偵錯小工具說明文件

儲存所有檔案,然後重新執行應用程式和小工具目標

再次執行目標,驗證應用程式和主畫面小工具是否正常運作。

  1. 在 Xcode 中選取應用程式結構定義,以執行應用程式目標。
  2. 選取 Xcode 中的擴充功能結構定義以執行擴充功能目標。
  3. 前往應用程式中的文章頁面。
  4. 按一下按鈕即可更新標題。主畫面小工具也必須更新標題。

更新 Android 程式碼

新增主畫面小工具 XML。

在 Android Studio 中,更新在上一個步驟中產生的檔案,然後開啟 res/layout/news_widget.xml 檔案。其定義了主畫面小工具的結構和版面配置。選取右上角的「Code」,然後將檔案內容替換為以下程式碼:

android/app/res/layout/news_widget.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/widget_container"
   style="@style/Widget.Android.AppWidget.Container"
   android:layout_width="wrap_content"
   android:layout_height="match_parent"
   android:background="@android:color/white"
   android:theme="@style/Theme.Android.AppWidgetContainer">
   
   <TextView
       android:id="@+id/headline_title"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="20sp"
       android:textStyle="bold" />

   <TextView
       android:id="@+id/headline_description"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_below="@+id/headline_title"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:layout_marginTop="4dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="16sp" />

</RelativeLayout>

這個 XML 定義了兩種文字檢視區塊,一個用於文章標題,另一個則用於文章說明。這些文字檢視區塊也會定義樣式。在整個程式碼研究室中,您將返回此檔案。

更新 NewsWidget 功能

開啟 NewsWidget.kt Kotlin 原始碼檔案。這個檔案包含系統產生的 NewsWidget 類別,可擴充 AppWidgetProvider 類別。

NewsWidget 類別包含父類別中的三個方法。您將修改 onUpdate 方法。Android 會以固定間隔對小工具呼叫這個方法。

NewsWidget.kt 檔案的內容替換為以下程式碼:

android/app/java/com.mydomain.homescreen_widgets/NewsWidget.kt

// Import will depend on App ID.
package com.mydomain.homescreen_widgets

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews

// New import.
import es.antonborri.home_widget.HomeWidgetPlugin


/**
 * Implementation of App Widget functionality.
 */
class NewsWidget : AppWidgetProvider() {
    override fun onUpdate(
            context: Context,
            appWidgetManager: AppWidgetManager,
            appWidgetIds: IntArray,
    ) {
        for (appWidgetId in appWidgetIds) {
            // Get reference to SharedPreferences
            val widgetData = HomeWidgetPlugin.getData(context)
            val views = RemoteViews(context.packageName, R.layout.news_widget).apply {

                val title = widgetData.getString("headline_title", null)
                setTextViewText(R.id.headline_title, title ?: "No title set")

                val description = widgetData.getString("headline_description", null)
                setTextViewText(R.id.headline_description, description ?: "No description set")
            }

            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }
}

現在,在呼叫 onUpdate 時,Android 會使用 the widgetData.getString() 方法從本機儲存空間取得最新值,然後呼叫 setTextViewText 以變更主畫面小工具顯示的文字。

測試更新

請測試應用程式,確保主畫面小工具加入新資料。如要更新資料,請使用文章頁面上的「更新主畫面」 FloatingActionButton。主畫面小工具應配合文章標題更新。

5ce1c9914b43ad79.png

5. 在 iOS 主畫面小工具中使用 Flutter 應用程式自訂字型

到目前為止,您已設定主畫面小工具來讀取 Flutter 應用程式提供的資料。Flutter 應用程式包含您可能會想在主畫面小工具中使用的自訂字型。你可以在 iOS 主畫面小工具中使用自訂字型。Android 裝置不支援在主畫面小工具中使用自訂字型。

更新 iOS 程式碼

Flutter 將其資產儲存在 iOS 應用程式的 mainBundle。您可以透過主畫面小工具程式碼存取這個套裝組合中的資產。

在 NewsWidgets.swift 檔案的 NewsWidgetsEntryView 結構中,進行下列變更

建立輔助函式,取得 Flutter 資產目錄的路徑:

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...

   // New: Add the helper function.
   var bundle: URL {
           let bundle = Bundle.main
           if bundle.bundleURL.pathExtension == "appex" {
               // Peel off two directory levels - MY_APP.app/PlugIns/MY_APP_EXTENSION.appex
               var url = bundle.bundleURL.deletingLastPathComponent().deletingLastPathComponent()
               url.append(component: "Frameworks/App.framework/flutter_assets")
               return url
           }
           return bundle.bundleURL
       }
   ...
}

使用自訂字型檔案的網址來註冊字型。

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...

   // New: Register the font.
   init(entry: Provider.Entry){
     self.entry = entry
     CTFontManagerRegisterFontsForURL(bundle.appending(path: "/fonts/Chewy-Regular.ttf") as CFURL, CTFontManagerScope.process, nil)
   }
   ...
}

更新標題文字檢視畫面,以使用自訂字型。

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...


   var body: some View {
    VStack {
      // Update the following line.
      Text(entry.title).font(Font.custom("Chewy", size: 13))
      Text(entry.description)
    }
   }
   ...
}

現在執行主畫面小工具時,小工具會使用廣告標題的自訂字型,如下圖所示:

93f8b9d767aacfb2.png

6. 將 Flutter 小工具算繪為圖片

在本節中,您將以 Flutter 應用程式為基礎的圖表做為主畫面小工具。

相較於您在主畫面上顯示的文字,這個小工具提供的挑戰性會比較高。相較於嘗試用原生 UI 元件重新建立 Flutter 圖表,會是更簡單的流程。

為主畫面小工具編寫程式碼,將 Flutter 圖表算繪為 PNG 檔案。主畫面小工具可顯示該圖片。

編寫 Dart 程式碼

在 Dart 端,從 home_widget 套件新增 renderFlutterWidget 方法。這個方法採用小工具、檔案名稱與鍵。這會傳回 Flutter 小工具的圖片,並儲存至共用容器。在程式碼中提供映像檔名稱,並確保主畫面小工具可以存取容器。key 會將完整檔案路徑以字串形式儲存在裝置的本機儲存空間中。當 Dart 程式碼中的名稱改變時,主畫面小工具就能找到檔案。

在本程式碼研究室中,lib/article_screen.dart 檔案中的 LineChart 類別代表圖表。其建構方法會傳回 CustomPainter,將圖表繪製到螢幕上。

如要實作這項功能,請開啟 lib/article_screen.dart 檔案。匯入 home_widget 套件。接著,將 _ArticleScreenState 類別中的程式碼替換為以下程式碼:

lib/article_screen.dart

import 'package:flutter/material.dart';
// New: import the home_widget package.
import 'package:home_widget/home_widget.dart';

import 'home_screen.dart';
import 'news_data.dart';

...

class _ArticleScreenState extends State<ArticleScreen> {
  // New: add this GlobalKey
  final _globalKey = GlobalKey();
  String? imagePath;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.article.title!),
      ),
      // New: add this FloatingActionButton
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () async {
          if (_globalKey.currentContext != null) {
            var path = await HomeWidget.renderFlutterWidget(
              const LineChart(),
              fileName: 'screenshot',
              key: 'filename',
              logicalSize: _globalKey.currentContext!.size,
              pixelRatio:
                  MediaQuery.of(_globalKey.currentContext!).devicePixelRatio,
            );
            setState(() {
              imagePath = path as String?;
            });
          }
          updateHeadline(widget.article);
        },
        label: const Text('Update Homescreen'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16.0),
        children: [
          Text(
            widget.article.description!,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 20.0),
          Text(widget.article.articleText!),
          const SizedBox(height: 20.0),
          Center(
            // New: Add this key
            key: _globalKey,
            child: const LineChart(),
          ),
          const SizedBox(height: 20.0),
          Text(widget.article.articleText!),
        ],
      ),
    );
  }
}

這個範例會變更 _ArticleScreenState 類別進行三項變更。

建立 GlobalKey

GlobalKey 會取得特定小工具的背景資訊,這是取得小工具大小所需的資訊。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   // New: add this GlobalKey
   final _globalKey = GlobalKey();
   ...
}

新增 imagePath

imagePath 屬性會儲存圖片在 Flutter 小工具算繪的位置。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   ...
   // New: add this imagePath
   String? imagePath;
   ...
}

將金鑰新增至小工具進行算繪

_globalKey 包含算繪至圖片的 Flutter 小工具。在此例中,Flutter 小工具是包含 LineChart 的 Center。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   ...
   Center(
      // New: Add this key
 key: _globalKey,
 child: const LineChart(),
   ),
   ...
}
  1. 將小工具儲存為圖片

使用者點選 floatingActionButton 時,系統會呼叫 renderFlutterWidget 方法。這個方法會將產生的 PNG 檔案儲存為「螢幕截圖」共用容器目錄這個方法也會將圖片的完整路徑儲存為裝置儲存空間中的檔案名稱金鑰。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   ...
   floatingActionButton: FloatingActionButton.extended(
 onPressed: () async {
   if (_globalKey.currentContext != null) {
     var path = await HomeWidget.renderFlutterWidget(
       LineChart(),
       fileName: 'screenshot',
       key: 'filename',
       logicalSize: _globalKey.currentContext!.size,
       pixelRatio:
         MediaQuery.of(_globalKey.currentContext!).devicePixelRatio,
     );
     setState(() {
        imagePath = path as String?;
     });
    }
  updateHeadline(widget.article);
  },
   ...
}

更新 iOS 碼

如果是 iOS 裝置,請更新程式碼以取得儲存空間中的檔案路徑,並使用 SwiftUI 將檔案顯示為圖片。

開啟 NewsWidgets.swift 檔案進行下列變更:

filenamedisplaySize 新增至 NewsArticleEntry 結構

filename 屬性會保留代表圖片檔路徑的字串。displaySize 屬性會保留使用者裝置的主畫面小工具大小。主畫面小工具的大小來自 context

ios/NewsWidgets/NewsWidgets.swift

struct NewsArticleEntry: TimelineEntry {
   ...

   // New: add the filename and displaySize.
   let filename: String
   let displaySize: CGSize
}

更新 placeholder 函式

加入預留位置 filenamedisplaySize

ios/NewsWidgets/NewsWidgets.swift

func placeholder(in context: Context) -> NewsArticleEntry {
      NewsArticleEntry(date: Date(), title: "Placeholder Title", description: "Placeholder description", filename: "No screenshot available",  displaySize: context.displaySize)
    }

getSnapshot 中的 userDefaults 取得檔案名稱

這會在主畫面小工具更新時,將 filename 變數設為 userDefaults 儲存空間中的 filename 值。

ios/NewsWidgets/NewsWidgets.swift

func getSnapshot(
   ...

   let title = userDefaults?.string(forKey: "headline_title") ?? "No Title Set"
   let description = userDefaults?.string(forKey: "headline_description") ?? "No Description Set"
   // New: get fileName from key/value store
   let filename = userDefaults?.string(forKey: "filename") ?? "No screenshot available"
   ...
)

建立顯示路徑中圖片的 ChartImage

ChartImage 檢視畫面會根據 Dart 端產生的檔案內容建立圖片。在這個範例中,您可以將大小設為影格的 50%。

ios/NewsWidgets/NewsWidgets.swift

struct NewsWidgetsEntryView : View {
   ...

   // New: create the ChartImage view
   var ChartImage: some View {
        if let uiImage = UIImage(contentsOfFile: entry.filename) {
            let image = Image(uiImage: uiImage)
                .resizable()
                .frame(width: entry.displaySize.height*0.5, height: entry.displaySize.height*0.5, alignment: .center)
            return AnyView(image)
        }
        print("The image file could not be loaded")
        return AnyView(EmptyView())
    }
   ...
}

NewsWidgetsEntryView 內文中使用 ChartImage

將 ChartImage 檢視新增至 NewsWidgetsEntryView 的內文,在主畫面小工具中顯示 ChartImage。

ios/NewsWidgets/NewsWidgets.swift

VStack {
   Text(entry.title).font(Font.custom("Chewy", size: 13))
   Text(entry.description).font(.system(size: 12)).padding(10)
   // New: add the ChartImage to the NewsWidgetEntryView
   ChartImage
}

測試變更

如要測試變更,請從 Xcode 重新執行 Flutter 應用程式 (Runner) 目標和擴充功能目標。如要查看圖片,請前往應用程式中的任一文章頁面,然後按下按鈕更新主畫面小工具。

33bdfe2cce908c48.png

更新 Android 程式碼

Android 程式碼的運作方式與 iOS 程式碼相同。

  1. 開啟 android/app/res/layout/news_widget.xml 檔案。其中包含主畫面小工具的 UI 元素。以下列程式碼取代其內容:

android/app/res/layout/news_widget.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/widget_container"
   style="@style/Widget.Android.AppWidget.Container"
   android:layout_width="wrap_content"
   android:layout_height="match_parent"
   android:background="@android:color/white"
   android:theme="@style/Theme.Android.AppWidgetContainer">

   <TextView
       android:id="@+id/headline_title"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="20sp"
       android:textStyle="bold" />

   <TextView
       android:id="@+id/headline_description"
       style="@style/Widget.Android.AppWidget.InnerView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_below="@+id/headline_title"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:layout_marginTop="4dp"
       android:background="@android:color/white"
       android:text="Title"
       android:textSize="16sp" />
   
   <!--New: add this image view -->
   <ImageView
       android:id="@+id/widget_image"
       android:layout_width="200dp"
       android:layout_height="200dp"
       android:layout_below="@+id/headline_description"
       android:layout_alignBottom="@+id/headline_title"
       android:layout_alignParentStart="true"
       android:layout_alignParentLeft="true"
       android:layout_marginStart="8dp"
       android:layout_marginLeft="8dp"
       android:layout_marginTop="6dp"
       android:layout_marginBottom="-134dp"
       android:layout_weight="1"
       android:adjustViewBounds="true"
       android:background="@android:color/white"
       android:scaleType="fitCenter"
       android:src="@android:drawable/star_big_on"
       android:visibility="visible"
       tools:visibility="visible" />

</RelativeLayout>

這個新程式碼會將圖片新增至主畫面小工具 (目前) 會顯示一般星號圖示。將這個星號圖示換成您儲存在 Dart 程式碼中的圖片。

  1. 開啟 NewsWidget.kt 檔案。以下列程式碼取代其內容:

android/app/java/com.mydomain.homescreen_widgets/NewsWidget.kt

// Import will depend on App ID.
package com.mydomain.homescreen_widgets

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.widget.RemoteViews
import java.io.File
import es.antonborri.home_widget.HomeWidgetPlugin


/**
 * Implementation of App Widget functionality.
 */
class NewsWidget : AppWidgetProvider() {
    override fun onUpdate(
            context: Context,
            appWidgetManager: AppWidgetManager,
            appWidgetIds: IntArray,
    ) {
        for (appWidgetId in appWidgetIds) {
            val widgetData = HomeWidgetPlugin.getData(context)
            val views = RemoteViews(context.packageName, R.layout.news_widget).apply {

                val title = widgetData.getString("headline_title", null)
                setTextViewText(R.id.headline_title, title ?: "No title set")

                val description = widgetData.getString("headline_description", null)
                setTextViewText(R.id.headline_description, description ?: "No description set")

                // New: Add the section below
               // Get chart image and put it in the widget, if it exists
                val imageName = widgetData.getString("filename", null)
                val imageFile = File(imageName)
                val imageExists = imageFile.exists()
                if (imageExists) {
                    val myBitmap: Bitmap = BitmapFactory.decodeFile(imageFile.absolutePath)
                    setImageViewBitmap(R.id.widget_image, myBitmap)
                } else {
                    println("image not found!, looked @: ${imageName}")
                }
                // End new code
            }

            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }
}

這個 Dart 程式碼會使用 filename 鍵將螢幕截圖儲存至本機儲存空間。它也會取得圖片的完整路徑,並從該路徑建立 File 物件。如果該圖片存在,Dart 程式碼會將主畫面小工具中的圖片換成新的圖片。

  1. 重新載入應用程式,然後前往文章畫面。按下「更新主畫面」。主畫面小工具會顯示圖表。

7. 後續步驟

恭喜!

恭喜!您已成功為 Flutter iOS 和 Android 應用程式建立主畫面小工具!

連結至 Flutter 應用程式中的內容

您可以根據使用者點擊的位置,將他們帶往應用程式中的特定頁面。舉例來說,在本程式碼研究室的新聞應用程式中,您可能希望使用者能夠閱讀所顯示標題的新聞報導。

這項功能不在本程式碼研究室的範圍內。您可以找到Home_widget 套件提供的串流範例,藉此找出可透過主畫面小工具啟動的應用程式,並透過網址從主畫面小工具傳送訊息。詳情請參閱 docs.flutter.dev 中的深層連結說明文件

在背景更新小工具

在本程式碼研究室中,您使用按鈕觸發了主畫面小工具的更新。雖然這對於測試是合理的做法,但在正式環境中,建議您讓應用程式在背景更新主畫面小工具。您可以使用 Workmanager 外掛程式建立背景工作,以更新主畫面小工具所需的資源。詳情請參閱 home_widget 套件的「背景更新」部分。

如果是 iOS,您也可以讓主畫面小工具發出網路要求,以更新 UI。如要控制請求的條件或頻率,請使用「時間軸」。如要進一步瞭解如何使用時間軸,請參閱 Apple 的「將小工具保持在最新狀態」一文說明文件。

延伸閱讀