Compose for Wear OS の予備調査: 単純な選択 APP の実装

序文

ことわざにあるように、人生には 3 つの大きな問題があります。それは、朝何を食べるか、昼に何を食べるか、そして夜に何を食べるかです。
この問題はかつては数え切れないほどの人々を悩ませていましたが、何を食べるかを選択するのに役立つアーティファクト「今日何を食べるか」が登場するまで、人々は毎日何を食べるかを心配する必要がなくなりました。

あはは、上記はまったくの気の利いた話です。

最近 Google Developers の公式 Web サイトにアクセスしたところ、ホームページのバナーが Wear OS のトピックに変更されており、そのトピックの 1 つが Compose for Wear OS でした。私はたまたま最近 Compose を学習していたので、ぜひ知りたいと思っていました。それを試してみてください。しかし、私の学習スタイルは、実践的なプロジェクトを学習のキャリアとして使用して、実践して学ぶことです。では、今回はどうすればよいでしょうか?

よく考えたら、フードセレクターが作れます。こういうのは難しくないし、実用的で楽しいです。一番重要なのは、この種のアプリはモバイルにすると少し肥大化してしまうことですアプリであり、小さなプログラムや Web ページに適しています。しかし、アプリを時計にインストールすると、その感覚は異なります。

とにかくやってみましょう。学習を始めましょう。

いつものように、開始する前にプレビューを確認してください。

s6.gif

エフェクトを無効にします (最初の 2 つだけが有効になり、残りは無効になります)。

s7.gif

学習を開始する

Wear OSの導入と開発の原則

Wear OS も Android システムに基づいていますが、特に時計やウェアラブル デバイス向けに最適化されています。

Wear OS は Android をベースとしているため、オリジナルのモバイル アプリケーションのコードを Wear OS 上で直接再利用することもできますが、Wear OS は負荷の高いタスクには適していないため、使用しないでください。これは、Wear OS 開発の原則の 1 つです。つまり、ミッションクリティカルなタスクのみを設計します

Wear OSを搭載した端末はいずれもウェアラブル端末であるため、長時間快適に操作できない場合があります。したがって、アプリケーションを開発する際にはこの機能を十分に考慮し、アプリケーションの操作を可能な限り簡素化し、ユーザーがわずか数秒で操作を完了できるようにする必要があります。これは手首への装着に最適化されています

オフライン シナリオのサポート関連コンテンツの提供など、他の開発原則もあるため、ここでは詳しく説明しません。自分でドキュメントを確認できます: Wear OS 開発の原則

Wear OS 用に作成

Wear OS の Compose は標準の Compose とほぼ同じであり、同じ API と使用法を共有します。

Wear OS には、次のScalingLazyColumnようなより具体的なコンポーネントがいくつかあるというだけです。Chip

また、これらはほぼ同じ API を持っていますが、実際には異なる依存関係とパッケージ名が使用されています。次に例を示します。

Weao OS に依存するもの 標準の依存関係
androidx.wear.compose:構成マテリアル androidx.compose.material:マテリアル
androidx.wear.compose:compose-navigation androidx.navigation:navigation-compose
androidx.wear.compose:compose-foundation androidx.compose.foundation:基礎

もちろん、これは依存関係を手動で変更する必要があるという意味ではありません。Android Studio 作成プロジェクト テンプレートには既に Wear OS テンプレートが含まれているため、作成時にこのテンプレートを選択するだけで済みます。

s1.png

デザインページ

全体的な配置

何を食べるかを選ぶアプリを作るのが目標ですが、Wear OSの画面は携帯電話とは異なり、一般的に画面が小さく、収まるコンポーネントも少ないです。

また、画面は長方形の画面ではなく、円形の画面である場合もあります。つまり、コンポーネント UI が画面範囲からはみ出す状況を適切に処理する必要があります。

幸いなことに、Compose は既成のレイアウト構造フレームワークを提供してくれました。Scaffoldこのフレームワークは利用可能な「スロット」を多数提供しており、対応するものをそれらに「差し込む」だけで済みます。

実は、これはScaffold標準の Compose でも提供されていますが、標準の Compose では、提供されているスロットを使用して、上部のタイトル バー ( topBar)、下部のナビゲーション バー ( bottomBar)、フローティング ボタン ( floatingActionButton)、ドロワー ナビゲーション ( drawerContent) などを配置します。

一方、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
)

vignette画面にぼかし効果を追加することを示します。たとえば、画面の下部と上部にぼかし効果を追加して、中央に表示されるコンテンツを強調します。

c1.png

