Flutter アプリにホーム画面ウィジェットを追加する

1. はじめに

ウィジェットとは

Flutter デベロッパーにとって、ウィジェットの一般的な定義は、Flutter フレームワークを使用して作成された UI コンポーネントを指します。この Codelab のコンテキストでは、ウィジェットとは、アプリの情報を アプリを開かずに表示するアプリのミニバージョンを指します。Android では、ウィジェットはホーム画面に表示されます。iOS では、ホーム画面、ロック画面、今日ビューに追加できます。

f0027e8a7d0237e0.png b991e79ea72c8b65.png

ウィジェットはどの程度複雑にできますか?

ほとんどのホーム画面ウィジェットはシンプルです。基本的なテキスト、シンプルなグラフィック、Android の場合は基本的なコントロールが含まれていることがあります。Android と iOS の両方で、使用できる UI コンポーネントと機能が制限されます。

819b9fffd700e571.png 92d62ccfd17d770d.png

ウィジェットの UI を作成する

このような UI の制限があるため、Flutter フレームワークを使用してホーム画面ウィジェットの UI を直接描画することはできません。代わりに、Jetpack Compose や SwiftUI などのプラットフォーム フレームワークで作成されたウィジェットを Flutter アプリに追加できます。この Codelab では、アプリとウィジェット間でリソースを共有して、複雑な UI の書き換えを回避する例について説明します。

作成するアプリの概要

この Codelab では、home_widget パッケージを使用して、ユーザーが記事を読めるシンプルな Flutter アプリの Android と iOS の両方でホーム画面ウィジェットを作成します。ウィジェットは次のようになります。

  • Flutter アプリのデータを表示します。
  • Flutter アプリから共有されたフォント アセットを使用してテキストを表示します。
  • レンダリングされた Flutter ウィジェットの画像を表示します。

a36b7ba379151101.png

この Flutter アプリには、次の 2 つの画面(またはルート)が含まれています。

  • 1 つ目は、見出しと説明文を含むニュース記事のリストを表示します。
  • 2 つ目は、CustomPaint を使用して作成されたグラフを含む記事全体を表示しています。

.

9c02f8b62c1faa3a.png d97d44051304cae4.png

学習内容

  • iOS と Android でホーム画面のウィジェットを作成する方法。
  • home_widget パッケージを使用して、ホーム画面ウィジェットと Flutter アプリの間でデータを共有する方法。
  • 書き直す必要のあるコードの量を減らす方法。
  • Flutter アプリからホーム画面ウィジェットを更新する方法。

2. 開発環境を設定する

両方のプラットフォームで、Flutter SDKIDE が必要です。Flutter の作業には、お好みの IDE を使用できます。Dart Code と Flutter の拡張機能を備えている Visual Studio Code、または Flutter プラグインと Dart プラグインがインストールされている Android Studio や IntelliJ などを使用できます。

iOS のホーム画面ウィジェットを作成するには:

  • この Codelab は、物理 iOS デバイスまたは iOS シミュレータで実行できます。
  • Xcode IDE を使用して macOS システムを構成する必要があります。これにより、アプリの iOS バージョンをビルドするために必要なコンパイラがインストールされます。

Android のホーム画面ウィジェットを作成するには:

  • この Codelab は、実機の Android デバイスまたは Android エミュレータで実行できます。
  • 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. 基本的なホーム画面ウィジェットを追加する

まず、ネイティブ プラットフォームのツールを使用してホーム画面ウィジェットを追加します。

基本的な iOS ホーム画面ウィジェットを作成する

Flutter iOS アプリにアプリ拡張機能を追加する方法は、SwiftUI または UIKit アプリにアプリ拡張機能を追加する方法と似ています。

  1. Flutter プロジェクト ディレクトリのターミナル ウィンドウで open ios/Runner.xcworkspace を実行します。または、VSCode の ios フォルダを右クリックして、[Open in Xcode] を選択します。これにより、Flutter プロジェクトのデフォルトの Xcode ワークスペースが開きます。
  2. メニューから [File] → [New] → [Target] を選択します。これにより、プロジェクトに新しいターゲットが追加されます。
  3. テンプレートのリストが表示されます。[Widget Extension] を選択します。
  4. このウィジェットの [プロダクト名] ボックスに「NewsWidgets」と入力します。[ライブ アクティビティを含める] と [構成インテントを含める] の両方のチェックボックスをオフにします。

