2023年Kyashにおけるリッチなウィジェットの開発記録

はじめに

この記事は Kyash Advent Calendar 2023 の15日目の記事です。 昨日は @tamadonカードゲームは良いぞ です。

こんにちは!KyashでAndroidを開発している @nitakan です。

皆さんはウィジェットを活用されていますでしょうか? アプリを開かなくてもサッと情報を確認できる便利な機能ですね。

少し前のことですが、Jetpack Composeを利用してウィジェットが記述できるJetpack GlanceがStableになりました 🎉

開発体験が大幅に改善されているので、この機会にぜひウィジェット作成を試してみてください!

さて、Kyashでは気軽に残高や利用履歴を確認することができるように、ウィジェット機能をリリースしています。 より便利なウィジェットを作りたいと思い、社内で上がっていた「利用額ウィジェット」を開発し、7月にリリースしました。

Kyashアプリのウィジェット機能が月間の利用額の表示に対応しました

この記事では、Kyashのウィジェットの歴史と2022に公開されたJetpack Glanceを使ってグラフを含むリッチなウィジェットをどのように実現したのかを紹介していこうと思います。

Kyashのウィジェット開発事情

Kyash Androidアプリでは、ウィジェット機能に対応しています。 その時の発表スライドはこちらです。

現在あるウィジェットは以下の3つです。

・Kyash 残高表示

・Kyash 残高・履歴表示

・Kyash 利用額・平均額表示

Kyashの最初のウィジェットである「残高表示」ウィジェットは、RemoteViewsを使用して開発されました。

Jetpack Glanceはリリースされていたものの、アルファ版の段階であり、プロダクトへの導入には適していないと判断されたために採用を見送りました。

1年が経過し、Jetpack Glanceはベータ版に進化しました。 このバージョンアップに伴い、Jetpack Glanceはウィジェットの開発に適した完成度と機能を提供するようになり、それによって新たなウィジェットの開発が可能になりました。 実現したい機能を十分に表現できることを確認したので、実際にプロジェクトに導入することにしました。

iOSエンジニアであるemoto-sanが「Kyash 残高・履歴表示」ウィジェットのプロジェクトを開始し、Android版のウィジェット開発はJetpack Glanceで作成する機会を得ました。

このウィジェットは2023年の4月にリリースしています。

その後2023年7月に「Kyash 利用額・平均額表示」ウィジェットをリリースしました。

「利用額表示」機能の追加について、プロダクトマネージャー・デザイナーと相談し、その月の利用額を表示することに決定しました。

さらに、利用額をどう表現するのが良いのかを協議し、最終的にグラフで月間利用額の推移を見せられると良さそう、ということでウィジェットにグラフを表示することにチャレンジすることになりました。

以下はデザイナーが提案した初期案です。 サイズや表現方法がいろいろありますが、ユーザーが見たいデータなのか、技術的な観点では既存のAPIで実現可能かどうかという点で検討しました。

月内の特定日までの積算利用額を取得するAPIがなかったので、前月同日比のグラフは見送りになりました。

視覚的に過去と当月を比べることができると、家計管理がしやすくなるのではないかという仮説と既存APIで実現可能であることから、棒グラフで各月の利用額を表示するような表現にしました。見やすいラベルやグラフの大きさを考慮すると横長のものが良さそうということで中央左の案を採用しました。

グラフを含むウィジェットをどうやって実現しているのか

デザインと実現方法のいろいろ

以下は最終案です。 月内の特定日までの積算利用額を取得するAPIがなかったので、前月同日比のグラフは見送りになりました。

視覚的に過去と当月を比べることができると、家計管理がしやすくなるのではないかという仮説と既存APIで実現可能であることから、棒グラフで各月の利用額を表示するような表現にしました。見やすいラベルやグラフの大きさを考慮すると横長のものが良さそうということで中央左の案を採用しました。

グラフを含むウィジェットをどうやって実現しているのか

デザインと実現方法のいろいろ

以下は最終案です。

Kyashではデザイナーの提案するデザインをできるだけ再現することを大事にしていますので、このデザイン案を実現するためにどうするかを技術検証するところから始めました。

特にグラフをどうやって描くかというところと、グラフとラベルが同じ領域に存在するので、位置の同期をどうやって取ろうかなというところに悩んでいました。

ウィジェットの左半分は以前作ったウィジェットの流用だったので、右半分のグラフ部分が今回のチャレンジとなります。

Jetpack GlanceはコードこそJetpack Composeのように記述できますが、実態はRemoteViewsです。 そのため、Jetpack Composeのような柔軟な表現はできず、様々な制限があります。

このあたりの詳しい記事はいろいろな方が書いてくれていますので、そちらを参考にしてください。

Jetpack ComposeだとCanvasで直接描画できますが、RemoteViewsではそのようなことはできないので、代替策を検討する必要がありました。

