コレクション ウィジェットを使用する

Compose をお試しください
Jetpack Compose は、Android で推奨される UI ツールキットです。Compose スタイルの API を使用してウィジェットを構築する方法を学習します。

コレクション ウィジェットは、ギャラリー アプリの写真のコレクション、ニュース アプリの記事、コミュニケーション アプリのメッセージなど、同じタイプの要素を多数表示することに特化しています。コレクション ウィジェットは通常、コレクションの閲覧と、コレクションの要素を詳細ビューで開くという 2 つのユースケースに重点を置いています。コレクション ウィジェットは上下にスクロールできます。

これらのウィジェットは、RemoteViewsService を使用して、コンテンツ プロバイダなどからのリモート データを利用するコレクションを表示します。ウィジェットは、次のいずれかのビュータイプ(「コレクション ビュー」といいます)を使用してデータを表示します。

ListView: 上下にスクロールできるリスト形式でアイテムを表示するビュー。

GridView: 上下左右にスクロールできるグリッド方式でアイテムを表示するビュー。

StackView: 重なったカードが(回転式カードファイルのように)表示され、最前面のカードを上にフリックすると前のカード、下にフリックすると次のカードが表示されるビューです。

AdapterViewFlipper: アダプタを使用する ViewAnimator で、複数のビューをアニメーションで切り替えます。一度に 1 つの子だけが表示されます。

こうしたコレクション ビューは、リモートデータに基づくコレクションを表示するため、Adapter を使用してユーザー インターフェースをデータにバインドします。Adapter は、1 セットのデータから個々のアイテムを個別の View オブジェクトにバインドします。

こうしたコレクション ビューはアダプタを使用するため、Android フレームワークにはウィジェットでの使用をサポートするための追加のアーキテクチャが必要です。ウィジェットでは、AdapterRemoteViewsFactory に置き換えられます。これは、Adapter インターフェースのシンラッパーです。コレクション内の特定のアイテムについてリクエストされた場合、RemoteViewsFactory はコレクションのアイテムを作成して RemoteViews オブジェクトとして返します。ウィジェットにコレクション ビューを含めるには、RemoteViewsServiceRemoteViewsFactory を実装します。

RemoteViewsService は、リモート アダプタが RemoteViews オブジェクトをリクエストできるようにするサービスです。RemoteViewsFactory は、コレクション ビュー(ListViewGridViewStackView など)とそのビューの元となるデータの間のアダプタのためのインターフェースです。StackWidget サンプルから、このサービスとインターフェースを実装するためのボイラープレート コードの例を次に示します。

class StackWidgetService : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
        StackRemoteViewsFactory(this.applicationContext, intent)
}

class StackRemoteViewsFactory(
    private val context: Context, intent: Intent
) : RemoteViewsService.RemoteViewsFactory {

    // See the RemoteViewsFactory API reference for the full list of methods to implement.

}

サンプル アプリケーション

このセクションのコードも、StackWidget サンプルから抜粋したものです。

積み上げビューを表示する StackWidget
図 1. StackWidget

このサンプルは、0 ~ 9 の値を表示する 10 個のビューのスタックで構成されています。サンプル ウィジェットの主な動作は次のとおりです。

  • ユーザーがウィジェットの最前面のビューを上下に動かして次または前のビューを表示できる。これは StackView に組み込まれた動作です。

  • ユーザー インタラクションなしに、ウィジェットが自動的に(スライドショーのように)順番にビューを表示する。これは、res/xml/stackwidgetinfo.xml ファイルでの android:autoAdvanceViewId="@id/stack_view" の設定によります。この設定はビュー ID に適用され、この場合はスタック ビューのビュー ID が対象となります。

  • ユーザーが最前面のビューにタッチしたときに、ウィジェットが Toast メッセージ「ビュー n にタッチしました」を表示する(n はタッチされたビューのインデックス(位置)を表す)。動作の実装方法について詳しくは、個々のアイテムに動作を追加するをご覧ください。

コレクションを使用するウィジェットを実装する

コレクションを使用するウィジェットを実装するには、ウィジェットを実装する手順に沿って、マニフェストの変更、ウィジェット レイアウトへのコレクション ビューの追加、AppWidgetProvider サブクラスの変更を行います。

コレクションを使用するウィジェットのマニフェスト

マニフェストでウィジェットを宣言するで列挙した要件に加えて、コレクションを使用するウィジェットが RemoteViewsService にバインドできるようにする必要があります。これを行うには、マニフェスト ファイルで BIND_REMOTEVIEWS 権限を使用してサービスを宣言します。こうすることで、他のアプリがウィジェットのデータに自由にアクセスできないようにします。

