1
1

More than 1 year has passed since last update.

ウィジェットをアプリ上に表示する ~ホームアプリ(ランチャーアプリ)の作り方~

Posted at

ホームアプリ(ランチャーアプリ)の作り方シリーズ
今回は他のアプリが提供するウィジェットを、自分のアプリ上に表示させてみましょう。

※シリーズが続くとは言っていない

AppWidgetをアプリ上に表示する機能はホームアプリ以外ではまず実装しない機能なのであまり知られていないかもしれません。
フル機能で実装しようとすると、ウィジェットの情報をDBなどに永続化する必要がありますし、グリッド上を移動させたり、リサイズさせたりといった機能も必要になりますが、この辺はウィジェット特有の事情は少なく、特にUI周りの実装が大変です。

ここでは、単にウィジェットをアプリの上に表示するという点に絞って説明してみます。
これだけなら、ほぼフレームワークがやってくれるので実装もそれほど難しくありません。
概ね以下のような操作になります。

  1. permissionを追加する
  2. AppWidgetHostを用意する
  3. ウィジェットを選択する(AppWidgetProviderInfoを取得する)
  4. AppWidgetIdを取得する
  5. AppWidgetIdとAppWidgetProviderをbindする
  6. configureが指定されていればそのActivityを起動する
  7. AppWidgetHostViewをcreateしViewとして貼り付ける

permissionを追加する

ウィジェットを貼り付けるにはパーミッションが必要です。Manifestに以下のパーミッションを追加します。
これはProtectedPermissionsなので、tools:ignoreもつけて起きます。

AndroidManifest.xml
<uses-permission
    android:name="android.permission.BIND_APPWIDGET"
    tools:ignore="ProtectedPermissions"
    />

AppWidgetHostを用意する

AppWidgetを提供するのはAppWidgetProviderで、それを表示するのがAppWidgetHostです。
AppWidgetHostの実体はシステムサービスですが、貼り付けたいアプリはこのAppWidgetHostのインスタンスを持っておく必要があります。
HOST_IDはアプリ内に複数のAppWidgetHostを保持する場合にそれらを区別するためのIDです。
アプリ内で重複しないように管理したInt値を設定します。

LauncherViewModel.kt
val appWidgetHost: AppWidgetHost = AppWidgetHost(application, HOST_ID)

また、本質的ではありませんが、ウィジェット自体はアプリが管理しているため、ウィジェットを貼り付けた後はアプリ終了後もその紐付けは生きたままとなります。この対応関係をDBなどで覚えておいてアプリの起動時に復元する必要があります。
今回、この機能はつくらないので、終了時に紐付けをクリアして起きます。本来はDBをクリアするなどした場合に実行するメソッドです。

LauncherViewModel.kt
override fun onCleared() {
    appWidgetHost.deleteHost()
}

また、ウィジェットの更新を行う期間を知らせるため、表示するActivityのonStart/onStopでstartListening/stopListeningをコールしておきましょう。

LauncherActivity.kt
override fun onStart() {
    super.onStart()
    viewModel.appWidgetHost.startListening()
}

override fun onStop() {
    super.onStop()
    viewModel.appWidgetHost.stopListening()
}

ウィジェットを選択する(AppWidgetProviderInfoを取得する)

これについては一覧を作る方法を説明していますので以下を参考。
ウィジェット一覧を作る ~ホームアプリ(ランチャーアプリ)の作り方~
この一覧のタップなどのイベントでAppWidgetProviderInfoを取得しましょう。

AppWidgetIdを取得する

AppWidgetProviderInfoを取得したら、Widgetに割り当てるAppWidgetIdを取得します。

val id = viewModel.appWidgetHost.allocateAppWidgetId()

ただの連番のInt値ですが、システム全体でユニークな値になります。

AppWidgetIdとAppWidgetProviderをbindする

IDを取得した段階では、まだ何にも割り当てられていないIDです。
このIDとAppWidgetProviderを紐付けます。

private fun setUpWidget(id: Int, info: AppWidgetProviderInfo) {
    val width = info.minWidth.toCellUnit()
    val height = info.minHeight.toCellUnit()
    val options = bundleOf(
        AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH to width,
        AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH to width,
        AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT to height,
        AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT to height,
    )
    val success =
        viewModel.appWidgetManager.bindAppWidgetIdIfAllowed(id, info.provider, options)
    if (success) {
        configureWidget(id, info)
        return
    }
    pendingAppWidgetId = id
    pendingAppWidgetProviderInfo = info
    val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_BIND)
    intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
    intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, info.provider)
    bindAppWidgetLauncher.launch(intent)
}

toCellUnit()については、AppWidgetProviderInfoで指定される最小サイズ(デフォルトサイズ)を上回る最小のグリッド単位のサイズを返すように作った拡張関数です。
詳細は後述するのでサイズを補正しているメソッドだと思ってください。

optionsとしてAppWidgetProviderに通知するサイズ情報を詰めたBundleを作成しています。
OPTION_APPWIDGET_MIN_WIDTHとOPTION_APPWIDGET_MAX_HEIGHTにportlaitの縦横サイズ、OPTION_APPWIDGET_MAX_WIDTHとOPTION_APPWIDGET_MIN_HEIGHT にlandscapeの縦横サイズを設定するようですが、一発表示するだけなので固定で設定しています。

AppWidgetManagerのbindAppWidgetIdIfAllowedをコールしてバインドを行います。IfAllowedという名前から、許可が必要なメソッドだと分かると思います。最初にManifestに追加したProtectedPermissionです。許可があればbindが成功しtrueが返ります。
falseが返ってきた場合は許可を取りに行く必要があります。