Box(View)を使う方法なども検討しました(*1)が、最終的にはImageViewにグラフや文字列を描画したBitmapを生成して表示する方法で実現することにしました。 この実現方法は2010年代のStack Overflow投稿を何個か見つけたので昔からあるようです。

*1) グラフ部分と文字列の位置関係の調整の複雑さや、Viewの角丸が一定未満のAndroidバージョンで無効になるなど実現できないことが多いので却下

Bitmapの生成とグラフの描画

グラフをBitmapとして表示することにしました。 まずはBitmapのサイズを確定させましょう。

通常のアプリ上で動作するJetpack Composeとは異なり、Jetpack GlanceのComposeには BoxWithConstraints に相当するものは無いですし、Bitmapを生成するには表示の前段階でサイズを取得している必要があります。

また、LocalSize.currentを使う方法もありますが、この方法では正しい値が取得できない場合があるため採用していません。この辺は後述します。

今回は以下のようにしてサイズを決定し、グラフを描画しています。

1. WidgetManagerからWidgetIDで配置サイズをdpで取得する

class WidgetSizeProvider(
   private val context: Context,
){

   private val appWidgetManager = AppWidgetManager.getInstance(context)

   fun getWidgetsSize(widgetId: Int): SizeF {
       val isPortrait = context.resources.configuration.orientation == ORIENTATION_PORTRAIT
       val width = getWidgetWidth(isPortrait, widgetId)
       val height = getWidgetHeight(isPortrait, widgetId)
       val widthInPx = context.d2p(width)
       val heightInPx = context.d2p(height)
       return SizeF(widthInPx, heightInPx)
   }


   private fun getWidgetWidth(isPortrait: Boolean, widgetId: Int): Int =
       if (isPortrait) {
           getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
       } else {
           getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH)
       }


   private fun getWidgetHeight(isPortrait: Boolean, widgetId: Int): Int =
       if (isPortrait) {
           getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
       } else {
           getWidgetSizeInDp(widgetId, AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)
       }


   private fun getWidgetSizeInDp(widgetId: Int, key: String): Int =
       appWidgetManager.getAppWidgetOptions(widgetId).getInt(key, 0)


   private fun Context.d2p(value: Int): Float = (value * resources.displayMetrics.density)
}

参考

Contextが必要なので、Hiltを利用してApplicationを取得しています。 AppWidgetManagerから OPTION_APPWIDGET_MIN_WIDTH 等を利用してサイズを取得します。

ここでの注意点として、取得できる値は以下のように画面の向きにかかわらず同じとなります。そのため、現在の画面の向きによって取得する値を動的に選択する必要があります。

Portrait: minWidth=333, maxWidth=666, minHeight=114, maxHeight=198 Landscape:minWidth=333, maxWidth=666, minHeight=114, maxHeight=198 ※値はPixel 5の場合です

これらはSDK 15以降でPortraitの場合は minWidth/maxHeight、 Landscapeの場合は maxWidth/minHeightに縦横のサイズが格納されているようです(公式のドキュメントはありませんでした…) https://stackoverflow.com/a/18723268

2. WidgetのComposable関数でグラフエリアのサイズを計算する

1.で取得した配置されたウィジェットの高さと幅の値からウィジェット左側のエリアの幅とウィジェット自体のデフォルトで設定されている8dpのPaddingを考慮してグラフ部分のサイズを決定します。

const summaryWidthDp = 120.dp
val summaryWidthPx = TypedValue.applyDimension(
    TypedValue.COMPLEX_UNIT_DIP,
    40.dp.value,
    displayMetrics,
)

val paddingWidthPx = TypedValue.applyDimension(
    TypedValue.COMPLEX_UNIT_DIP,
    16.dp.value,
    displayMetrics,
)

val graphWidth = widgetWidthPx - summaryWidthPx - paddingWidthPx
val graphHeight = … // <同上>

3. Bitmapを生成する

上記で計算したサイズのBitmapを生成し、Canvasに設定します。

val bitmap = Bitmap.createBitmap(graphWidth.toInt(), graphHeight.toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)

4. Canvasにグラフを描き、Bitmapとして出力する

ここは一般的なCanvasへの描画となるので実装は省きます。 Canvasでは数学やCADなどで使われる数学座標系と異なり、X軸で反転していて左上が原点となる(復習)ので地道に座標計算をしてグラフをBitmapに描いていきます。

Padding領域(薄赤)、グラフエリア(薄緑)、ラベル領域(薄紫)の上に、5つの要素を描いていきます。

・グラフ背景 Padding領域内を白背景として塗りつぶし。

・平均額の点線 平均利用額が存在する場合、過去の最大利用額を100%とする割合をグラフエリアの高さにかけてx軸を算出し、点線を描画。

・棒グラフの基準線 グラフエリアの0位置に線を描画。

・棒グラフ 棒グラフの横幅とグラフ間の隙間は同じとしたので、グラフエリア幅を9(5ヶ月*2-1)で割って算出します。 上部のみRoundしている表現は、canvas.drawRoundRect したあとに下部のみ ​​canvas.drawRect で上書きして実現しています。 高さは平均額と同様の計算で算出。