サンプルコードを調べる

新しいターゲットを追加すると、選択したテンプレートに基づいてサンプルコードが生成されます。生成されたコードと WidgetKit について詳しくは、Apple のアプリ拡張機能のドキュメント をご覧ください。

サンプル ウィジェットをデバッグしてテストする

  1. まず、Flutter アプリの構成を更新します。これは、Flutter アプリに新しいパッケージを追加し、Xcode からプロジェクトのターゲットを実行する予定がある場合に行う必要があります。アプリの構成を更新するには、Flutter アプリのディレクトリで次のコマンドを実行します。
$ flutter build ios --config-only
  1. [Runner] をクリックして、ターゲットのリストを表示します。作成したばかりのウィジェット ターゲット NewsWidgets を選択し、[実行] をクリックします。iOS ウィジェットのコードを変更したら、Xcode からウィジェット ターゲットを実行します。

bbb519df1782881d.png

  1. シミュレータまたはデバイスの画面に、基本的なホーム画面ウィジェットが表示されるはずです。表示されていない場合は、画面に追加できます。ホーム画面を長押しし、左上の + をクリックします。

18eff1cae152014d.png

  1. アプリの名前を検索します。この Codelab では、「Homescreen Widgets」を検索します。

a0c00df87615493e.png

  1. ホーム画面ウィジェットを追加すると、時刻を示すシンプルなテキストが表示されます。

基本的な Android ウィジェットを作成する

  1. Android でホーム画面ウィジェットを追加するには、Android Studio でプロジェクトのビルドファイルを開きます。このファイルは android/build.gradle にあります。または、VSCode で android フォルダを右クリックして、[Open in Android Studio] を選択します。
  2. プロジェクトがビルドされたら、左上にあるアプリ ディレクトリを探します。このディレクトリに新しいホーム画面ウィジェットを追加します。ディレクトリを右クリックし、[New] -> [Widget] -> [App Widget] を選択します。

f19d8b7f95ab884e.png

  1. Android Studio に新しいフォームが表示されます。ホーム画面ウィジェットの基本情報(クラス名、配置、サイズ、ソース言語など)を追加します

この Codelab では、次の値を設定します。

  • [クラス名] ボックスを NewsWidget に変更します。
  • [Minimum Width (cells)] プルダウンを 3 に設定します。
  • [Minimum Height (cells)] プルダウンを 3 に設定します。

サンプルコードを調べる

フォームを送信すると、Android Studio によって複数のファイルが作成および更新されます。この Codelab に関連する変更は、次の表にまとめられています。

アクション

ターゲット ファイル

変更

更新

AndroidManifest.xml

NewsWidget を登録する新しいレシーバを追加します。

作成

res/layout/news_widget.xml

ホーム画面ウィジェットの UI を定義します。

作成

res/xml/news_widget_info.xml

ホーム画面ウィジェットの設定を定義します。このファイルでウィジェットのサイズや名前を調整できます。

作成

java/com/example/homescreen_widgets/NewsWidget.kt

ホーム画面ウィジェットに機能を追加するための Kotlin コードが含まれています。

これらのファイルの詳細については、この Codelab 全体で説明します。

サンプル ウィジェットをデバッグしてテストする

アプリケーションを実行して、ホーム画面のウィジェットを確認します。アプリをビルドしたら、Android デバイスのアプリ選択画面に移動し、この Flutter プロジェクトのアイコンを長押しします。ポップアップ メニューから [ウィジェット] を選択します。

dff7c9f9f85ef1c7.png

Android デバイスまたはエミュレータに、Android のデフォルトのホーム画面ウィジェットが表示されます。

4. Flutter アプリからホーム画面ウィジェットにデータを送信する