positionIndicatorこの垂直スクロール リストに追加された位置表示など、画面の端 (通常は右側) に位置表示 UI を追加することを示します。

c2.png

pageIndicatorページ表示 UI を追加することを示します。Wear OS では通常、左右にスワイプして異なるページを切り替えるため、このスロットを使用して現在のページ位置を追加できます。

c3.png

timeTextこれは、設計原則として、長時間使用する必要があるインターフェイスに時刻表示を追加するのが最善であるため、インターフェイスの上部に時刻表示 UI を追加することを意味します。時計が時間さえ読めないとしても、それは何の機能があるのでしょうか?

c4.png

使用後のレイアウト構造を決定するとScaffold、おそらくアプリの全体的な UI レイアウトがどのように見えるべきかがわかります。

大きく分けて次の 2 ページに分かれています。

最初のページには、スクロール可能なレイアウトを使用したメイン UI (スタート ボタンと料理名のテキスト) が表示され、下にスクロールすると、料理名リストで特定の料理を無効にするオプションが表示されます。

2 ページ目も引き続きスクロール可能なレイアウトを使用して設定オプションを表示します。主に料理名リストの追加、削除、変更、内容の確認、使用する料理名リストの選択に使用されます。この機能は に接続する必要があるためです。携帯電話でデータを同期するのと、時計がまだ発送されていないので、当分このページは作らず、時計が到着したら書きます。

左右にスワイプすることで 2 つのページを切り替えることができます。

ホームページを実現する

まず基本的なフレームワークを作成します。

@Composable
fun WearApp() {
    
    
    WearOScomposetestTheme {
    
    
        val listState = rememberScalingLazyListState()

        Scaffold(
            timeText = {
    
    
                if (!listState.isScrollInProgress) {
    
    
                    TimeText()
                }
            },
            vignette = {
    
    
                Vignette(vignettePosition = VignettePosition.TopAndBottom)
            },
            positionIndicator = {
    
    
                PositionIndicator(
                    scalingLazyListState = listState
                )
            }
        ) {
    
    
            ScalingLazyColumn(
                modifier = Modifier.fillMaxSize(),
                state = listState,
                autoCentering = AutoCenteringParams(itemIndex = 0)
            ) {
    
    
                // 内容列表
                // ……
            }
        }
        
    }
}

上記のコードはTimeText()現在のリアルタイム時間を表示するために使用していますが、スクロール中の場合は表示しないという判定も追加しています。

Vignette(vignettePosition = VignettePosition.TopAndBottom)画面の上端と下端をぼかすために使用します。

PositionIndicator(scalingLazyListState = listState)現在の項目の位置を示すために使用しますScalingLazyColumn

メインページはScalingLazyColumn親レイアウトとして使用されます。

ScalingLazyColumn標準の Compose と同様ですLazyColumnただし、違いがあります。つまり、項目は現在の画面に合わせて自動的に拡大縮小されます。

上で述べたように、Wear OS を搭載したデバイスの画面の多くは円形です。つまり、高さの異なるコンポーネントは異なる幅を表示でき、ズームやフェードイン、フェードアウトすることで、異なる幅の表示を自動的に処理できるようになりますScalingLazyColumn

c5.gif

ここで読者の方が質問があるか分かりませんが、円形の画面の幅は一定ではなく、画面の中心から離れるにつれて幅が小さくなっているので、最初のいくつかの項目(最初のもの) はスクロール レイアウトでは決して表示されません。最大幅の表示を達成するために中央に移動することはできませんか?

そうです、この問題は存在するので、この問題を解決するためのScalingLazyColumnパラメータを提供しますautoCentering

たとえば、上記のコードでは、このパラメータを次のように設定しています。AutoCenteringParams(itemIndex = 0)これは、最初の項目にパディングとオフセットが自動的に追加され、最初の項目も中央にプルダウンできることを意味します。

s2.png

このスクリーンショットでは、中央のボタンが実際には最初の項目ですが、設定したAutoCenteringParams(itemIndex = 0)ため中央まで引き下げることができます。引き下げることができない場合は、次のようになります。

s3.png

次に、スタート ボタンから始めて、基本フレームにコンテンツを入力します。

@Composable
fun StartButton(
    icon: ImageVector,
    onClick: () -> Unit
) {
    
    
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center
    ) {
    
    
        Button(
            modifier = Modifier.size(ButtonDefaults.LargeButtonSize),
            onClick = onClick
        ) {
    
    
            Icon(
                imageVector = icon,
                contentDescription = icon.name
            )
        }
    }
}