・月表示ラベル 文字列の幅の半分を paint.measureText(text) / 2 で取得し、棒グラフ中心x座標からマイナス方向にオフセットして描画。

特に保存等は無くそのままBitmapに描画されていますので、3で生成したBitmapをComposeに渡します。

5. 生成したBitmapを表示する

Image Composableに直接Bitmapを渡すことができるので、これでウィジェットに反映されます。

       Image(
            provider = ImageProvider(bitmap),
            contentScale = ContentScale.FillBounds,
            contentDescription = null,
            modifier = GlanceModifier.defaultWeight(),
       )

これでウィジェット上にグラフを配置することができました。

LocalSize.currentの挙動

SizeModeのドキュメントにある通り、Widgetのサイズは LocalSize.current によって、Glance Composable上でアクセスすることができます。 ただし、Android 12(SDK31)未満の場合は必ずしも正しいサイズが取得できるとは限りません。

実際に複数のAndroidバージョンで試してみました。

今回実験に使ったWidgetのMetadata

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/widget_usage_history_description"
    android:targetCellHeight="2"
    android:targetCellWidth="5"
    android:minWidth="349dp"
    android:minHeight="110dp"
    android:minResizeWidth="130dp"
    android:minResizeHeight="110dp"
    android:previewImage="@drawable/widget_usage_history_preview"
    android:resizeMode="horizontal"
    android:widgetCategory="home_screen"
    android:updatePeriodMillis="0" />

上記はSDK31用ですが、デフォルトも同様です。

また、SizeModeによっても取得できる値が異なります。 以下はエミュレータを利用して各パターンを取得したものです。

スクリーンサイズ:density=2.75, width=1080px, height=2028px
Android SDK:25, 28, 31
SizeMode:Single, Exact, Responsive(110dp x 80dp, 220dp x 80dp, 330dp x 80dp)

MAX_WIDTHなどは OPTION_APPWIDGET_MAX_WIDTH を省略したものです。

Android SDK SizeMode MAX_WIDTH MIN_WIDTH width(px) dp LocalSize.current.width dp
25 Single 1540 990 990.00 360.00 358 130
Exact 990.00 360.00 1540 560
Responsive 990.00 360.00 907 330
28 Single 1716 959.75 959.75 349.00 358 130
Exact 959.75 349.00 1716 624
Responsive 959.75 349.00 908 330
31 Single 959.75 1661 959.75 349.00 358 130
Exact 959.75 349.00 961 349
Responsive 959.75 349.00 908 330
302->605->907 109.82 -> 220 -> 329.82
Android SDK SizeMode MAX_HEIGHT MIN_HEIGHT height LocalSize.current.height
25 Single 599.5 343.75 599.50 218.00 303 110
Exact 599.50 218.00 343 125
Responsive 599.50 218.00 220 80
28 Single 580.25 343.25 580.25 211.00 303 110
Exact 580.25 211.00 344 125
Responsive 580.25 211.00 220 80
31 Single 541.75 343.74 541.75 197.00 303 110
Exact 541.75 197.00 544 198
Responsive 541.75 197.00 220 80

SDK 31では、SizeModeにResponsiveを指定すると、その境界(110, 220dp, 330dp)毎にComposeされドキュメント通りの挙動であることを確認しました。しかし、SDK28/25では、330dpのみでComposeされました。 ドキュメントの注釈にあるように、SDK 31未満では動作が保証されていないため、それらも対象とする場合は注意が必要です。

その他のSizeModeでもSDKバージョン毎に挙動が異なっています。 Singleを指定する場合、どのSDKバージョンでもLocalSizeには固定の値が設定されています。 Metadataの minResizeWidth の値が設定されているようです。

Exactを指定すると、SDK 31では実際の配置サイズがLocalSizeに設定されています。しかし、SDK28/25ではどこから来たのかわからないサイズが設定されているのでGlance Composable内でのサイズ比較には利用できないと思います。

終わりに

Kyashのウィジェット事情とグラフをウィジェットに表示する試行錯誤について紹介しました。

この記事を執筆している間に、より良い表現方法があることに気づいたり、ドキュメントを読み返すことで新たな発見があったりと、大変勉強になりました。

Kyashではウィジェットを始めプロジェクト以外でも様々な技術を使ってより便利なアプリを目指しています。

参考資料

https://developer.android.com/develop/ui/views/appwidgets/layouts https://developer.android.com/jetpack/compose/glance/build-ui https://developer.android.com/jetpack/androidx/releases/glance?hl=ja https://developer.android.com/reference/android/widget/RemoteViews https://stackoverflow.com/questions/16801721/calculate-height-of-appwidget/18723268#18723268 https://stackoverflow.com/questions/25153604/get-the-size-of-my-homescreen-widget/58501760#58501760