gikyu's memo

気が向いたら書いていきます?

2023年にやったJetpackComposeの対応を少しだけ紹介します

去年に引き続き、アドベントカレンダーの季節がやってきてました! ということでこれは 🎅GMOペパボエンジニア Advent Calendar 2023 - Adventar の16日目の記事です。 昨日は minne の同僚のnobuさんの記事でした。クリスマスツリーすごい、業務とは全然違ったことをやってるし、何よりプログラミングで楽しんでいて刺激になりました!(語彙力なくてすみません)。

hibinokoto.hatenadiary.jp

てことで、自分は何を書こうか色々迷っていたのですが、今年はAndoridエンジニアになって2年目でminneのいくつかの画面を JetpackCompose で実装したので、JetpackCompose 関連でやった作業をの内容を少〜し紹介いたします。😂

どんなものやったのか

フォロー一覧画面の書き換え

minneではユーザーのフォローリストを確認できる画面があります。 フォローしているユーザーとフォローされているユーザーの一覧を確認できる画面で、タブがあり、タブを切り替えるとユーザーの一覧を切り替えることができる画面になっています。 イメージとしてはこちらのような感じです。

Image from Gyazo

すでにAndroidViewで実装されているもので、 TabRowTabHorizontalPagerを組み合わせて書き換え実装を行いました。

プロダクトコードから簡略化されてはいますが、だいたい大枠としては以下のようなコードになりました。

private enum class Tab(val tabName: String) {
    TAB1("tab1"),
    TAB2("tab2"),
}

@Composable
fun HogeListScreen(
    state: State = rememberState(),
    coroutineScope: CoroutineScope = rememberCoroutineScope(),
    modifier: Modifier = Modifier.fillMaxSize()
) {

    val pagerState = rememberPagerState(initialPage = Tab.TAB1.ordinal)

    Column(modifier = modifier) {
        TabRow(
            selectedTabIndex = pagerState.currentPage,
            backgroundColor = Color.LightGray,
            contentColor = Color.Yellow,
        ) {
            Tab.values().map { it.tabName }.forEachIndexed { index, tabName ->
                Tab(
                    text = { Text(text = tabName, fontWeight = FontWeight.Bold) },
                    selected = pagerState.currentPage == index,
                    onClick = {
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(index)
                        }
                    },
                    selectedContentColor = Color.Red,
                    unselectedContentColor = Color.Blue
                )
            }
        }

        HorizontalPager(
            count = Tab.values().size,
            state = pagerState
        ) { pager ->
            when (pager) {
                Tab.TAB1.ordinal -> {
                    UserListSection(
                        users = state.users,
                        onRowClick = { state.onUserClick(it.id) },
                        modifier = modifier
                    )
                }

                Tab.TAB2.ordinal -> {
                    UserListSection(
                        users = state.users,
                        onRowClick = { state.onUserClick(it.id) },
                        modifier = modifier
                    )
                }
            }
        }
    }
}

少しポイントを紹介

①Tabの中の選択・未選択状態でのインジケーターや文字色の設定はTabRowとTabで別々に設定

TabRow(
            selectedTabIndex = pagerState.currentPage,
            backgroundColor = Color.LightGray,
            contentColor = Color.Yellow,
        ) {
            Tab.values().map { it.tabName }.forEachIndexed { index, tabName ->
                Tab(
                    text = { Text(text = tabName, fontWeight = FontWeight.Bold) },
                    selected = pagerState.currentPage == index,
                    onClick = {
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(index)
                        }
                    },
                    selectedContentColor = Color.Red,
                    unselectedContentColor = Color.Blue
                )
            }
        }

Tab 全体の色は backgroundColor で設定し、選択された際のインジケーターの色は contentColor で設定します。 選択時の Tab の文字色は Tabの selectedContentColor に Color を設定し、逆に未選択時のTab の文字色は Tab の unselectedContentColor に Color を設定します。 最初、TabRow の contentColor で選択・未選択時の色の設定ができると思っていたのですが、TabRow 内で定義されたTabで直接文字色を設定する必要があり少し迷ったのでこちらは注意です。

②Tabタップ時にそのTabに遷移する方法

TabRow では、PagerState に保持されたindex値を変更することで、タブの切り替えが可能です。 rememberPagerState で PagerState を作成し、その際 initialPage を設定することで初期Indexを決定できます。 Tab を enumで定義して、以下のように enum の index を取得して、initialPage として渡しています。

val pagerState = rememberPagerState(initialPage = Tab.TAB1.ordinal)

そして、タップの切り替えは pagerState を animateScrollToPage で切り替えます。 animateScrollToPage はsuspend関数なので、coroutineScope を呼び出す必要があり、scope内で animateScrollToPage を呼び出します

coroutineScope.launch {
    pagerState.animateScrollToPage(index)
}

③HorizontalPagerでページを切り替える。

HorizontalPager も pagerState をページの切り替えに使用するので、TabRow で使用した Pager State を渡します。 Horizontal Pager 上の pagerState の index の切り替えは、自動でやってくれるようなので特に Tab のように手動で切り替えるようなことをする必要はありません。 切り替えた際に PagerScope として、page の Index 値が返ってくるので、その index 値で表示したい View を宣言してあげます。

