向 Flutter 应用添加主屏幕 widget

1. 简介

什么是 widget?

对于 Flutter 开发者来说,widget 的常见定义是指使用 Flutter 框架创建的界面组件。在此 Codelab 中,微件是指应用的迷你版本,可在不打开应用的情况下提供应用信息的视图。在 Android 上,微件位于主屏幕上。在 iOS 设备上,它们可以添加到主屏幕、锁定屏幕或“今天”视图中。

f0027e8a7d0237e0.png b991e79ea72c8b65.png

Widget 可以有多复杂?

大多数主屏幕 widget 都很简单。它们可能包含基本文本、简单图形,或者在 Android 上包含基本控件。Android 和 iOS 都会限制您可以使用的界面组件和功能。

819b9fffd700e571.png 92d62ccfd17d770d.png

为 widget 创建界面

由于存在这些界面限制,您无法使用 Flutter 框架直接绘制主屏幕 widget 的界面。不过,您可以将使用 Jetpack Compose 或 SwiftUI 等平台框架创建的 widget 添加到 Flutter 应用中。此 Codelab 讨论了在应用和 widget 之间共享资源的示例,以避免重写复杂的界面。

构建内容

在此 Codelab 中,您将使用 home_widget 软件包,为一款简单的 Flutter 应用在 Android 和 iOS 上构建主屏幕 widget,让用户能够阅读文章。您的 widget 将:

  • 显示来自 Flutter 应用的数据。
  • 使用从 Flutter 应用共享的字体资源显示文字。
  • 显示已渲染的 Flutter widget 的图片。

a36b7ba379151101.png

此 Flutter 应用包含两个界面(或路由):

  • 第一个界面显示包含标题和说明的新闻报道列表。
  • 第二张图片显示了包含使用 CustomPaint 创建的图表的完整文章。

9c02f8b62c1faa3a.png d97d44051304cae4.png

学习内容

  • 如何在 iOS 和 Android 上创建主屏幕 widget。
  • 如何使用 home_widget 软件包在主屏幕 widget 和 Flutter 应用之间共享数据。
  • 如何减少需要重写的代码量。
  • 如何从 Flutter 应用更新主屏幕 widget。

2. 设置您的开发环境

对于这两个平台,您都需要 Flutter SDKIDE。您可以使用自己喜欢的 IDE 来处理 Flutter 项目。可以是包含 Dart Code 和 Flutter 扩展程序的 Visual Studio Code,也可以是安装了 Flutter 和 Dart 插件的 Android Studio 或 IntelliJ。

如需创建 iOS 主屏幕 widget,请执行以下操作

  • 您可以在 iOS 设备或 iOS 模拟器上运行此 Codelab。
  • 您必须使用 Xcode IDE 配置 macOS 系统。此命令会安装构建 iOS 版应用所需的编译器。

如需创建 Android 主屏幕 widget,请执行以下操作

  • 您可以在实体 Android 设备或 Android 模拟器上运行此 Codelab。
  • 您必须使用 Android Studio 配置开发系统。此命令会安装构建应用的 Android 版本所需的编译器。

获取起始代码

从 GitHub 下载项目的初始版本

在命令行中,将 GitHub 代码库克隆到 flutter-codelabs 目录:

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

克隆代码库后,您可以在 flutter-codelabs/homescreen_codelab 目录中找到此 Codelab 的代码。该目录包含此 Codelab 中每个步骤的完整项目代码。

打开起始应用

在您的首选 IDE 中打开 flutter-codelabs/homescreen_codelab/step_03 目录。

安装软件包

所有必需的软件包都已添加到项目的 pubspec.yaml 文件中。如需检索项目依赖项,请运行以下命令:

$ flutter pub get

3. 添加基本的主屏幕 widget

首先,使用原生平台工具添加主屏幕 widget。

创建基本的 iOS 主屏幕 widget

向 Flutter iOS 应用添加应用扩展服务与向 SwiftUI 或 UIKit 应用添加应用扩展服务类似:

  1. 在终端窗口中,从您的 Flutter 项目目录运行 open ios/Runner.xcworkspace。或者,您也可以在 VSCode 中右键点击 ios 文件夹,然后选择 Open in Xcode。这会在您的 Flutter 项目中打开默认的 Xcode 工作区。
  2. 从菜单中选择 File(文件)→ New(新建)→ Target(目标)。此操作会向项目添加新目标。
  3. 系统会显示模板列表。选择微件扩展程序
  4. 在此 widget 的产品名称框中输入“NewsWidgets”。同时清除包含实时活动包含配置 intent 复选框。

