Android

Androidウィジェットの作り方

アプリの分身をホーム画面に表示する、Androidウィジェットを、有名デザイナーmizutamaさんとのコラボアプリ「mizutamaTODO」に追加していく実戦形式で解説していきます!

Androidの基本的な実装は把握しているものとして、基本的な実装の詳細については割愛します。

 

1. さぁ早速、作っちゃおう!

AndroidStudioのメニューから

File > New > Widget > App Widget

で次のようなウィザードが開きます。
一番最初の項目「Class Name」を好きな名前に変えつつ「Configuration Screen」にチェックを入れます。

これはウィジェットの設定画面を追加するオプションで、mizutamaTODOの場合、「ウィジェットに表示するTODOのカテゴリーを選ばせたい」ので、 チェックを入れます

そして、「Finish」を押します。

widget関連ファイルをwidgetフォルダを作って入れたかったのですが、エラーが出て動かなかったので、ルートに置いています。
databindingが悪さしてるみたいですが、ちょっと僕には直せませんでした..

これだけで
ウィジェット本体やウィジェットの設定xmlの作成、AndroidManifestへの設定追加、各種テーマファイルの追加等々が一気に行われます。

どっちゃり

ただ、ほとんどのファイルはサンプルソース用に追加されたものなので、次のファイルは削除 & リセットしちゃってOKです。

新規追加ファイル

  • drawable-v21/app_widget_background.xml
  • drawable-v21/app_widget_inner_view_background.xml
  • values/attrs.xml
  • values/themes.xml
  • values-night-v31/themes.xml
  • values-v21/styles.xml
  • values-v31/styles.xml
  • values-v31/themes.xml

 

変更

  • values/colors.xml
  • values/dimens.xml
  • values/strings.xml
  • values/styles.xml

 

この時点でもう、最低機能のウィジェットは完成しているので、ホーム画面を長押しで出てくる「ウィジェット」メニューを選ぶと、(変な見た目だけど)もうあります!

 

早速ホーム画面に追加してみると、ウィジェットの初期設定画面が表示されるので、適当に入力して…

 

「ADD WIDGET」ボタンを押すと、変なのがホーム画面に追加されます。

 

サイズを変更してみると、先程入力した文字が入ったウィジェットが表示されます。

 

2. ウィジェットの初期設定画面を作ってみよう

次に、ウィジェットに表示したいTODOのカテゴリを選択する、初期設定画面を作っていきます。
先程、早速ウィジェットをホーム画面に追加したら出てきた設定画面ですね。

あれを、アプリでカテゴリを選ぶ画面はこんな感じなので、同じような画面に作り変えます。

初期設定画面は、自動生成された「AppWidgetConfigureActivity.kt」に実装します。

これは名前の通り、Activityのサブクラスなので、「onCreate関数」の中にある「setContentView」のあたりに、UIを定義している処理が見つかります。