たとえば、RemoteViewsService を使用してコレクション ビューにデータを入力するウィジェットを作成する場合のマニフェスト エントリの例を以下に示します。

<service android:name="MyWidgetService"
    android:permission="android.permission.BIND_REMOTEVIEWS" />

この例では、android:name="MyWidgetService"RemoteViewsService のサブクラスを示しています。

コレクションを使用するウィジェットのレイアウト

ウィジェットのレイアウト XML ファイルの主な要件は、ListViewGridViewStackViewAdapterViewFlipper のコレクション ビューのいずれかを含むことです。StackWidget サンプルwidget_layout.xml ファイルは次のとおりです。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <StackView
        android:id="@+id/stack_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:loopViews="true" />
    <TextView
        android:id="@+id/empty_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:background="@drawable/widget_item_background"
        android:textColor="#ffffff"
        android:textStyle="bold"
        android:text="@string/empty_view_text"
        android:textSize="20sp" />
</FrameLayout>

空のビューは、そのビューで空の状態を表しているコレクション ビューの兄弟とする必要があります。

ウィジェット全体のレイアウト ファイルに加えて、コレクション内の各アイテムのレイアウト(たとえば、本のコレクション内の個々の本のレイアウト)を定義する別のレイアウト ファイルを作成します。StackWidget サンプルでは、すべてのアイテムが同じレイアウトを使用するため、アイテム レイアウト ファイルは widget_item.xml 1 つだけです。

コレクションを使用するウィジェットの AppWidgetProvider クラス

通常のウィジェットの場合と同様に、AppWidgetProvider サブクラス内のコードの大部分が通常、onUpdate() でも使用されます。コレクションを使用するウィジェットを作成する際の onUpdate() の実装での主な違いは、setRemoteAdapter() を呼び出す必要があることです。このメソッドは、コレクション ビューにデータを取得する場所を指示します。すると RemoteViewsServiceRemoteViewsFactory の実装を返すことができ、ウィジェットは適切なデータを提供できます。このメソッドを呼び出すときは、RemoteViewsService の実装を指すインテントと、更新するウィジェットを指定するウィジェット ID を渡します。

例として、StackWidget サンプルから、onUpdate() コールバック メソッドを実装して RemoteViewsService をウィジェット コレクションのリモート アダプタとして設定するコードを以下に示します。

override fun onUpdate(
    context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray
) {
    // Update each of the widgets with the remote adapter.
    appWidgetIds.forEach { appWidgetId ->

        // Set up the intent that starts the StackViewService, which
        // provides the views for this collection.
        val intent = Intent(context, StackWidgetService::class.java).apply {
            // Add the widget ID to the intent extras.
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
            data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
        }
        // Instantiate the RemoteViews object for the widget layout.
        val views = RemoteViews(context.packageName, R.layout.widget_layout).apply {
            // Set up the RemoteViews object to use a RemoteViews adapter.
            // This adapter connects to a RemoteViewsService through the
            // specified intent.
            // This is how you populate the data.
            setRemoteAdapter(R.id.stack_view, intent)

            // The empty view is displayed when the collection has no items.
            // It must be in the same layout used to instantiate the
            // RemoteViews object.
            setEmptyView(R.id.stack_view, R.id.empty_view)
        }

        // Do additional processing specific to this widget.

        appWidgetManager.updateAppWidget(appWidgetId, views)
    }
    super.onUpdate(context, appWidgetManager, appWidgetIds)
}

データを永続化する

このページで説明したように、RemoteViewsService サブクラスは、リモート コレクション ビューのデータ入力に使用される RemoteViewsFactory を提供します。

具体的には、次の手順を行います。

  1. サブクラス RemoteViewsServiceRemoteViewsService は、リモート アダプタが RemoteViews をリクエストできるようにするサービスです。

  2. RemoteViewsService サブクラスに、RemoteViewsFactory インターフェースを実装するクラスを含めます。RemoteViewsFactory は、リモート コレクション ビュー(ListViewGridViewStackView など)とそのビューの元となるデータの間のアダプタのためのインターフェースです。データセット内の各アイテムについて RemoteViews オブジェクトを作成する作業は実装の責任です。このインターフェースは Adapter のシンラッパーです。