そして、次の料理名が続きます。

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun FoodText(text: String) {
    
    
    Text(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        textAlign = TextAlign.Center,
        color = MaterialTheme.colors.primary,
        text = text
    )
}

上記 2 つのコンポーネントは標準の Compose と同じなので、あまり説明しません。

最後にオプションの料理名:

@Composable
fun FoodChip(
    text: String,
    checked: Boolean,
    onCheckedChange: (checked: Boolean) -> Unit
) {
    
    
    ToggleChip(
        modifier = Modifier
            .fillMaxWidth()
            .padding(4.dp),
        checked = checked,
        toggleControl = {
    
    
            Icon(
                imageVector = ToggleChipDefaults.switchIcon(checked = checked),
                contentDescription = if (checked) "$text On" else "$text Off"
            )
        },
        onCheckedChange = {
    
    
            onCheckedChange(it)
        },
        label = {
    
    
            Text(
                text = text,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
        }
    )
}

これは、選択状態を切り替えることができるチップを表します。ここで、 はtoggleControl選択状態を示すために使用され、ここではデフォルトのトグル アイコン スタイルが使用されます。

onCheckedChange選択された状態が変化したときのコールバックを表します。

labelメイン表示テキストを示します。

このコントロールの表示効果は次のとおりです。

s4.png

上記の 3 つのモジュールをScalingLazyColumn次の場所に置きます。

// ……

item {
    
    
    StartButton(icon = runButtonIcon) {
    
    
        // TODO 点击按钮
    }
}

item {
    
     FoodText(foodText) }

itemsIndexed(foodList) {
    
     index: Int, item: Foods ->
    FoodChip(
        text = item.name,
        checked = item.enable
    ) {
    
    
        // TODO 菜的选中状态改变
    }
}

// ……

それ以来、すべてのインターフェースが完成しました。

ホームページロジックを実装する

インターフェイスを作成したら、次に制御ロジックを作成します。

これは Compose for Wear OS の使い方の予備的な検討にすぎないため、最初にアーキテクチャを設計する必要はなく、ロジック コードとインターフェイス コードを直接記述する必要があります (cover face.jpg)。

まず、いくつかの状態を定義します。

var isRunning = remember {
    
     false } // 标记是否正在选菜中

val listState = rememberScalingLazyListState() // ScalingLazyList 的 State
var runButtonIcon by remember {
    
     mutableStateOf(Icons.Rounded.PlayArrow) } // 开始运行按钮的图标
var foodText by remember {
    
     mutableStateOf("吃啥") } // 菜名
val foodList = remember {
    
     mutableStateListOf<Foods>() }  // 可选菜列表

val coroutine = rememberCoroutineScope() // 协程

次に、料理名のリストを直接書きます。

data class Foods(
    val name: String,
    var enable: Boolean = true
)

fun getFoodsList(): Array<Foods> = arrayOf(
    Foods("刀削面"),
    Foods("牛肉粉"),
    Foods("羊肉粉"),
    Foods("包子"),
    Foods("馒头"),
    Foods("泡面"),
    Foods("手抓饼"),
    Foods("牛肉泡馍"),
    Foods("蛋炒饭"),
    Foods("饭炒蛋"),
    Foods("饿着"),
    Foods("烤鸡腿"),
    Foods("烤肉拌饭"),
    Foods("怪噜饭"),
    Foods("糯米饭"),
    Foods("蛋包饭"),
    Foods("饭包蛋"),
    Foods("包蛋饭"),
)

に料理の名前を追加しますWearApp()

DisposableEffect(key1 = Unit) {
    
    
    foodList.addAll(getFoodsList())
    onDispose {
    
      }
}

ここでは、副作用にディッシュ名を追加することを選択します。これは、この副作用が 1 回だけ実行される、つまり、コンポーザブルが初めて結合されるときに実行され、データの再編成と繰り返しの追加を避けるためです。

次に、料理名リストの選択状態変更イベントを処理します。

// ……

FoodChip(
    text = item.name,
    checked = item.enable
) {
    
    
    foodList[index] = foodList[index].copy(enable = it)
}

// ……

foodList[index].enable = itを使用してリストのステータスを直接変更することはできないため、Compose はリストの変更をタイムリーに認識できなくなることに注意してください。具体的なパフォーマンスとしては、クリックしても反応はありませんが画面からスライドアウトしてから再びスライドすると、正常に更新されます。

s5.gif

foodList[index] = foodList[index].copy(enable = it)を使用してオブジェクトを直接再作成する必要がありますFoods

详见:Android Compose の遅延列は、ライブデータが変更されても更新されません

最後に、クリック開始ボタンのコールバックを処理します。

private const val RunTimeInterval = 150L

// ……

if (isRunning) {
    
    
    isRunning = false
    // coroutine.cancel()
    // coroutine.coroutineContext.cancelChildren()
    runButtonIcon = Icons.Rounded.Refresh
}
else {
    
    
    isRunning = true
    coroutine.launch(Dispatchers.IO) {
    
    

        runButtonIcon = Icons.Rounded.Pause

        var index = 0
        while (isRunning) {
    
    
            val food = foodList[index]
            if (food.enable) {
    
    
                foodText = food.name
                delay(RunTimeInterval)
            }

            index++
            if (index >= foodList.size) index = 0
        }
    }
}

// ……

処理ロジックは非常にシンプルで、まず実行中かどうかを判断し、実行中の場合は実行を停止し、ボタンアイコンを元に戻します。

実行されずに実行を開始した場合は、コルーチンを開始し、コルーチン内のディッシュ名のリストをループして、有効なディッシュ名をすべて表示します。

ここで注意すべき点は、実行が停止すると 2 行のコードがコメントアウトされたことがわかるということです。

最初はコルーチンを停止させた方が良いのではないかと思い(実際には実行時のループ条件が なので積極的に停止する必要はないisRunning)、coroutine.cancel()ステートメントを追加しました。

ただし、これを追加した後、プログラムは 1 回しか実行できず、2 回目はいずれにせよ実行できません。データを調べたところ、直接呼び出すとすべてのサブコルーチンがキャンセルされるだけでなく、プログラム自体が強制終了されることがわかりましCoroutineScope.cancel()CoroutineScope。もちろん、このスコープで新しいコルーチンを開始することはできなくなりました。

キャンセルしたい場合は、すべてを強制終了するのではなく、 cancel サブルーチンを使用する必要がありますcoroutine.coroutineContext.cancelChildren()

または、より詳細に言うと、各ジョブをそれ自体で制御する必要があります。

val job = coroutine.launch {
    
    
    // ……
}
job.cancel()

ちなみに、見栄えを良くするために、Text()料理名の表示に簡単なアニメーションを追加してみましょう。

@Composable
fun FoodText(text: String) {
    
    
    AnimatedContent(
        targetState = text,
        transitionSpec = {
    
    
            fadeIn(animationSpec = tween(100, delayMillis = 40)) +
                    scaleIn(initialScale = 0.92f, animationSpec = tween(100, delayMillis = 40)) with
                    fadeOut(animationSpec = tween(40))
        }
    ) {
    
    
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp),
            textAlign = TextAlign.Center,
            color = MaterialTheme.colors.primary,
            text = it
        )
    }

}