检查示例代码

添加新目标时,Xcode 会根据您选择的模板生成示例代码。如需详细了解生成的代码和 WidgetKit,请参阅 Apple 的应用扩展文档

调试并测试示例 widget

  1. 首先,更新 Flutter 应用的配置。当您在 Flutter 应用中添加新软件包并计划从 Xcode 运行项目中的目标时,必须执行此操作。如需更新应用的配置,请在 Flutter 应用目录中运行以下命令:
$ flutter build ios --config-only
  1. 点击 Runner 以显示目标列表。选择您刚刚创建的 widget 目标平台 NewsWidgets,然后点击 Run。更改 iOS widget 代码时,请从 Xcode 运行 widget 目标。

bbb519df1782881d.png

  1. 模拟器或设备屏幕应显示一个基本的主屏幕 widget。如果您没有看到该图标,可以将其添加到屏幕上。在主屏幕上点击并按住,然后点击左上角的 +

18eff1cae152014d.png

  1. 搜索应用的名称。在本 Codelab 中,搜索“主屏幕 widget”

a0c00df87615493e.png

  1. 添加主屏幕 widget 后,它应显示简单的文本,其中包含时间。

创建基本的 Android widget

  1. 如需在 Android 中添加主屏幕 widget,请在 Android Studio 中打开项目的 build 文件。您可以在 android/build.gradle 中找到此文件。或者,您也可以在 VSCode 中右键点击 android 文件夹,然后选择 Open in Android Studio
  2. 项目构建完成后,找到左上角的应用目录。将新的主屏幕 widget 添加到此目录。右键点击该目录,然后依次选择 New -> Widget -> App Widget

f19d8b7f95ab884e.png

  1. Android Studio 会显示一个新表单。添加有关主屏幕 widget 的基本信息,包括其类名称、放置位置、大小和源语言

对于此 Codelab,请设置以下值:

  • 类名称框中
  • 最小宽度(单元格)下拉菜单设置为 3
  • 最小高度(单元格)下拉菜单中选择 3

检查示例代码

提交表单后,Android Studio 会创建并更新多个文件。下表列出了与此 Codelab 相关的更改

操作

目标文件

更改

更新

AndroidManifest.xml

添加了一个用于注册 NewsWidget 的新接收器。

创建

res/layout/news_widget.xml

定义主屏幕 widget 界面。

创建

res/xml/news_widget_info.xml

定义主屏幕 widget 配置。您可以在此文件中调整 widget 的尺寸或名称。

创建

java/com/example/homescreen_widgets/NewsWidget.kt

包含用于向主屏幕 widget 添加功能的 Kotlin 代码。

您可以在本 Codelab 中详细了解这些文件。

调试并测试示例 widget

现在,运行您的应用,并查看主屏幕 widget。构建应用后,前往 Android 设备的“应用选择”界面,然后长按相应 Flutter 项目的图标。从弹出式菜单中选择微件

dff7c9f9f85ef1c7.png

Android 设备或模拟器会显示 Android 的默认主屏幕 widget。

4. 将数据从 Flutter 应用发送到主屏幕 widget

您可以自定义创建的基本主屏幕 widget。更新主屏幕 widget 以显示新闻报道的标题和摘要。以下屏幕截图显示了主屏幕 widget 显示标题和摘要的示例。

acb90343a3e51b6d.png

如需在应用和主屏幕 widget 之间传递数据,您需要编写 Dart 原生代码。本部分将此流程分为三个部分:

  1. 在 Flutter 应用中编写 Android 和 iOS 都能使用的 Dart 代码
  2. 添加原生 iOS 功能
  3. 添加原生 Android 功能

使用 iOS 应用群组

如需在 iOS 父应用和 widget 扩展程序之间共享数据,这两个目标必须属于同一应用群组。如需详细了解应用组,请参阅 Apple 的应用组文档

更新您的软件包标识符

在 Xcode 中,前往目标的设置。在 Signing & Capabilities(签名和功能)标签页中,检查您的团队和软件包标识符是否已设置。

