kako.dev

開発、自作アプリのこと

Wear OS で初めてのタイル作成 の Codelabをやってみた

前回 Compose for Wear OSのCodelabをやってみたら、WearOSが楽しくなったので、タイル作成のCodelabもやってみた。

タイルって?

WaerOSでアクションや情報を見る時に簡単にアクセスできる、画面を横スワイプすることで表示される。
モバイルでいうところのウィジェットみたいなやつかな(違うかも)…こういうやつ

Memo

セットアップ

waerOSのタイルはスマートフォンとの接続が必要なためまずセットアップする。

スマートフォンとwearOS AVDのペアリング

私の環境では wearOSは AVDなので下記ような手順。 尚、スマートフォンAVDとwearOSAVDのペアリングは現状サポートされてないの注意。

  1. スマートフォンでwearOSアプリをインストール
  2. PCとUSBで繋いでUSBデバッグON
  3. adb -d forward tcp:5601 tcp:5601 で通信ポートを接続済みのスマートフォンに転送。接続のたびに必要。
  4. wearOS アプリでペアリング設定

タイル追加

スマートフォンのwearOSアプリからタイルの追加が可能なので今回作るタイルを追加する

TileServiceの拡張

TileServiceクラスを拡張していく。 タイルの作成時に使用する次のメソッドがある。

  • onTileRequest()
    システムからタイルのリクエストがあった場合に、タイルを作成
  • onResourcesRequest()
    onTileRequest() で返されるタイルに必要な画像を提供

onTileRequest

  • onTileRequestの戻り値はFeatureであるため、CoroutineScopeのfutureでListenableFutureに変換
  • Tile.Builder() でTileを作成、Hello Worldを試す
  • リソースバージョンの設定
  • リソースが使用の有無に関わらず、onResourcesRequest で返されるバージョンと一致している必要がある
  • システムからonResourcesRequest を呼び出してグラフィックを取得するときに、グラフィックを適切なバージョンと一致させている

TimeLine

  • TimeLineは1つ以上のTimeLineEntryで構成されている
  • 各インスタンスが時間間隔ごとのレイアウトを記述
  • 複数の TimelineEntry 値を作成すると、システムはそれらの値を指定された時間間隔でレンダリングする

🚶‍♀️寄り道

TimeLineについてもう少し調べてみた

https://developer.android.com/training/wearables/tiles?hl=ja#timeline

  • Single Entry Tile
    レイアウトは固定されてコンテンツの情報だけ更新するような場合は、TimelineEntryを1つで足りる 例. その日のフィットネスの進捗状況を示すタイルは、常に同じ進捗状況のレイアウトを表示する、そのレイアウトを調整して異なる値を表示するようなケース この場合、コンテンツがいつ変更されるかは事前にわからない
override fun onTileRequest(
    requestParams: TileRequest
): ListenableFuture<Tile> {
    val tile = Tile.Builder()
        .setResourcesVersion(RESOURCES_VERSION)
        .setTimeline(Timeline.Builder()
            // We add a single timeline entry when our layout is fixed, and
            // we don't know in advance when its contents might change.
            .addTimelineEntry(TimelineEntry.Builder()
                // .setLayout(...).build()
            ).build()
        ).build()
    return Futures.immediateFuture(tile)
}
  • Timebound timeline entries
    TimelineEntry にあるオプションを利用して有効期間を定義することが可能 アプリが新しいタイルをプッシュすることなく、タイルが既知の時間にレイアウトを変更できる Tiles APIでは、有効期間を重複させることが可能。その場合、残り時間が最も短い画面が表示されます。一度に表示されるイベントは1つだけ

  • タイルを更新する
    タイルに表示される情報は、しばらくすると期限切れになる場合がある。 例.) 1日を通して同じ気温を示す天気タイルは正確ではない 期限切れのデータを処理するには、タイルの作成時にタイルの有効期間を設定する。 天気タイルの例では、次のコードサンプルに示すように、コンテンツを1時間に1回更新する場合がある

override fun onTileRequest(requestParams: RequestBuilders.TileRequest) =
    Futures.immediateFuture(Tile.Builder()
        .setResourcesVersion(RESOURCES_VERSION)
        .setFreshnessIntervalMillis(60 * 60 * 1000) // 60 minutes
        .setTimeline(Timeline.Builder()
            .addTimelineEntry(TimelineEntry.Builder()
                .setLayout(getWeatherLayout())
                .build()
            ).build()
        ).build()
    )
  • 間隔が終了するとすぐに onTileRequest()が呼び出さる。有効期間を設定しない場合、システムはonTileRequest()を呼び出されない。
  • タイルは、外部イベントが原因で期限切れになる場合もある 例. ユーザーがカレンダーから会議を削除し、タイルが更新されていない場合でも、タイルには削除された会議が表示さる この場合、次のコードサンプルに示すように、アプリケーションコードの任意の場所から更新を要求する
fun eventDeletedCallback() {
     TileService.getUpdater(context)
             .requestUpdate(MyTileService::class.java)
}

  • 「Hello World」を表示する場合の例