HorizontalPager(
            count = Tab.values().size,
            state = pagerState // こちらにTabRowで使用したPagerStateを渡す。
        ) { pager ->
            when (pager) {
                Tab.TAB1.ordinal -> { 
                      // ここにindexごとに表示したいViewを宣言する
                 }
                Tab.TAB2.ordinal -> { 
                      // ここにindexごとに表示したいViewを宣言する
                 }
            }
        }

AndroidView だったらこの View を作るのにやることが多くて大変ですが、JetpackCompose だと本当に簡単にできて感動です。🥹

SwipeRefresh の置き換え作業

SwipeRefreshって?

Accompanist で提供されてた SwipeRefresh の置き換え作業。 SwipeRefresh(Swipe to Refresh)っていうのはこんなやつ。よく見たことあると思いますが、画面の上から下にスワイプすることで画面が更新されるやつです。

Image from Gyazo

そして、Accompanist が何か少し補足すると、Accompanist は、Jetpack Compose をサポートする補助ライブラリの集まりです。 Jetpack Compose はまだ新しく、Android の従来の UI フレームワークのいくつかの機能は公式にはまだ提供されていません。 Accompanist は、このギャップを埋めます。つまり、Android View システムで使われていた機能を Jetpack Compose 内で簡単に使用できるようにするための橋渡しをしてくれるのが、Accompanist です。

Accompanist の Swipe Refresh を確認すると、deprecated の記載があり、公式から提供されたので、そちらを使ってくださいって記載がある、そして丁寧に migration guide も書いてあります。

google.github.io

今までのコードはこんな感じ

シンプルにリフレッシュしたい View を SwipeRefresh でラップしているだけの実装になってます。 すごいわかりやすい、実装ですよね。

val viewModel: MyViewModel = viewModel()
val isRefreshing by viewModel.isRefreshing.collectAsState()

SwipeRefresh(
    state = rememberSwipeRefreshState(isRefreshing),
    onRefresh = { viewModel.refresh() },
) {
 // リフレッシュしたいView
    LazyColumn {
        items(30) { index ->
            // TODO: list items
        }
    }
}

MigrationGuideを確認してみる

そして、migration guide に載っているコードはこちら。今まで SwipeRefresh を宣言してラムダの中にリフレッシュさせたい View を記述していたのが、Box を宣言して、refreshState を Box の modifier に渡して、 さらに PullRefreshIndicator を宣言する形に変わってます。

val viewModel: MyViewModel = viewModel()
val refreshing by viewModel.isRefreshing

val pullRefreshState = rememberPullRefreshState(refreshing, { viewModel.refresh() })

// Box に pullRefreshState を渡している
Box(Modifier.pullRefresh(pullRefreshState)) {

    LazyColumn(Modifier.fillMaxSize()) {
        ...
    }

    // こちらも新たに追加されてた
    PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}

Composable 関数を定義する

今まで SwipeRefresh と宣言していた部分に Box と PullRefreshIndicator を新たに宣言するのは大変そう(めんどくさい)、かつこれで Swipe to Refresh できるのかわかりづらい。だったら新たに SwipeRefresh という Composable 関数を宣言すれば差分もそこまでなくできそうってことで改めてこんな Composable 関数を作りました。何にも難しいことはしていないです。

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeRefresh(
    refreshing: Boolean,
    onRefresh: () -> Unit,
    indicatorColor: Color = Color.gray,
    indicatorModifier: Modifier = Modifier,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {

    val refreshState = rememberPullRefreshState(
        refreshing = refreshing,
        onRefresh = onRefresh
    )

    Box(modifier = modifier.pullRefresh(refreshState)) {

        content()

        PullRefreshIndicator(
            refreshing = refreshing,
            state = refreshState,
            contentColor = indicatorColor,
            modifier = indicatorModifier.align(Alignment.TopCenter),
            scale = true
        )
    }
}

で最終的に使う時はこんな感じ、変わったのは、SwipeRefresh の引数が state から refreshing に変わったぐらい。そのまま SwipeRefresh を使えていい感じです!

val viewModel: MyViewModel = viewModel()
val isRefreshing by viewModel.isRefreshing.collectAsState()

SwipeRefresh(
    refreshing = isRefreshing,
    onRefresh = { viewModel.refresh() },
) {
    LazyColumn {
        items(30) { index ->
            // TODO: list items
        }
    }
}

どんどん公式の実装が出ており、Accompanist もDeprecatedが増えてきたので、どんどん更新していきたいですね!🚀(どんどん多いな)

最後に

てことで、今年実装した Jetpack Compose 関連の紹介でした。 まだまだ Jetpack Compose で実装した画面はたくさんあるので、紹介できたらよかったのですが、書いてると結構なボリュームになりそうなのでまたどこかで紹介します!

最近は新規の画面は基本的に Jetpack Compose で実装しており、だんだんと慣れ書くのが楽しくなってきたので、もっと Jetpack Compose と仲良くなって、既存の画面も Jetpack Compose に置き換えていきたいと思っています。

明日の17日目の記事は一体どんな内容なのか楽しみです!