在 Xcode 中,将应用组添加到 Runner 目标和 NewsWidgetExtension 目标两者中:

依次选择 + Capability -> App Groups,然后添加新的应用组。针对 Runner(父应用)目标和 widget 目标重复上述操作。

135e1a8c4652dac.png

添加 Dart 代码

iOS 和 Android 应用可以通过多种不同的方式与 Flutter 应用共享数据。若要与这些应用通信,请利用设备的本地 key/value 存储空间。iOS 将此存储空间称为 UserDefaults,而 Android 将其称为 SharedPreferenceshome_widget 软件包封装了这些 API,可简化将数据保存到任一平台的过程,并使主屏幕 widget 能够提取更新后的数据。

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 应用更新主屏幕 widget 的功能,请前往 lib/home_screen.dart 文件。将该文件的内容替换为以下代码。然后,将 <YOUR APP GROUP> 替换为您的应用组的标识符。

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 的值。该函数还会通知原生平台可以检索并渲染主屏幕 widget 的新数据。

修改 floatingActionButton

在按下 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 按钮时,主屏幕 widget 详细信息会更新。

更新 iOS 代码以显示文章数据

如需更新 iOS 版主屏幕 widget,请使用 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 结构体定义了在更新时要传递到主屏幕 widget 的传入数据。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)
      }
    }
}

修改提供程序以告知主屏幕 widget 何时以及如何更新

将现有的 Provider 替换为以下代码。然后,将 <YOUR APP GROUP> 替换为您的应用组标识符:

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. 当用户首次预览主屏幕 widget 时,placeholder 方法会生成一个占位条目。

45a0f64240c12efe.png

  1. getSnapshot 方法从用户默认设置中读取数据,并生成当前时间的条目。
  2. getTimeline 方法会返回时间轴条目。如果您有可预测的时间点来更新内容,此功能会很有帮助。此 Codelab 使用 getSnapshot 函数来获取当前状态。.atEnd 方法会告知主屏幕 widget 在当前时间过后刷新数据。

NewsWidgets_Previews 注释掉

使用预览版不在本 Codelab 的范围之内。如需详细了解如何预览 SwiftUI 主屏幕 widget,请参阅 Apple 关于调试 widget 的文档

保存所有文件,然后重新运行应用和 widget 目标。

再次运行目标,以验证应用和主屏幕 widget 是否正常运行。

  1. 在 Xcode 中选择应用架构,以运行应用目标。
  2. 在 Xcode 中选择扩展程序架构,以运行扩展程序目标。
  3. 前往应用中的文章页面。
  4. 点击相应按钮即可更新标题。主屏幕 widget 也应更新标题。

更新 Android 代码

添加主屏幕 widget XML。

在 Android Studio 中,更新在上一步中生成的文件。打开 res/layout/news_widget.xml 文件。它定义了主屏幕 widget 的结构和布局。选择右上角的代码,然后将该文件的内容替换为以下代码:

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 定义了两个文本视图,一个用于显示文章标题,另一个用于显示文章说明。这些文本视图还定义了样式。在本 Codelab 中,您将多次返回此文件。

更新了 NewsWidget 功能

打开 NewsWidget.kt Kotlin 源代码文件。此文件包含一个名为 NewsWidget 的生成类,该类扩展了 AppWidgetProvider 类。

NewsWidget 类包含其父类中的三个方法。您将修改 onUpdate 方法。Android 会以固定的时间间隔为 widget 调用此方法。

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 来更改主屏幕 widget 上显示的文本。

测试更新

测试应用,确保主屏幕 widget 会随新数据更新。如需更新数据,请使用文章页面上的更新主屏幕 图标 FloatingActionButton。主屏幕 widget 应会更新为显示文章标题。

5ce1c9914b43ad79.png

5. 在 iOS 主屏幕 widget 中使用 Flutter 应用自定义字体

到目前为止,您已配置主屏幕 widget 以读取 Flutter 应用提供的数据。Flutter 应用包含您可能想在主屏幕 widget 中使用的自定义字体。您可以在 iOS 主屏幕 widget 中使用自定义字体。Android 设备不支持在主屏幕 widget 中使用自定义字体。

更新 iOS 代码

Flutter 将其资源存储在 iOS 应用的 mainBundle 中。您可以通过主屏幕 widget 代码访问此软件包中的资源。

在 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)
    }
   }
   ...
}