最後に、先ほど言ったことを覚えていますか? リストの最初の項目の幅は非常に小さく、表示するのが非常に見苦しいです。自動的に埋められるように追加しましたがAutoCenteringParams(itemIndex = 0)、最初に開いたときのデフォルトの位置は依然として先頭のままです。弊社のUIデザインに準拠していません。

したがって、最初の起動時に最初の項目を手動で中央に移動する必要があります。

// 移动到第一个 item 确保按钮在中间
LaunchedEffect(key1 = Unit) {
    
    
    listState.scrollToItem(0)
}

完全なコード

コードは非常に単純なので、コードホスティングにはアップロードせず、すべてを貼り付けるだけです。

private const val RunTimeInterval = 150L

@Composable
fun WearApp() {
    
    
    WearOScomposetestTheme {
    
    
        var isRunning = remember {
    
     false } // 标记是否正在选菜中

        val listState = rememberScalingLazyListState() // ScalingLazyList 的 State
        var runButtonIcon by remember {
    
     mutableStateOf(Icons.Rounded.PlayArrow) } // 开始运行按钮的图标
        var foodText by remember {
    
     mutableStateOf("吃啥") } // 菜名
        val foodList = remember {
    
     mutableStateListOf<Foods>() }  // 可选菜列表

        val coroutine = rememberCoroutineScope() // 协程

        DisposableEffect(key1 = Unit) {
    
    
            foodList.addAll(getFoodsList())
            onDispose {
    
      }
        }

        Scaffold(
            timeText = {
    
    
                if (!listState.isScrollInProgress) {
    
    
                    TimeText()
                }
            },
            vignette = {
    
    
                Vignette(vignettePosition = VignettePosition.TopAndBottom)
            },
            positionIndicator = {
    
    
                PositionIndicator(
                    scalingLazyListState = listState
                )
            }
        ) {
    
    
            ScalingLazyColumn(
                modifier = Modifier.fillMaxSize(),
                state = listState,
                autoCentering = AutoCenteringParams(itemIndex = 0)
            ) {
    
    
                item {
    
    
                    StartButton(icon = runButtonIcon) {
    
    

                        if (isRunning) {
    
    
                            isRunning = false
                            //coroutine.cancel()
                            //coroutine.coroutineContext.cancelChildren()

                            runButtonIcon = Icons.Rounded.Refresh
                        }
                        else {
    
    
                            isRunning = true
                            coroutine.launch(Dispatchers.IO) {
    
    

                                runButtonIcon = Icons.Rounded.Pause

                                var index = 0
                                while (isRunning) {
    
    
                                    val food = foodList[index]
                                    if (food.enable) {
    
    
                                        foodText = food.name
                                        delay(RunTimeInterval)
                                    }

                                    index++
                                    if (index >= foodList.size) index = 0
                                }
                            }
                        }
                    }
                }

                item {
    
     FoodText(foodText) }

                itemsIndexed(foodList) {
    
     index: Int, item: Foods ->
                    FoodChip(
                        text = item.name,
                        checked = item.enable
                    ) {
    
    
                        // foodList[index].enable = it // 直接修改将无法触发 重组 see: https://stackoverflow.com/questions/70071194/android-compose-lazycolumn-does-not-update-when-livedata-is-changed
                        foodList[index] = foodList[index].copy(enable = it)
                    }
                }
            }
        }

        // 移动到第一个 item 确保按钮在中间
        LaunchedEffect(key1 = Unit) {
    
    
            listState.scrollToItem(0)
        }
    }
}