作成した基本的なホーム画面ウィジェットをカスタマイズできます。ニュース記事の見出しと概要を表示するようにホーム画面ウィジェットを更新します。次のスクリーンショットは、見出しと概要を表示するホーム画面ウィジェットの例を示しています。

acb90343a3e51b6d.png

アプリとホーム画面ウィジェット間でデータを渡すには、Dart ネイティブ コードを記述する必要があります。このセクションでは、このプロセスを 3 つの部分に分けて説明します。

  1. Android と iOS の両方で使用できる Flutter アプリで Dart コードを作成する
  2. ネイティブ iOS 機能の追加
  3. ネイティブの Android 機能を追加する

iOS アプリグループの使用

iOS の親アプリとウィジェット拡張機能の間でデータを共有するには、両方のターゲットが同じアプリグループに属している必要があります。アプリグループの詳細については、Apple のアプリグループに関するドキュメントをご覧ください。

バンドル識別子を更新します。

Xcode で、ターゲットの設定に移動します。[Signing & Capabilities] タブで、チームとバンドル ID が設定されていることを確認します。

Xcode の Runner ターゲットと NewsWidgetExtension ターゲットの両方に App Group を追加します。

[+ Capability -> App Groups] を選択して、新しい App Group を追加します。Runner(親アプリ)ターゲットとウィジェット ターゲットの両方で繰り返します。

135e1a8c4652dac.png

Dart コードを追加する

iOS アプリと Android アプリは、いくつかの方法で Flutter アプリとデータを共有できます。これらのアプリと通信するには、デバイスのローカル key/value ストアを活用します。iOS ではこのストアを UserDefaults と呼び、Android では SharedPreferences と呼びます。home_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 の値が格納されます。また、この関数は、ホーム画面ウィジェットの新しいデータを取得してレンダリングできることをネイティブ プラットフォームに通知します。

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'),
      ),
...

この変更により、ユーザーが記事ページで [見出しを更新] ボタンを押すと、ホーム画面のウィジェットの詳細が更新されます。

記事データを表示するように 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> に置き換えます。

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

前のコードの ProviderTimelineProvider に準拠しています。Provider には次の 3 つのメソッドがあります。

  1. placeholder メソッドは、ユーザーがホーム画面ウィジェットを初めてプレビューしたときにプレースホルダ エントリを生成します。

45a0f64240c12efe.png

  1. getSnapshot メソッドは、ユーザーのデフォルトからデータを読み取り、現在の時刻のエントリを生成します。
  2. getTimeline メソッドはタイムライン エントリを返します。これは、コンテンツを更新するタイミングが予測できる場合に役立ちます。この Codelab では、getSnapshot 関数を使用して現在の状態を取得します。.atEnd メソッドは、現在の時刻が経過した後にデータを更新するようホーム画面ウィジェットに指示します。

NewsWidgets_Previews をコメントアウトします。

プレビューの使用は、この Codelab の範囲外です。SwiftUI ホーム画面ウィジェットのプレビューについて詳しくは、ウィジェットのデバッグに関する Apple のドキュメントをご覧ください。

すべてのファイルを保存して、アプリとウィジェットのターゲットを再度実行します。

ターゲットを再度実行して、アプリとホーム画面ウィジェットが動作することを確認します。

  1. Xcode でアプリ スキーマを選択して、アプリ ターゲットを実行します。
  2. Xcode で拡張機能スキーマを選択して、拡張機能ターゲットを実行します。
  3. アプリで記事ページに移動します。
  4. ボタンをクリックして見出しを更新します。ホーム画面のウィジェットでも見出しが更新されるようにします。

Android コードを更新する

ホーム画面ウィジェットの XML を追加します。

Android Studio で、前の手順で生成したファイルを更新します。res/layout/news_widget.xml ファイルを開きます。ホーム画面ウィジェットの構造とレイアウトを定義します。右上にある [コード] を選択し、そのファイルの内容を次のコードに置き換えます。

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 は、記事の見出しと記事の説明の 2 つのテキスト ビューを定義しています。これらのテキスト ビューでは、スタイル設定も定義します。この Codelab では、このファイルに戻って作業します。

