本記事は、CyberAgent Advent Calendar 2022 22日目の記事です。

はじめに

AWAでAndroidエンジニアをしている向井です。

この記事は

  • Android開発に興味がある人
  •  Jetpack Composeに興味がある人
  • Wear OS向けアプリの開発に興味がある人

を対象にしています。

AWAではさまざまなシーンで音楽を楽しめるように多様なデバイスに対してアプリを提供しています。
そのひとつがWear OS搭載のスマートウォッチです。
AWAでは以前からWear OSに対してアプリをリリースしていましたが、2022年11月にリニューアルをしてスタンドアローン再生などの機能拡充を行いました。
https://news.awa.fm/jpn/2022/11/29/wearosbygoogle

その際に画面も全体的に作り直すこと、AWAでのCompose導入を考え始めていたこと、Compose for Wear OS用のライブラリが1.0.0になったことが重なりいい機会なのでComposeを使って開発することにしました。

Compose for Wear OSを使ってアプリを開発した事例を国内ではまだあまり聞かないので、AWA開発の際に使ったCompose for Wear OS特有の機能をいくつか紹介したいと思います。
なお、Compose for Wear OSはandroidx.wear.compose〜に含まれており、androidx.compose〜のCompose系のライブラリについてはCompose for Wear OSと区別するために通常のComposeという言い方をします。

Compose for Wear OSといっても基本は通常のComposeと同じです。
ColumnやTextといった基本的なComposable関数は使えますしModifierで装飾を指定するところも同じです。
なのでWear OS用といっても求められる基本的な知識はほぼ同じなので特別身構える必要はありません。
それでは順に紹介していきましょう。

Compose for Wear OS特有の機能

Scaffold

画面のレイアウトの土台をさっと作ってくれる便利機能としてScaffoldがあります。
通常のComposeでもScaffoldはありますが、Compose for Wear OSではWear OS用のScaffoldが用意されています。
実装はとても短いので全て載せてしまいます。

@Composable
public fun Scaffold(
    modifier: Modifier = Modifier,
    vignette: @Composable (() -> Unit)? = null,
    positionIndicator: @Composable (() -> Unit)? = null,
    pageIndicator: @Composable (() -> Unit)? = null,
    timeText: @Composable (() -> Unit)? = null,
    content: @Composable () -> Unit
) {
    Box(modifier = modifier) {
        content()
        vignette?.invoke()
        positionIndicator?.invoke()
        pageIndicator?.invoke()
        timeText?.invoke()
    }
}

このように単純にBoxで重ねる実装になっています。
各Composable関数の引数にはそれぞれ渡されることが想定されたコンポーネントが存在するので自前で用意するのでない限りそれらを使うとよいでしょう。

例えばtimeTextパラメータにはTimeText Composableが用意されています。
これは画面上部に時刻を表示させるためのコンポーネントです。
内部的に円形のデバイスかどうかを判定していて、円形であれば時刻の文字列をカーブさせて円形に沿う形で表示して、そうでなければ直線で文字列を表示してくれます。
下の画像はプレビューで円形、四角形それぞれの表示をしたものです。
デバイスの形状によって表示が変化していることがわかります。

円形のデバイスでは弧を描くように文字が表示される
円形のデバイスで表示したとき
四角形のデバイスでは直線に文字が表示される
四角形のデバイスで表示したとき

ScrollAway

TimeTextとコンテンツが重なってしまう
TimeTextとコンテンツが重なる様子

TimeTextはWear OS版Scaffoldを使うと引数で指定することができますが単純にそれだけでは時刻とコンテンツが重なって表示されてしまいます。

しかし、TimeTextは他のコンテンツと重ねずに表示することが推奨されています

これを実現するための機能がHorologistというライブラリで提供されています。
Composeを補完するためのライブラリ群であるAccompanistというリポジトリがありますが、そのWear OS版がHorologistです。
HorologistはメディアコントロールやProgressIndicatorなどの機能をWear OSに特化した形で提供しています。
今回はTimeTextの重なり問題を解決するためにHorologistで提供されているScrollAwayを使うことにしました。
これをModifierで指定することによってScalingLazyListStateの状態に従って対象のComposableの位置を移動させることができます。

コンテンツのスクロールに合わせてTimeTextを移動している
スクロールに合わせてTimeTextを移動している様子
import com.google.android.horologist.compose.layout.scrollAway

Scaffold(
    timeText = {
        TitleTimeText(
            modifier = Modifier.scrollAway(
                scrollState = scalingLazyListState,
            ),
            text = "title",
        )
    },
) {
    ...
}

ただこの記事を執筆中に確認したところscrollAwayはHorologistを卒業してCompose for Wear OSの1.1.0に入ったため、今後はこちらを使うほうが良いでしょう。

ScalingLazyColumn

よく使われるComposableとしてLazyColumnがありますが、Wear OS用の同様の機能をもつScalingLazyColumnがあります。
これはWear OS用のmaterialライブラリの中に含まれています。
ScalingLazyColumnを使うと魚眼レンズのような表現でコンテンツのスクロールアニメーションを見せることができます。
使い心地はLazyColumnと変わらずにWear OS用の表現ができるため非常に簡単です。
ただ1点だけ気になる点があります。
Wear OS搭載のスマートウォッチは円形のものもあれば四角形のものも存在します。
エミュレーターでも円形と四角形が提供されています。
魚眼レンズのアニメーションは円形スマートウォッチのときには画面の形に沿うようにスケーリングされるので自然に見えるのですが、四角形であっても同じく魚眼レンズのアニメーションをします。