override fun onTileRequest(requestParams: TileRequest) = serviceScope.future {
        Tile.Builder()
            .setResourcesVersion(RESOURCES_VERSION)
            .setTimeline(
                Timeline.Builder().addTimelineEntry(
                    TimelineEntry.Builder()
                            .setLayout(
                                Layout.Builder().setRoot(
                                    Text.Builder().setText("Hello, world!").build()).build()
                            ).build()
                    )
                    .build()
            ).build()
    }

ActivtyでTileのプレビューを見る

  • activityを実行してプレビューを見る(へ〜アクティビティ実行でみれるのか!)
  • プレビュー用のActivtyなのでここでは debugフォルダに入っている
tileClient = TileClient(
    context = this,
    component = ComponentName(this, GoalsTileService::class.java),
    parentView = rootLayout
)
tileClient.connect()
  • TileClientでタイルをプレビューし、コンテキスト、コンポーネント(作業中のタイルのサービスクラス)、タイルを挿入する親ビューを設定
  • connect() を使用してタイルを作成
  • onDestroy() で tileClient.close() を使用してクリーンアップ
  • プレビュー用なため、パッケージのサイズを大きくしないためにも debug配下がおすすめ

ルート レイアウトをボックスにする

  • リポジトリからデータを取得
  • setLayoutでルートレイアウトを設定
override fun onTileRequest(requestParams: TileRequest) = serviceScope.future {

        // Retrieves progress value to populate the Tile.
        val goalProgress = GoalsRepository.getGoalProgress()
        // Retrieves device parameters to later retrieve font styles for any text in the Tile.
        val deviceParams: DeviceParameters? = requestParams.deviceParameters

        // Creates Tile.
        Tile.Builder()
            // If there are any graphics/images defined in the Tile's layout, the system will
            // retrieve them using onResourcesRequest() and match them with this version number.
            .setResourcesVersion(RESOURCES_VERSION)
            // Creates a timeline to hold one or more tile entries for a specific time periods.
            .setTimeline(
                Timeline.Builder().addTimelineEntry(
                    TimelineEntry.Builder().setLayout(
                        Layout.Builder().setRoot(
                            // Creates the root [Box] [LayoutElement]
                            layout(goalProgress, deviceParams)
                        ).build()
                    ).build()
                ).build()
            ).build()
    }

layoutの中身を実装

  • Box コンテナは、数多くあるタイル表示レイアウト コンテナの 1 つで、子要素を配置
  • コンテナの種類はこちら layout-containers
// Creates a simple [Box] container that lays out its children one over the other. In our
    // case, an [Arc] that shows progress on top of a [Column] that includes the current steps
    // [Text], the total steps [Text], a [Spacer], and a running icon [Image].
    private fun layout(goalProgress: GoalProgress, deviceParameters: DeviceParameters) =
        Box.Builder()
            // Sets width and height to expand and take up entire Tile space.
            .setWidth(expand())
            .setHeight(expand())

            // Adds an [Arc] via local function.
            .addContent(progressArc(goalProgress.percentage))

            // TODO: Add Column containing the rest of the data.
            // TODO: START REPLACE THIS LATER
            .addContent(
                Text.Builder()
                    .setText("REPLACE ME!")
                    .setFontStyle(FontStyles.display3(deviceParameters).build()).build()
            )
            // TODO: END REPLACE THIS LATER

            .build()

ArcLine

  • ユーザーの歩数が増えるにつれて、画面の端を囲むように弧を描く円を作る
  • Tiles API には、Arcコンテナがいつくか用意されている。Arcs
  • アンカータイプにはさまざまなタイプが用意されている (codelabにあったリンクが切れててみれなかった)
// Creates an [Arc] representing current progress towards steps goal.
    private fun progressArc(percentage: Float) = Arc.Builder()
        .addContent(
            ArcLine.Builder()
                // Uses degrees() helper to build an [AngularDimension] which represents progress.
                .setLength(degrees(percentage * ARC_TOTAL_DEGREES))
                .setColor(argb(ContextCompat.getColor(this, R.color.primary)))
                .setThickness(PROGRESS_BAR_THICKNESS)
                .build()
        )
        // Element will start at 12 o'clock or 0 degree position in the circle.
        .setAnchorAngle(degrees(0.0f))
        // Aligns the contents of this container relative to anchor angle above.
        // ARC_ANCHOR_START - Anchors at the start of the elements. This will cause elements
        // added to an arc to begin at the given anchor_angle, and sweep around to the right.
        .setAnchorType(ARC_ANCHOR_START)
        .build()

Columnコンテナを配置

  • 複数のテキスト フィールドや画像などを配置する時は、Columnコンテナを使う
// TODO: Add Column containing the rest of the data.
            // Adds a [Column] containing the two [Text] objects, a [Spacer], and a [Image].
            .addContent(
                Column.builder()
                    // Adds a [Text] using local function.
                    .addContent(
                        currentStepsText(goalProgress.current.toString(), deviceParameters)
                    )
                    // Adds a [Text] using local function.
                    .addContent(
                        totalStepsText(
                            resources.getString(R.string.goal, goalProgress.goal),
                            deviceParameters
                        )
                    )
                    // TODO: Add Spacer and Image representations of our step graphic.
                    // DO LATER
            )