public override fun onCreate(icicle: Bundle?) {
  super.onCreate(icicle)

  // Set the result to CANCELED. This will cause the widget host to cancel
  // out of the widget placement if the user presses the back button.
  setResult(RESULT_CANCELED)

  binding = AppWidgetConfigureBinding.inflate(layoutInflater)
  setContentView(binding.root) // コレ

 

binding.rootの「root」の部分をCmdを押しながら(WindowsだとCtrl) クリックすると、layout/app_widget_configure.xml、が開きます。
ここにカテゴリのリストを作成していきます。

ウィジェットで使えるViewは次の通りです。

  • FrameLayout
  • LinearLayout
  • RelativeLayout
  • GridLayout
  • AnalogClock
  • Button
  • Chronometer
  • ImageButton
  • ImageView
  • ProgressBar
  • TextView
  • ViewFlipper
  • ListView
  • GridView
  • StackView
  • AdapterViewFlipper

アプリではRecyclerViewを使って作っていましたが、ウィジェットでは使えないのでListViewでちょちょいと作っていきます。
作り方はアプリと一緒なので、割愛します。

完成!

デフォルトの初期設定画面は、ADD_WIDGETボタンを押すと、単純にウィジェットがホーム画面に表示されましたが、今回はボタンではなく、任意のリストの行を選んだらホーム画面にウィジェットを表示したいので、リストのクリックイベントに次のコードを書いておきます。

ウィジェットへはSharedPreferencesを使って情報を渡します。

listView.setOnItemClickListener { parent, view, position, id ->
  val context = this@AppWidgetConfigureActivity

  // SharedPreferencesに選ばれたカテゴリのIDを保存する
  saveCategoryIDPref(context, appWidgetId, categoryList[position].category_id)

  // これ以降はデフォルトのソースなので、そのままにしておけばOK
  val appWidgetManager = AppWidgetManager.getInstance(context)
  updateAppWidget(context, appWidgetManager, appWidgetId)

  val resultValue = Intent()
  resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
  setResult(RESULT_OK, resultValue)

  finish()
}

 

「同じアプリのウィジェットを複数設置できるので」 SharedPreferencesのキーは
「キー + appWidgetId」
とするのがポイントです。
appWidgetIdは、ウィジェット毎に自動で割り振られるIDで、新規作成で作られるソースに最初から定義されています。

private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID

...

private const val PREFS_NAME = "hogehoge.AppWidget"
private const val PREF_PREFIX_KEY = "appwidget_"

internal fun saveCategoryIDPref(context: Context, appWidgetId: Int, categoryID: Int) {
  val prefs = context.getSharedPreferences(PREFS_NAME, 0).edit()
  prefs.putInt(PREF_PREFIX_KEY + appWidgetId, categoryID)
  prefs.apply()
}

 

 

3. ウィジェット画面を作ってみよう

選ばれたカテゴリに含まれるTODO一覧を表示するウィジェット本体を実装していきます。アプリのTODO一覧はこんな感じなので、同じような画面に作り変えます。

3.1.レイアウトの実装

ウィジェット本体は、自動生成された「AppWidget.kt」に実装します。

こちらは設定画面と違って、AppWidgetProviderのサブクラスなので、普段Androidネイティブ開発に慣れている人でも見たことが無い処理が並んでいますが、デフォルトのソースに、次の様な処理があるので、こちらを書き換えて、TODO一覧画面を作っていきます。

internal fun updateAppWidget(
  context: Context,
  appWidgetManager: AppWidgetManager,
  appWidgetId: Int
) {
  val widgetText = loadTitlePref(context, appWidgetId)
  // Construct the RemoteViews object
  val views = RemoteViews(context.packageName, R.layout.app_widget)
  views.setTextViewText(R.id.appwidget_text, widgetText)

  // Instruct the widget manager to update the widget
  appWidgetManager.updateAppWidget(appWidgetId, views)
}

 

R.layout.app_widgetは、ウィジェットの画面になるので、そちらを書き換えてレイアウトを作ればOKです。

今回はTODO一覧なので、ListViewを使ってちょちょいのちょいとリストを作っておきます。
特に特別な事は何もありません。

3.2. レイアウトへデータをセット

レイアウトへのデータセットが少し厄介です。

デフォルトのソースコードをもう一度見ると、見慣れないデータのセットの仕方をしているのが分かります。

val views = RemoteViews(context.packageName, R.layout.app_widget)
views.setTextViewText(R.id.appwidget_text, widgetText) //こいつ

 

いつものやり方なら、TextViewにtextをセットするなら

views.findViewById(R.id.appwidget_text).text = "ほげほげ"

 

の様に書く所ですが、Widget本体へのデータセットは、RemoteViewsインスタンスのメソッドを通してセットしていくことになります。

そのため、TextViewクラスにあるパラメータでも、RemoteViewsにセット関数がない場合、変更することはできません。

この点に注意して、上に貼った外部リンクにあるRemoteViewsの関数一覧を何度も眺めて値をセットしていきます。

今回使ったものは

  • TextViewに文字をセットする、setTextViewText
  • ImageViewに画像をセットする、setImageViewResource

です。

それと、ListViewにAdapterをセットする setRemoteAdapterですが、こちらはかなり独特なので次の節で解説します。

3.3. ListViewへのデータセット

ListViewにデータをセットする場合、他のセット関数と違い、Serviceを使って間接的にデータをセットします。

そのため、ListViewにデータをセットするセット関数
setRemoteAdapter(viewId: Int, intent: Intent)
は、第2引数に、Adapterではなくて、Intentを指定します。

変に複雑なので、ListViewにデータをセットする流れを整理すると

  1. setRemoteAdapter、で RemoteViewService のサブクラスを呼び出す
  2. RemoteViewService.onGetViewFactory(Intent): RemoteViewsService.RemoteViewsFactory、が呼ばれる
  3. RemoteViewsService.RemoteViewsFactoryインタフェースを実装したのサブクラスで、BaseAdapterと同様の処理が実行され、ListViewにデータがセットされる

となります。

具体的に実装手順を、後ろから説明したほうが抜けがなく分かりやすいので、逆順に解説していきます。

3.3.1 RemoteViewsService.RemoteViewsFactory

RemoteViewsFactoryは、RemoteViewsServiceに定義されているインタフェースで、この中でListViewの生成、更新を行います。

BaseAdapterとほとんど同じなので、馴染み深いかと思いますがActivityで使う場合と微妙に違う所もあるので、一部、実装例と共に、RemoteViewsFactoryインタフェースで宣言されている関数を載せておきます。

// 初期化時に1回だけ呼ばれる
fun onCreate() {
  // SharedPreferencesからカテゴリーIDとか取っておくと良さそうですよね。
}

// 初期化時と、それ以降、データが変更される度に呼ばれる
fun onDataSetChanged() {
  // リストのデータの生成をすると良さそうですね。
}

// 破棄の時ですよね
fun onDestroy() {
  // 破棄したいデータがあればここで。
}

// リストのアイテム数
fun getCount(): Int {
  // リストのデータのカウントを返せばいいですね。
}

// リストのレイアウト
fun getViewAt(int position): RemoteViews {
  // BaseAdapterと似ていますが、戻り値がViewではなく、RemoteViewsな点に注意!

  // TODOリスト用に作ったリストのレイアウトを元に、RemoteViewsインスタンスを生成
  // contextはコンストラクターの引数でもらうと良さそうですね
  val remoteViews = RemoteViews(context.packageName, R.layout.widget_item_layout)

  // リストにデータをセットする処理を書いて...

  // リストのクリックイベントを埋め込み、アプリ側にIntent経由で情報を渡す。
  val fillInIntent = Intent()
  fillInIntent.putExtra("適当なキー", categoryID)
  remoteViews.setOnClickFillInIntent(リストのレイアウトのルートのID, fillInIntent)

  return remoteViews
}

// nullを返しておけばデフォルトのローディング表示がでる。
fun getLoadingView(): RemoteViews? {
  return null
}

// リストのレイアウトの種類。
fun getViewTypeCount(): Int {
  return 1
}

// 特に意図がなければ、引数をLong型に変換してそのまま返せばOK。
fun getItemId(int position); Long {
  return position.toLong()
}

// trueを返しておけばOK。
fun hasStableIds(): Boolean {
  return true
}

 

3.3.2 RemoteViewsService

RemoteViewsServiceのサブクラスを用意して、AndroidManifest.xmlに登録します。

3.3.1 RemoteViewsService.RemoteViewsFactoryで解説した実装クラスのコンストラクタは、今回は次の様な形としました。

TodoRemoteViewsFactory(
  val context: Context,
  val intent: Intent
): RemoteViewsService.RemoteViewsFactory

これを返すServiceを定義します。

// applicationContextのゲッターは、Serviceの親クラスContextWrapperに定義されているので、しれーっと呼び出せます。
class TodoWidgetService: RemoteViewsService() {
  override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
    return TodoRemoteViewsFactory(applicationContext, intent)
  }
}

 