サービスの 1 つのインスタンス、またはそれに含まれるデータは維持されません。静的でない限り、データを RemoteViewsService に保存することは避ける必要があります。ウィジェットのデータを維持する方法としては、データがプロセスのライフサイクルを超えて維持される ContentProvider を使用するのが最善です。たとえば、食料品店のウィジェットは、各食料品リスト アイテムの状態を SQL データベースなどの永続的な場所に保存できます。

RemoteViewsService 実装のメイン コンテンツは RemoteViewsFactory です。これについて次のセクションで説明します。

RemoteViewsFactory インターフェース

RemoteViewsFactory インターフェースを実装するカスタムクラスは、コレクション内のアイテムのデータをウィジェットに提供します。このために、ウィジェット アイテムの XML レイアウト ファイルにデータのソースを組み合わせます。このデータソースは、データベースでも配列でも構いません。StackWidget のサンプルでは、データソースは WidgetItems の配列になっています。RemoteViewsFactory は、データをリモート コレクション ビューに結びつけるアダプタとして機能します。

RemoteViewsFactory サブクラスのために実装する必要がある特に重要な 2 つのメソッドは、onCreate()getViewAt() です。

初めて Factory を作成するときに、システムは onCreate() を呼び出します。ここで、データソースへの接続やカーソルをセットアップします。たとえば、StackWidget サンプルでは、onCreate() を使用して WidgetItem オブジェクトの配列を初期化します。ウィジェットが有効になると、システムが配列内のインデックス位置を使用してこのオブジェクトにアクセスし、オブジェクト内のテキストが表示されます。

StackWidget サンプルから、onCreate() メソッドの位置を示す RemoteViewsFactory の実装を抜粋したコードを以下に示します。

private const val REMOTE_VIEW_COUNT: Int = 10

class StackRemoteViewsFactory(
    private val context: Context
) : RemoteViewsService.RemoteViewsFactory {

    private lateinit var widgetItems: List<WidgetItem>

    override fun onCreate() {
        // In onCreate(), set up any connections or cursors to your data
        // source. Heavy lifting, such as downloading or creating content,
        // must be deferred to onDataSetChanged() or getViewAt(). Taking
        // more than 20 seconds on this call results in an ANR.
        widgetItems = List(REMOTE_VIEW_COUNT) { index -> WidgetItem("$index!") }
    }

}

RemoteViewsFactory メソッドの getViewAt() は、データセット内の指定された position にあるデータに対応する RemoteViews オブジェクトを返します。StackWidget サンプルの RemoteViewsFactory 実装から抜粋したコードを以下に示します。

override fun getViewAt(position: Int): RemoteViews {
    // Construct a remote views item based on the widget item XML file
    // and set the text based on the position.
    return RemoteViews(context.packageName, R.layout.widget_item).apply {
        setTextViewText(R.id.widget_item, widgetItems[position].text)
    }
}

個々のアイテムに動作を追加する

これまでのセクションでは、ウィジェットのコレクションにデータをバインドする方法を説明しました。ここでは、コレクション ビューの個々のアイテムに動的な動作を追加する場合について説明します。

onUpdate() クラスでイベントを処理するで説明したように、通常は setOnClickPendingIntent() を使用してオブジェクトのクリック動作(ボタンに Activity を起動させるなど)を設定します。ただしこの手法は、個々のコレクション アイテム内の子ビューでは使用できません。たとえば、setOnClickPendingIntent() を使用して Gmail ウィジェットでアプリを起動するグローバル ボタンをセットアップすることはできますが、個々のリスト項目ではできません。

コレクション内の個々のアイテムにクリック動作を追加するには、setOnClickFillInIntent() を使用します。コレクション ビューのペンディング インテント テンプレートをセットアップし、RemoteViewsFactory を使用してコレクション内の各アイテムでフィルイン インテントを設定します。

このセクションでは、StackWidget サンプルを使用して、個々のアイテムに動作を追加する方法について説明します。StackWidget サンプルでは、ユーザーが最前面のビューにタッチすると、ウィジェットは Toast メッセージ「ビュー n にタッチしました」を表示します(n はタッチされたビューのインデックス(位置)を示します)。次のような仕組みになっています。

  • StackWidgetProviderAppWidgetProvider サブクラス)が、TOAST_ACTION と呼ばれるカスタム アクションを持つペンディング インテントを作成します。

  • ユーザーがビューにタッチしたときに、インテントが呼び出され、TOAST_ACTION をブロードキャストします。

  • このブロードキャストは StackWidgetProvider クラスの onReceive() メソッドによってインターセプトされ、ウィジェットがタッチされたビューについて Toast メッセージを表示します。コレクション アイテムのデータは、RemoteViewsService を介して RemoteViewsFactory によって提供されます。