@Composable
fun StartButton(
    icon: ImageVector,
    onClick: () -> Unit
) {
    
    
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center
    ) {
    
    
        Button(
            modifier = Modifier.size(ButtonDefaults.LargeButtonSize),
            onClick = onClick
        ) {
    
    
            Icon(
                imageVector = icon,
                contentDescription = icon.name
            )
        }
    }
}

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun FoodText(text: String) {
    
    
    AnimatedContent(
        targetState = text,
        transitionSpec = {
    
    
            fadeIn(animationSpec = tween(100, delayMillis = 40)) +
                    scaleIn(initialScale = 0.92f, animationSpec = tween(100, delayMillis = 40)) with
                    fadeOut(animationSpec = tween(40))
        }
    ) {
    
    
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp),
            textAlign = TextAlign.Center,
            color = MaterialTheme.colors.primary,
            text = it
        )
    }

}

@Composable
fun FoodChip(
    text: String,
    checked: Boolean,
    onCheckedChange: (checked: Boolean) -> Unit
) {
    
    
    ToggleChip(
        modifier = Modifier
            .fillMaxWidth()
            .padding(4.dp),
        checked = checked,
        toggleControl = {
    
    
            Icon(
                imageVector = ToggleChipDefaults.switchIcon(checked = checked),
                contentDescription = if (checked) "$text On" else "$text Off"
            )
        },
        onCheckedChange = {
    
    
            onCheckedChange(it)
        },
        label = {
    
    
            Text(
                text = text,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis
            )
        }
    )
}

fun getFoodsList(): Array<Foods> = arrayOf(
    Foods("刀削面"),
    Foods("牛肉粉"),
    Foods("羊肉粉"),
    Foods("包子"),
    Foods("馒头"),
    Foods("泡面"),
    Foods("手抓饼"),
    Foods("牛肉泡馍"),
    Foods("蛋炒饭"),
    Foods("饭炒蛋"),
    Foods("饿着"),
    Foods("烤鸡腿"),
    Foods("烤肉拌饭"),
    Foods("怪噜饭"),
    Foods("糯米饭"),
    Foods("蛋包饭"),
    Foods("饭包蛋"),
    Foods("包蛋饭"),
)

data class Foods(
    val name: String,
    var enable: Boolean = true
)


@Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true)
@Composable
fun DefaultPreview() {
    
    
    WearApp()
}

ps: 中のプレビュー コードは削除されません。直接コピーしてプレビューできます。

要約する

それ以来、私たちは Compose for Wear OS の使用方法を一般的に理解し、実際にそれを体験するために簡単な小さなデモを作成しました。

しかし、手元に道具がないため、深く体験することができません。

それでは、私の時計が到着したら、未完成の機能に移りましょう。

参考文献

  1. Wear OS コードラボ用に作成する
  2. Wear OS で Jetpack Compose を使用する

おすすめ

転載: blog.csdn.net/sinat_17133389/article/details/130894395