NewsWidget の機能を更新

NewsWidget.kt Kotlin ソースコード ファイルを開きます。このファイルには、AppWidgetProvider クラスを拡張する NewsWidget という名前の生成されたクラスが含まれています。

NewsWidget クラスには、スーパークラスの 3 つのメソッドが含まれています。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 を呼び出してホーム画面ウィジェットに表示されるテキストを変更します。

更新をテストする

アプリをテストして、ホーム画面のウィジェットが新しいデータで更新されることを確認します。データを更新するには、記事ページの [Update Home Screen ] 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
       }
   ...
}

カスタム フォント ファイルの URL を使用してフォントを登録します。

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

カスタム フォントを使用するように見出しの TextView を更新します。

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 アプリのグラフをホーム画面のウィジェットとして表示します。

このウィジェットは、ホーム画面に表示したテキストよりも難しい課題を提供します。Flutter のグラフをネイティブ UI コンポーネントを使用して再作成するよりも、画像として表示する方がはるかに簡単です。

ホーム画面ウィジェットをコーディングして、Flutter グラフを PNG ファイルとしてレンダリングします。ホーム画面のウィジェットにその画像を表示できます。

Dart コードを記述する

Dart 側で、home_widget パッケージから renderFlutterWidget メソッドを追加します。このメソッドは、ウィジェット、ファイル名、キーを受け取ります。Flutter ウィジェットの画像を返し、共有コンテナに保存します。コードでイメージ名を指定し、ホーム画面ウィジェットがコンテナにアクセスできるようにします。key は、デバイスのローカル ストレージに文字列としてフルファイルパスを保存します。これにより、Dart コードで名前が変更された場合でも、ホーム画面ウィジェットでファイルを見つけることができます。

この Codelab では、lib/article_screen.dart ファイルの LineChart クラスがグラフを表します。このチャートを画面に描画する CustomPainter を返す build メソッドがあります。

この機能を実装するには、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 クラスに 3 つの変更を加えています。

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. ウィジェットを画像として保存する

renderFlutterWidget メソッドは、ユーザーが floatingActionButton をクリックすると呼び出されます。このメソッドは、生成された 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 ファイルを開き、次のように変更します。

filenamedisplaySizeNewsArticleEntry 構造体に追加

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

getSnapshotuserDefaults からファイル名を取得

これにより、ホーム画面ウィジェットが更新されると、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 を使用する

NewsWidgetsEntryView の body に ChartImage ビューを追加して、ホーム画面ウィジェットに 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 アプリのコンテンツにリンクする

ユーザーがクリックした場所に応じて、アプリ内の特定のページにユーザーを誘導したい場合があります。たとえば、この Codelab のニュースアプリでは、表示された見出しのニュース記事をユーザーに表示したい場合があります。

この機能は、この Codelab の範囲外です。home_widget パッケージが提供するストリームを使用して、ホーム画面ウィジェットからのアプリの起動を特定し、ホーム画面ウィジェットから URL を介してメッセージを送信する例については、こちらをご覧ください。詳しくは、docs.flutter.dev のディープリンクのドキュメント をご覧ください。

バックグラウンドでウィジェットを更新する

この Codelab では、ボタンを使用してホーム画面ウィジェットの更新をトリガーしました。これはテストでは合理的ですが、本番用コードでは、アプリがホーム画面ウィジェットをバックグラウンドで更新することが望ましい場合があります。workmanager プラグインを使用すると、ホーム画面ウィジェットに必要なリソースを更新するバックグラウンド タスクを作成できます。詳しくは、home_widget パッケージのバックグラウンド更新のセクションをご覧ください。

iOS の場合、ホーム画面ウィジェットでネットワーク リクエストを行って UI を更新することもできます。リクエストの条件や頻度を管理するには、タイムラインを使用します。タイムラインの使用について詳しくは、Apple の「ウィジェットを最新の状態に保つ」のドキュメント をご覧ください。

関連情報