画像の追加

  • Spaceを追加して要素の間に余白を追加
  • 画像をレンダリングする Image レイアウト要素のコンテンツを追加
  • Imageビルダーをつかって画像を設定
  • 修飾子はこちら modifiers
  • .setResourceId(ID_IMAGE_START_RUN)で画像名を定数にして渡している
.addContent(Spacer.Builder().setHeight(VERTICAL_SPACING_HEIGHT).build())
.addContent(startRunButton())

...

private val BUTTON_SIZE = dp(48f)
private val BUTTON_PADDING = dp(12f)
private const val ID_IMAGE_START_RUN = "image_start_run"


// Creates a running icon [Image] that's also a button to refresh the tile.
    private fun startRunButton() =
        Image.Builder()
            .setWidth(BUTTON_SIZE)
            .setHeight(BUTTON_SIZE)
            .setResourceId(ID_IMAGE_START_RUN)
            .setModifiers(
                Modifiers.Builder()
                    .setPadding(
                        Padding.Builder()
                            .setStart(BUTTON_PADDING)
                            .setEnd(BUTTON_PADDING)
                            .setTop(BUTTON_PADDING)
                            .setBottom(BUTTON_PADDING)
                            .build()
                    )
                    .setBackground(
                        Background.Builder()
                            .setCorner(Corner.Builder().setRadius(BUTTON_RADIUS).build())
                            .setColor(argb(ContextCompat.getColor(this, R.color.primaryDark)))
                            .build()
                    )
                // TODO: Add click (START)
                // DO LATER
                // TODO: Add click (END)
                    .build()
            )
            .build()

onResourcesRequest() に画像マッピングを追加

  • タイルはアプリのリソースにアクセスできない
  • つまり、Android 画像 ID を画像レイアウト要素に渡して解決することができない
  • 代わりに、onResourcesRequest() メソッドをオーバーライドして、リソースを手動で指定する必要がある
  • onResourcesRequest() メソッド内で画像を指定する方法は 2 つ

    • setAndroidResourceByResId()を使用して、ドローアブル リソースを指定
    • setInlineResource() を使用して、動的な画像を ByteArray として指定

    ここではsetAndroidResourceByResId()を使用する方法を用いる

  • addIdToImageMapping() 作成した Image レイアウト要素を実際の画像にマッピング

  • setResourceId(R.drawable.ic_run ) で特定のドローアブルを設定
override fun onResourcesRequest(requestParams: ResourcesRequest) = serviceScope.future {
        Resources.Builder()
            .setVersion(RESOURCES_VERSION)
            // No Resources quite yet!
            .addIdToImageMapping(
                ID_IMAGE_START_RUN,
                ImageResource.Builder()
                    .setAndroidResourceByResId(
                        AndroidImageResourceByResId.Builder()
                            .setResourceId(R.drawable.ic_run)
                            .build()
                    )
                    .build()
            )
            .build()
    }

クリックリスナーを設定

  • clickable でクリックリスナーを設定
  • クリック イベントへの反応として、次の 2 つのアクションを実行
    • LaunchAction アクティビティを起動します。
    • LoadAction: onTileRequest() を呼び出してタイルを強制的に更新
  • LoadAction を使用して、シンプルな行 ActionBuilders.LoadAction.builder() でタイル自体を更新
  • onTileRequest() の呼び出しがトリガーされますが、設定した ID ID_CLICK_START_RUN が渡される
  • onTileRequest() に最後に渡されたクリック可能な ID をチェックして、その ID で別のタイルをレンダリング可能
.setClickable(
                        Clickable.builder()
                            .setId(ID_CLICK_START_RUN)
                            .setOnClick(ActionBuilders.LoadAction.builder())
                    )
  • 渡されるIDにより別のタイルをレンダリングする例
override fun onTileRequest(requestParams: TileRequest) = serviceScope.future {

    if (requestParams.state.lastClickableId == ID_CLICK_START_RUN) {
        // Create start run tile...
    } else {
        // Create default tile...
    }
}

Manifestファイルの確認

  • タイル プロバイダをバインドする権限
  • サービスをタイル プロバイダとして登録するインテント フィルタ
  • スマートフォンに表示するプレビュー画像を指定する追加のメタデータ
<service
            android:name="com.example.wear.tiles.GoalsTileService"
            android:label="@string/fitness_tile_label"
            android:description="@string/tile_description"
            android:icon="@drawable/ic_run"
            android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
            <intent-filter>
                <action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
            </intent-filter>

            <!-- The tile preview shown when configuring tiles on your phone -->
            <meta-data
                android:name="androidx.wear.tiles.PREVIEW"
                android:resource="@drawable/tile_goals" />
        </service>

完走した完走

  • wearOSといえばタイルを作りたくなるかもしれないのでやってよかった
  • スマートフォンとのセットアップが必要なところが知らないと詰まりそう
  • サービスでの実装がメインなのでActivtyベースでの実装ばかりやってた自分は慣れが必要
  • WearOS for Jetpack Compose は活用可能なのどうか気になる