运行主屏幕 widget 时,它现在会使用自定义字体作为标题,如下所示:

93f8b9d767aacfb2.png

6. 将 Flutter widget 渲染为图片

在本部分中,您将展示 Flutter 应用中的图表,并将其作为主屏幕 widget。

此 widget 提供的挑战比您在主屏幕上显示的文字更具挑战性。将 Flutter 图表显示为图片要比尝试使用原生界面组件重新创建它容易得多。

对主屏幕 widget 进行编码,以将 Flutter 图表渲染为 PNG 文件。主屏幕微件可以显示该图片。

编写 Dart 代码

在 Dart 端,添加来自 home_widget 软件包的 renderFlutterWidget 方法。此方法可获取 widget、文件名和密钥。它会返回 Flutter widget 的图片,并将其保存到共享容器中。在代码中提供映像名称,并确保主屏幕 widget 可以访问容器。key 将完整的文件路径作为字符串保存在设备的本地存储空间中。这样,即使名称在 Dart 代码中发生更改,主屏幕 widget 也能找到相应文件。

在此 Codelab 中,lib/article_screen.dart 文件中的 LineChart 类表示图表。其 build 方法会返回一个 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 获取特定 widget 的上下文,这是获取该 widget 大小所必需的。

lib/article_screen.dart

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

添加了 imagePath

imagePath 属性用于存储 Flutter widget 渲染的图片的位置。

lib/article_screen.dart

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

将键添加到要呈现的 widget

_globalKey 包含渲染到图片中的 Flutter widget。在本例中,Flutter widget 是包含 LineChart 的 Center。

lib/article_screen.dart

class _ArticleScreenState extends State<ArticleScreen> {
   ...
   Center(
      // New: Add this key
 key: _globalKey,
 child: const LineChart(),
   ),
   ...
}
  1. 将 widget 保存为图片

当用户点击 floatingActionButton 时,系统会调用 renderFlutterWidget 方法。该方法会将生成的 PNG 文件以“screenshot”为名保存到共享容器目录中。该方法还会将图片的完整路径保存为设备存储空间中的文件名键。

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 属性用于保存用户设备上主屏幕 widget 的大小。主屏幕 widget 的大小来自 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 获取文件名

这会在主屏幕 widget 更新时将 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 的正文中,以在主屏幕 widget 中显示 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) 目标和扩展程序目标。如需查看图片,请前往应用中的某个文章页面,然后按相应按钮更新主屏幕 widget。

33bdfe2cce908c48.png

更新 Android 代码

Android 代码的功能与 iOS 代码相同。

  1. 打开 android/app/res/layout/news_widget.xml 文件。它包含主屏幕 widget 的界面元素。将其内容替换为以下代码:

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>

此新代码会向主屏幕 widget 添加图片,该图片(目前)会显示一个通用星形图标。将此星形图标替换为在 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 代码会将主屏幕 widget 中的图片替换为新图片。

  1. 重新加载应用,然后前往文章界面。按更新主屏幕。主屏幕 widget 会显示图表。

7. 后续步骤

恭喜!

恭喜,您已成功为 Flutter iOS 和 Android 应用创建了主屏幕 widget!

链接到 Flutter 应用中的内容

您可能希望根据用户点击的位置将用户引导至应用中的特定页面。例如,在此 Codelab 的新闻应用中,您可能希望用户看到所显示标题对应的新闻报道。

此功能不在本 Codelab 的范围之内。您可以找到使用 home_widget 软件包提供的 stream 来识别从主屏幕 widget 启动的应用并通过网址从主屏幕 widget 发送消息的示例。如需了解详情,请参阅 docs.flutter.dev 上的深层链接文档

在后台更新 widget

在此 Codelab 中,您使用按钮触发了主屏幕 widget 的更新。虽然这对于测试来说是合理的,但在生产代码中,您可能希望应用在后台更新主屏幕 widget。您可以使用 workmanager 插件创建后台任务,以更新主屏幕 widget 所需的资源。如需了解详情,请参阅 home_widget 软件包中的后台更新部分。

对于 iOS,您还可以让主屏幕 widget 发出网络请求来更新其界面。如需控制该请求的条件或频次,请使用时间轴。如需详细了解如何使用时间轴,请参阅 Apple 的“保持 widget 最新状态”文档。

深入阅读