AppWidgetManager.ACTION_APPWIDGET_BINDというActionのIntentを投げることで以下のようなダイアログが出てきます。サードパーティのホームアプリをインストールしたことがあれば見たことがあると思います。ウィジェットの作成にはユーザーの許可が必要です。

チェックをつけて許可をすると、移行確認が不要になりますが、この許可はアプリをアンインストールしても残ってしまいます。削除するにはアプリ設定の「デフォルトで開く」から「設定を消去」する必要があります。

失敗した場合は、諦めるしかありませんが、割り当て予定だったAppWidgetIdを開放しておきます。
成功した場合は、Bindが完了しているため、次の処理に進みます。

private val bindAppWidgetLauncher = registerForActivityResult(StartActivityForResult()) {
    val pendingAppWidgetId =
        it.data?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, pendingAppWidgetId)
            ?: pendingAppWidgetId
    val pendingAppWidgetProviderInfo =
        viewModel.appWidgetManager.getAppWidgetInfo(pendingAppWidgetId)
            ?: pendingAppWidgetProviderInfo
    if (it.resultCode != Activity.RESULT_OK) {
        if (pendingAppWidgetId >= 0) {
            viewModel.appWidgetHost.deleteAppWidgetId(pendingAppWidgetId)
        }
    } else {
        if (pendingAppWidgetId >= 0 && pendingAppWidgetProviderInfo != null) {
            configureWidget(pendingAppWidgetId, pendingAppWidgetProviderInfo)
        }
    }
    this.pendingAppWidgetId = INVALID_ID
    this.pendingAppWidgetProviderInfo = null
}

configureが指定されていればそのActivityを起動する

configureにComponentInfoが設定されている場合は、ウィジェットを設置する前に設定画面を呼び出す必要があります。
設定されていなければnullになっているので次の処理に進みます。

private fun configureWidget(id: Int, info: AppWidgetProviderInfo) {
    if (info.configure == null) {
        placeWidget(id, info)
        return
    }
    pendingAppWidgetId = id
    pendingAppWidgetProviderInfo = info
    val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE)
    intent.component = info.configure
    intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
    configureLauncher.launch(intent)
}

呼び出した設定画面で、ウィジェット提供側の設定が行われます。これは提供側で保持されるため戻り値を反映させる必要などはありませんが、設定が行われなかった場合はウィジェットの設置ができませんので、AppWidgetIdを開放して起きます。
成功すれば次の処理に進みます。

private val configureLauncher = registerForActivityResult(StartActivityForResult()) {
    val pendingAppWidgetId =
        it.data?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, pendingAppWidgetId)
            ?: pendingAppWidgetId
    val pendingAppWidgetProviderInfo =
        viewModel.appWidgetManager.getAppWidgetInfo(pendingAppWidgetId)
            ?: pendingAppWidgetProviderInfo
    if (it.resultCode != Activity.RESULT_OK) {
        if (pendingAppWidgetId >= 0) {
            viewModel.appWidgetHost.deleteAppWidgetId(pendingAppWidgetId)
        }
    } else {
        if (pendingAppWidgetId >= 0 && pendingAppWidgetProviderInfo != null) {
            placeWidget(pendingAppWidgetId, pendingAppWidgetProviderInfo)
        }
    }
    this.pendingAppWidgetId = INVALID_ID
    this.pendingAppWidgetProviderInfo = null
}

AppWidgetHostViewをcreateしViewとして貼り付ける

前準備が終わったのでViewをcreateし、Viewに貼り付けます。
ここの処理は通常のViewと同じです。

最後にupdateAppWidgetSizeをコールして再度サイズを通知していますが、サイズが変化した場合にコールするので、このタイミングでは必ずしも必要ありません。

private fun placeWidget(id: Int, info: AppWidgetProviderInfo) {
    val context = requireContext()
    val view = viewModel.appWidgetHost.createView(context.applicationContext, id, info)
    val width = info.minWidth.toCellUnit()
    val height = info.minHeight.toCellUnit()
    val params = FrameLayout.LayoutParams(width, height)
    params.gravity = Gravity.CENTER
    view.layoutParams = params
    binding.widgetFrame.addView(view)
    view.updateAppWidgetSize(bundleOf(), width, height, width, height)
}

注意点として、createViewに渡すcontextはapplicationContextである必要があります
Activityなどのcontextを渡してはいけません。
内部でRemoteViewsのinflateが行われますが、AppCompatActivityのインスタンスなどを渡してしまうと、ImageViewの代わりにAppCompatImageViewが使われるなど、本来のRemoteViewsが期待するViewのインスタンスが作られなくなってしまうため正常に動作しない場合があります。

サイズの調整

通常ホームアプリはアイコンなどを設置するグリッドを持っており、そのグリッド何個分をウィジェットに割り当てるかを決めます。
割り当てるサイズについてはガイドラインがありますが、ホームアプリやデバイスによってまちまちです。

ここでは以下のような計算を行って、サイズを決定しています。これが正しいやり方というわけではありません。アプリごとの事情にあった計算を行いましょう。

private fun Int.toCellUnit(): Int {
    val density = resources.displayMetrics.density
    val cell = ((1..4).find { (70 * it - 30) * density >= this } ?: 4) + 1
    return ((70 * cell - 30) * density).roundToInt()
}

ウィジェットをアプリ上に貼り付ける方法について説明しました。
先に説明したとおり、これだけでリリースアプリに搭載できる訳ではありませんが、基本的な仕組みについては分かってもらえたでしょうか。
以上となります。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1