向 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,可简化将数据保存到任一平台的过程,并使主屏幕小组件能够提取更新后的数据。

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

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

将现有的 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 目标。

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

  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 主屏幕微件中使用 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>

此新代码会向主屏幕小组件添加图片,该图片(目前)会显示一个通用星形图标。将此星形图标替换为在 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 最新状态”文档。

深入阅读