円形のデバイスでスクロールしたときは円形の形状にそってスクロールされるように見える
円形のデバイスでスクロールしたとき
四角形のデバイスでスクロールしたときにも魚眼レンズのアニメーションをしている
四角形のデバイスでスクロールしたとき

円形の場合は魚眼レンズアニメーション、四角形の場合はただのスクロールアニメーションで動かすにはどうすればよいでしょうか?

案1:ScalingLazyColumnとLazyColumnを出し分ける

魚眼レンズアニメーションが起こるのはScalingLazyColumn特有なので、画面が円形かどうかを判定してLazyColumnと出し分けをするComposableを作れば解決するかもしれません。
ここでScalingLazyColumnとLazyColumnの定義を見てみましょう。

@Composable
public fun ScalingLazyColumn(
    modifier: Modifier = Modifier,
    state: ScalingLazyListState = rememberScalingLazyListState(),
    ...
    content: ScalingLazyListScope.() -> Unit
) {
    ...
}
@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    ...
    content: LazyListScope.() -> Unit
) {
    ...
}

これらはかなり似ているのですが、stateの型やcontentのScopeの型が異なっています。
仮にこれらのComposableを出し分けようとするとそれぞれのComposable用にstate管理やitemの追加を書かなければならずコードが重複することになってしまいます。
できなくはないかもしれませんがコードが煩雑になってしまいます。

案2:ScalingParamsをカスタムする

もう一つの案ではScalingLazyColumnのみを使います。
ScalingLazyColumnにはScalingParamsという引数を渡すことができます。
これを変更することで魚眼レンズアニメーションの細かい挙動をカスタムすることができます。
今回は四角形であれば魚眼レンズアニメーションが起こらないカスタムをしました。
スマートウォッチの形の違いを吸収するComposableを作ることで、形の違いを気にせずに一覧系のレイアウトを作ることができます。

@Composable
fun FormCompatScalingLazyColumn(
    ...
) {
    val isRound = context.resources.configuration.isScreenRound
    val scalingParams = remember(isRound) {
        if (isRound) {
            ScalingLazyColumnDefaults.scalingParams()
        } else {
            object : ScalingParams {
                override val edgeAlpha: Float = 1f
                override val edgeScale: Float = 0f
                override val maxElementHeight: Float = 0f
                override val maxTransitionArea: Float = 0f
                override val minElementHeight: Float = 0f
                override val minTransitionArea: Float = 0f
                override val scaleInterpolator: Easing = LinearEasing
                override fun resolveViewportVerticalOffset(viewportConstraints: Constraints): Int {
                    return (viewportConstraints.maxHeight / 20f).toInt() // ScalingLazyColumnDefaults.scalingParams()を踏襲
                }
            }
        }
    }
    val autoCentering = remember(isRound) {
        if (isRound) {
            roundAutoCenteringParams ?: AutoCenteringParams(itemIndex = 0)
        } else {
            null // ScalingLazyColumnの上下にスペースを挿入させないため
        }
    }

    ScalingLazyColumn(
        modifier = modifier,
        verticalArrangement = verticalArrangement,
        horizontalAlignment = horizontalAlignment,
        scalingParams = scalingParams,
        autoCentering = autoCentering,
        contentPadding = contentPadding,
        state = state,
        content = scalingLacyColumnContent,
    )
}

ScalingLazyColumnを使いつつ魚眼レンズアニメーションを抑制した様子。

魚眼レンズのアニメーションがなくなり直線にスクロールしている
アニメーションを調整した結果

SwipeDismissableNavHost

NavHostもWear OS用のものが用意されています。
それがSwipeDismissableNavHostです。
NavHostの代わりに使うことで画面をスワイプすると前の画面に戻るNavigationを簡単に実現することができます。
単純にNavHostからSwipeDismissableNavHostに置き換えるだけで動かせます。

画面を右にスワイプするとバックする
スワイプでバック操作をしている様子

TextField

ここでは逆にCompose for Wear OSでは使えない機能の話になります。
androidx.compose.material:materialライブラリにTextFieldというComposableがありますが、Wear OSでは専用のmaterialライブラリが用意されています。
https://android-developers-jp.googleblog.com/2021/11/compose-for-wear-os-now-in-developer.html
Compose for Wear OSアプリ開発では上記URLに書かれているように専用のライブラリを使うことが推奨されています。
さて、推奨はされているのですがWear OS用のライブラリにはTextFieldの実装は含まれていません。
Wear OSでも使えるandroidx.compose.foundation:foundationにBasicTextFieldというComposableは含まれているのですが、装飾がされていないシンプルな機能のみを持っています。
これに自分で一から装飾を施すのはなかなか骨が折れます。
さらにこのComposableはWear OSのエミュレーターで動かしたときに不安定な挙動をします。
入力値が反映されなかったり確定ボタンが効かなかったりするのでWear OSでTextField系のComposableを使うのは現状難しいと感じています。
とはいえComposeで実装しているUIでテキストを入力したいケースもあるでしょう。
その場合はAndroidView Composableを使ってEditTextをラップする形で利用しましょう。

おわりに

この記事ではCompose for Wear OS特有の機能についていくつか紹介しました。
基本的には通常のComposeと書き方は同じでとても完結に実装することができます。
ですが、スマートウォッチ特有の画面の形状や小ささに由来する表現に対応するためにはスマートウォッチ用の機能を使う必要があります。
アプリケーションの種類によってはまた違った機能が必要になる場面もあると思いますのでWear OSの開発をする機会がありましたら是非それらの知見も共有いただけると嬉しいです。