ペンディング インテント テンプレートをセットアップする

ペンディング インテントをセットアップするのは StackWidgetProviderAppWidgetProvider サブクラス)です。コレクション内の個々のアイテムは自分のペンディング インテントをセットアップできません。コレクション全体がペンディング インテント テンプレートをセットアップし、個々のアイテムがフィルイン インテントを設定して、アイテムごとに一意の動作を作成します。

このクラスは、ユーザーがビューにタッチしたときに送信されるブロードキャストも受け取ります。このイベントを onReceive() メソッドで処理します。インテントのアクションが TOAST_ACTION であれば、ウィジェットは現在のビューについて Toast メッセージを表示します。

const val TOAST_ACTION = "com.example.android.stackwidget.TOAST_ACTION"
const val EXTRA_ITEM = "com.example.android.stackwidget.EXTRA_ITEM"

class StackWidgetProvider : AppWidgetProvider() {

    // ...

    // Called when the BroadcastReceiver receives an Intent broadcast.
    // Checks whether the intent's action is TOAST_ACTION. If it is, the
    // widget displays a Toast message for the current item.
    override fun onReceive(context: Context, intent: Intent) {
        val mgr: AppWidgetManager = AppWidgetManager.getInstance(context)
        if (intent.action == TOAST_ACTION) {
            val appWidgetId: Int = intent.getIntExtra(
                AppWidgetManager.EXTRA_APPWIDGET_ID,
                AppWidgetManager.INVALID_APPWIDGET_ID
            )
            // EXTRA_ITEM represents a custom value provided by the Intent
            // passed to the setOnClickFillInIntent() method to indicate the
            // position of the clicked item. See StackRemoteViewsFactory in
            // Set the fill-in Intent for details.
            val viewIndex: Int = intent.getIntExtra(EXTRA_ITEM, 0)
            Toast.makeText(context, "Touched view $viewIndex", Toast.LENGTH_SHORT).show()
        }
        super.onReceive(context, intent)
    }

    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // Update each of the widgets with the remote adapter.
        appWidgetIds.forEach { appWidgetId ->

            // Sets up the intent that points to the StackViewService that
            // provides the views for this collection.
            val intent = Intent(context, StackWidgetService::class.java).apply {
                putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
                // When intents are compared, the extras are ignored, so embed
                // the extra sinto the data so that the extras are not ignored.
                data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
            }
            val rv = RemoteViews(context.packageName, R.layout.widget_layout).apply {
                setRemoteAdapter(R.id.stack_view, intent)

                // The empty view is displayed when the collection has no items.
                // It must be a sibling of the collection view.
                setEmptyView(R.id.stack_view, R.id.empty_view)
            }

            // This section makes it possible for items to have individualized
            // behavior. It does this by setting up a pending intent template.
            // Individuals items of a collection can't set up their own pending
            // intents. Instead, the collection as a whole sets up a pending
            // intent template, and the individual items set a fillInIntent
            // to create unique behavior on an item-by-item basis.
            val toastPendingIntent: PendingIntent = Intent(
                context,
                StackWidgetProvider::class.java
            ).run {
                // Set the action for the intent.
                // When the user touches a particular view, it has the effect of
                // broadcasting TOAST_ACTION.
                action = TOAST_ACTION
                putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
                data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))

                // The template must be mutable, because each item fills in its
                // own extras through setOnClickFillInIntent().
                PendingIntent.getBroadcast(
                    context, 0, this,
                    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
                )
            }
            rv.setPendingIntentTemplate(R.id.stack_view, toastPendingIntent)

            appWidgetManager.updateAppWidget(appWidgetId, rv)
        }
        super.onUpdate(context, appWidgetManager, appWidgetIds)
    }
}

フィルイン インテントを設定する

RemoteViewsFactory が、コレクション内の各アイテムについてフィルイン インテントを設定する必要があります。こうすることで、特定のアイテムがクリックされたときの個々のアクションを区別できるようになります。次に、フィルイン インテントと PendingIntent テンプレートを組み合わせて、アイテムがタップされたときに実行される最終的なインテントを決定します。

private const val REMOTE_VIEW_COUNT: Int = 10