そして、AndroidManifest.xmlに登録します。Activityと同じ並びの好きな場所にどうぞ。

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

 

3.3.3 RemoteViewServiceのサブクラスを呼び出す

「3.2. レイアウトへデータをセット」の中で、RemoteViewsを通してViewに値をセットしていく話をしましたが、ここで、RemoteViewServiceのサブクラスを呼び出して、ListViewに値をセットさせます。

val intent = Intent(context, TodoWidgetService::class.java).apply {
  putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
  data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
views.setRemoteAdapter(R.id.todoListView, intent)

 

という訳で、無事、ListViewに値がセットされ、こんな感じに表示されました。
かわゆぃ💗

 

4. その他、細かい話

その他、実装で必要になる細かい話を以下にのせておきます。

4.1. アプリ側からウィジェットをリフレッシュさせるには?

ウィジェットの本体の処理を記述しているAppWidgetに、次のようなクラス関数を用意し、ウィジェットをリフレッシュしたいタイミングでアプリから呼ぶようにします。

僕は、アプリがバックグラウンドに入ったタイミングで呼んでいます。

companion object {
  // アプリから呼ぶWidgetの更新メソッド
  fun sendRefreshBroadCast(context: Context) {
    val i = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
    i.setComponent(ComponentName(context, AppWidget::class.java))
    context.sendBroadcast(i)
  }
}

 

そして、AppWidgetのonReceive内で、BroadCastをハンドリングすればOKです。
例えば、ListViewを更新させたい場合は..

override fun onReceive(context: Context?, intent: Intent?) {
  if (context != null
      && intent?.action == AppWidgetManager.ACTION_APPWIDGET_UPDATE) {
    val appWidgetManager = AppWidgetManager.getInstance(context)
    val componentName = ComponentName(context, AppWidget::class.java)

    // ListViewを更新させる
    appWidgetManager.notifyAppWidgetViewDataChanged(
      appWidgetManager.getAppWidgetIds(componentName), R.id.todoListView)
  }

  super.onReceive(context, intent)
}

 

4.2. ウィジェットを押したらアプリを開く、には?

ウィジェットの背景にクリックイベントをセットすればOKです。

val views = RemoteViews(context.packageName, R.layout.app_widget)

...

val layoutIntent = Intent(context, MainActivity::class.java).apply {
  flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}

// アプリ側に渡したい情報をセット
layoutIntent.putExtra(Config.IntentKey.CATEGORY_ID_FORM_WIDGET.name, categoryID)
val pendingLayoutIntent = PendingIntent.getActivity(context, appWidgetId, layoutIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
// R.id.widgetLayoutがウィジェットのレイアウトのルート要素
views.setOnClickPendingIntent(R.id.widgetLayout, pendingLayoutIntent)

 

ListViewを使っている場合は、ListViewにもクリックイベントを追加しないと何も起きないので注意。

val clickIntentTemplate = Intent(context, MainActivity::class.java).apply {
  flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}

// アプリ側に渡したい情報をセット
clickIntentTemplate.putExtra(Config.IntentKey.CATEGORY_ID_FORM_WIDGET.name, categoryID)
val clickPendingIntentTemplate = TaskStackBuilder.create(context)
  .addNextIntentWithParentStack(clickIntentTemplate)
  .getPendingIntent(appWidgetId, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
views.setPendingIntentTemplate(R.id.todoListView, clickPendingIntentTemplate)

 

これで、ウィジェットをクリックするとアプリが起動し直されて、MainActivityのonCreateが呼ばれるので、そこで、ウィジェットから受け取ったパラメータを元に何かするとOKです。

4.3. ウィジェットの最小サイズの定義は?

デフォルトの設定のままだと、ウィジェットのサイズを一番小さくした時、見た目が不格好になってしまう場合があります。

これを防ぐには、xml/app_widget_info.xml、に

  • minWidth
  • minHeight

を追加して、最低サイズを指定してあげればOKです。

いくつにするかは、何セル、ウィジェットが使うかを元に指定してください。
セルとサイズの対応は以下のとおりです。

参考

minWidthとminHeightをどちらも110dpにして、一安心

4.4. ウィジェット一覧に表示されるサンプル画像の定義は?

ホーム画面にウィジェットを追加する時に表示させるウィジェット一覧。

ここに表示されるウィジェットのプレビュー画像は、xml/app_widget_info.xml、の
previewImage=”@drawable/example_appwidget_preview”
に定義されているので、drawable-nodpi/example_appwidget_preview.png、を画像編集ソフトで書き換えればOKです。

この際、xml/app_widget_info.xmlにpreviewLayoutプロパティがあると、Android12以降では、上記のプレビュー画像を使わず、previewLayoutに指定されているレイアウトを使った動的なプレビューを表示しようとするため、簡単に済ませたい場合は、previewLayoutプロパティを削除してください。

参考資料