class StackRemoteViewsFactory(
    private val context: Context,
    intent: Intent
) : RemoteViewsService.RemoteViewsFactory {

    private lateinit var widgetItems: List<WidgetItem>
    private val appWidgetId: Int = intent.getIntExtra(
        AppWidgetManager.EXTRA_APPWIDGET_ID,
        AppWidgetManager.INVALID_APPWIDGET_ID
    )

    override fun onCreate() {
        // In onCreate(), set up any connections or cursors to your data source.
        // Heavy lifting, such as downloading or creating content, must be
        // deferred to onDataSetChanged() or getViewAt(). Taking more than 20
        // seconds on this call results in an ANR.
        widgetItems = List(REMOTE_VIEW_COUNT) { index -> WidgetItem("$index!") }
        // ...
    }

    // ...

    override fun getViewAt(position: Int): RemoteViews {
        // Construct a remote views item based on the widget item XML file
        // and set the text based on the position.
        return RemoteViews(context.packageName, R.layout.widget_item).apply {
            setTextViewText(R.id.widget_item, widgetItems[position].text)

            // Set a fill-intent to fill in the pending intent template.
            // that is set on the collection view in StackWidgetProvider.
            val fillInIntent = Intent().apply {
                Bundle().also { extras ->
                    extras.putInt(EXTRA_ITEM, position)
                    putExtras(extras)
                }
            }
            // Make it possible to distinguish the individual on-click
            // action of a given item.
            setOnClickFillInIntent(R.id.widget_item, fillInIntent)
            // ...
        }
    }
    // ...
}

コレクション データを最新の状態に保つ

図 2 は、コレクションを使用するウィジェットの更新フローを示しています。ウィジェットのコードが RemoteViewsFactory とどのように通信するか、また更新をどのように開始できるかを示しています。

更新中の RemoteViewsFactory とのやり取り
図 2. 更新中の RemoteViewsFactory とのやり取り。

コレクションを使用するウィジェットは、ユーザーに最新のコンテンツを提供できます。たとえば、Gmail ウィジェットでは、ユーザーに受信トレイのスナップショットが表示されます。それには、RemoteViewsFactory とコレクション ビューを起動して新しいデータを取得して表示させる必要があります。

これを行うには、AppWidgetManager を使用して notifyAppWidgetViewDataChanged() を呼び出します。この呼び出しの結果、RemoteViewsFactory オブジェクトの onDataSetChanged() メソッドへのコールバックが行われ、新しいデータを取得できるようになります。

onDataSetChanged() コールバックの中では、大量の処理が発生するオペレーションを同時に実行できます。この呼び出しは、RemoteViewsFactory からメタデータまたはビューデータが取得される前に完了します。また、getViewAt() メソッドの中でも、大量の処理が発生するオペレーションを実行できます。この呼び出しに時間がかかる場合、応答があるまで、コレクション ビューに対応する位置に読み込み中のビュー(RemoteViewsFactory オブジェクトの getLoadingView() メソッドで指定されたもの)が表示されます。

RemoteCollectionItems を使用してコレクションを直接渡す

Android 12(API レベル 31)では、setRemoteAdapter(int viewId, RemoteViews.RemoteCollectionItems items) メソッドが追加されました。このメソッドにより、アプリはコレクション ビューの入力時にコレクションを直接渡せるようになります。このメソッドを使用してアダプターを設定する場合、RemoteViewsFactory を実装する必要はなく、notifyAppWidgetViewDataChanged() を呼び出す必要もありません。

このアプローチでは、アダプターへの入力が容易になるだけでなく、ユーザーがリストをスクロールして新しいアイテムを表示するときに、新しいアイテムを入力する際の遅延も解消されます。このアダプターの設定方法は、コレクション アイテムのセットが比較的小さい場合に適しています。ただし、たとえば、コレクションに setImageViewBitmap に渡される Bitmaps が多数含まれている場合、このアプローチはうまく機能しません。

コレクションで一定数のレイアウトが使用されていない場合(つまり、一部のアイテムがときどきしか存在しない場合)、setViewTypeCount を使用してコレクションに含まれる固有のレイアウトの最大数を指定します。これにより、アプリ ウィジェットの更新間でアダプターを再利用できます。

簡素化された RemoteViews コレクションを実装する方法の例を次に示します。

val itemLayouts = listOf(
    R.layout.item_type_1,
    R.layout.item_type_2,
    // ...
)

remoteView.setRemoteAdapter(
    R.id.list_view,
    RemoteViews.RemoteCollectionItems.Builder()
        .addItem(/* id= */ ID_1, RemoteViews(context.packageName, R.layout.item_type_1))
        .addItem(/* id= */ ID_2, RemoteViews(context.packageName, R.layout.item_type_2))
        // ...
        .setViewTypeCount(itemLayouts.count())
        .build()
)