10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

NTTテクノクロスAdvent Calendar 2021

Day 21

Jetpack Composeが正式版になったのでQiitaビューアに機能を追加してみた

Last updated at Posted at 2021-12-20

この記事はNTTテクノクロス Advent Calendar 2021の21日目です。

こんにちは、NTTテクノクロスの戸部@etctaroと申します。

普段は社内でモバイルアプリ開発関連の技術支援や社内向けのノウハウ記事執筆、社内研修講師活動、社内コミュニティ活動などを行なっています。
社内研修でも本記事のテーマであるJetpack Composeを取り扱っています。

はじめに

  • Jetpack Composeは今年7月に正式にリリースされた、Androidアプリの宣言的UIフレームワークです。
  • 一言で言えば、新しいUI開発手法となります。
  • 12/1現在のStable版は1.0.5であり、次のバージョンである1.1系についても、ベータ版になっています。

※という、ざっくりな説明を 今年たくさんした気がします

今回のサンプルコードについて

昨年、一昨年の記事について

一昨年度昨年度はその時のバージョンでQiitaビューアを作ってみました。

昨年度の記事執筆時はアルファ版で、その後ベータ版でも記事を作成しましたが、そこから正式版にあたっては、
手元で確認した限りではバージョンの更新などはあるものの、大きな変更しなくても比較的動くような状況でした。

今年の内容

ということで、今回はQiitaビューアについて、以下のテーマで更新を行いました。

  • 現時点での最新Stable版への更新
  • 記事画面をWebViewで表示
  • 記事のリストと記事を2ペインで表示する
  • Navigation対応

ちょっと後半が細かいですが、具体的には以下をやります。

  • AndroidViewを使い、Jetpack ComposeのアプリにWebViewの実装を盛り込む
  • 状態ホイスティングによりStateを上位のコンポーザブルに移動し、複数のComposableに共有できるように変更する
  • navigation-composeを適用した画面遷移

ということで早速本題に入ります。

開始時点での構成イメージ(擬似コード)

さて、昨年作ったアプリについておさらいしてみましょう。
画面イメージはこんな感じでした。

昨年度のイメージ

擬似的なComposableのコードを書きますと大体こんな感じの構成になります。

HomeScreen {
    QiitaItemList {
        Column {
            QiitaItem {
                Row {
                    Column {
                        Text()
                        Text()
                    }
                }
            }
            //...
        }
    }
}

現時点でのStable(1.0.5)への更新

さて、何はともあれ関連するライブラリ類のバージョンを上げてみます。

Android Studioならbuild.gradleで古めのライブラリが使われているときにマーカー表示されるのでわかりやすいですね。

ということで、Beta版からStableに更新する際に差分の入ったファイルは以下の通りです。

差分の入ったファイル一覧

ソースコードに変更が入っておらず、ほぼ バージョン更新が入ったのみ となることがお分かりではないでしょうか。
昨年度はAPIに大きな変更が入ってため、なかなか大変でした。

あえていうなら、Android12対応のためAndroidManifest.xmlexportedが付与されたくらいでしょうか。

ということで、それほど詰まることなく更新はできてしまいました。
このままでは盛り上がりに欠ける。

記事画面をWebViewで表示

このまま動かすだけでは芸がないので、もう少し変更を加えてみることにします。

昨年の記事では記事を表示する際にはIntentでブラウザを起動して表示していました。
これはこれで一つの手ですが、今回はWebViewで表示するようにしてみました。

WebViewを利用する

Jetpack ComposeでWeb画面を表示する方法ですが、実はJetpack Compose自体にはWebViewに相当するComposable関数はありません。(少なくとも現時点において。)

※同じような話ではMapViewも筆頭に挙がります。また、iOSのSwiftUIでも同じような話があります。

そこで登場するのが、AndroidのViewを利用するためのComposable関数 AndroidView です。

具体的に、AndroidViewを利用してWebView対応のComposableを作ったコードが以下の通りです。

@Composable
private fun MyWebComposable(url: String) {
    AndroidView(factory = { ctx ->
        WebView(ctx)
    }, update = {
        it.webViewClient = WebViewClient()
        it.loadUrl(url)
    }, modifier = Modifier.fillMaxSize()
    )
}

factoryで表示したいViewの初期化、updateのブロックで再構築の際の動作を指定してあげる、という感じになります。
また、AndroidView自体はComposableなので、modifierを設定することができます。

WebView on AndroidViewの動きを確認する

一例で、以下のようなPreviewで動作確認できます。ボタンをクリックするとそれぞれのURLの内容が切り替わりながら画面に表示されるようなComposableです。

@Preview
@Composable
fun MyWebPrev() {
    val weburl = remember { mutableStateOf("")}
    Column {
        Row {
            Button(onClick = { weburl.value = "https://google.co.jp/" }) {
               Text("Url1")
            }
            Button(onClick = { weburl.value = "https://yahoo.co.jp/" }) {
                Text("Url2")
            }
        }
        MyWebComposable(url = weburl.value)
    }
}
AndroidViewを使ったアプリ

URLを指定すれば、そのページが表示される動きをするComposableという感じの、ステートレスなComposableになります。わかりやすいですね。

なお、httpの場合はNetwork security configurationの設定が必要なのでローカルのサーバに接続する場合などはご注意ください。

記事のリストと記事を2ペインで表示する

さて、リスト部分と、上記で作成したWebViewもどきのComposableを使って、2ペインで表示するようなアプリを作ってみましょう。

画面の完成形はこんな感じになります。(選んだURLに他意はありません。たまたま面白そうだっただけです。)

完成形イメージ

ちょっとだけわかりやすいように、以下のように画面を名付けておきます。

  • HomeScreen : ListScreenとDetailScreenを横並びで表示するComposable
  • ListScreen : 記事の一覧を表示するComposable
  • DetailScreen : クリックした記事を表示するComposable

注意しなければならない点がいくつかあります。

  • ListScreen側でクリックしたアイテムの情報をDetailScreen側に渡す必要がある。
  • 「どの記事を表示しているか」という情報が必要となる。この情報はListScreen側とDetailScreen側で共用する。

ListScreenの各要素をクリックする際の動作はModifier.clickableで設定しています。
イメージとしてはこれくらいの位置にあります。

変更前.png

なかなか深い場所に。

ただし、この情報をDetailScreenコンポーザブルが扱いたいとなった場合、問題が発生します。
コンポーザブルは他のコンポーザブルの内部状態を扱うことは単純にはできません。

Jetpack Composeのコンポーザブル内の状態の値は上から下に流れていくイメージになります。
下位のコンポーザブルで定義された内部状態を上位のコンポーザブルで扱うことはできません。
逆に上位のコンポーザブルで状態が定義されていれば、その配下のコンポーザブルで扱うことができるということになります。

これを実現するため、状態ホイスティングにより、上位のComposableに状態を移すことを考えます。

イメージとしてはこんな感じ。

変更後.png

状態ホイスティング

※状態については、公式ブログ及び@takahiromによるJetpack Compose State Practicesが勉強になります。

状態ホイスティングはComposableの中で定義された内部状態を外部に掃き出すためのテクニックです。
詳しくは公式のこちらを参照してください。

この方法で、状態に関する値を上位のComposableに移動します。
実装の変更としては以下の通りとなります。

クリックした記事を状態として持つ1: QiitaItemのonClickを外に追い出す

さて、ひとまず、クリックした記事がどんな記事かという情報を持たせることにしました。

ただし、その前に、リストの1要素を表示するためのQiitaItemを見直し。

@Composable
fun QiitaItem(title: String, url: String) {
    val context: Context = LocalContext.current

    Row(Modifier.clickable(onClick = {
        val uri = Uri.parse(url)
        val intent = Intent(ACTION_VIEW, uri)
        startActivity(context, intent, null)
    })) {
        Column {
            Text(text = title, style = typography.h6)
            Text(text = url, style = typography.body2)
        }
    }
}

ここでonClickの処理をModifier.clickableで設定していますが、ちょっと深いですね。
ということで、ちょっとだけ修正。(Refactor > Introduce Parameter)

@Composable
fun QiitaItem(title: String, url: String, modifier: Modifier) {
    Row(modifier) {
        Column {
            Text(text = title, style = typography.h6)
            Text(text = url, style = typography.body2)
        }
    }
}

このように、外からmodifierを渡すようにしました。

これをやったことにより、上位のComposableであるQiitaItemListはこのようになります。

@Composable
fun QiitaItemList(items: List<QiitaArticle>) {
    LazyColumn(
        modifier = Modifier.semantics { contentDescription = "Item List" }) {
        items(items) { item ->
            QiitaItem(title = item.title, url = item.url, modifier = Modifier.clickable(onClick = {
                /// ここの中身は要変更
            }))
            Divider(color = Color.Black)
        }
    }
}

クリックした記事を状態として持つ2: QiitaItemListにクリックした記事の情報を持たせる

さて、ここからが本番です。
まずは、クリックした記事の情報を持つようにしてみましょう。
単に記事を表すStateであるselectedItemを追加するだけです。

@Composable
fun QiitaItemList(items: List<QiitaArticle>) {
    var selectedItem by remember { mutableStateOf<QiitaArticle?>(null) }

    LazyColumn(
        modifier = Modifier.semantics { contentDescription = "Item List" }) {
        items(items) { item ->
            QiitaItem(title = item.title, url = item.url, modifier = Modifier.clickable(onClick = {
                selectedItem = item
            }))
            Divider(color = Color.Black)
        }
    }
}

次に、selectedItemを上位のコンポーザブルで利用するため、ホイスティングを行います。

@Composable
fun QiitaItemList(items: List<QiitaArticle>, selectedItem : QiitaArticle?, onSelectedItemChanged : (QiitaArticle) -> Unit ) {
    // var selectedItem by remember { mutableStateOf<QiitaArticle?>(null) }

    LazyColumn(
        modifier = Modifier.semantics { contentDescription = "Item List" }) {
        items(items) { item ->
            QiitaItem(title = item.title, url = item.url, modifier = Modifier.clickable(onClick = { onSelectedItemChanged(item) }))
            Divider(color = Color.Black)
        }
    }
}

このQiitaItemListを含む、上位のComposableであるListScreenは、selectedItemを移動して、このようになります。

@Composable
fun ListScreen(qiitaArticles: SnapshotStateList<QiitaArticle>) {
    var selectedItem by remember { mutableStateOf<QiitaArticle?>(null) }

    QiitaItemList(items = qiitaArticles,
        selectedItem = selectedItem,
        onSelectedItemChanged = {selectedItem = it}
    )
}

クリックした記事を状態として持つ3: ListScreenの状態ホイスティング

同じ要領でListScreenから状態を外に出します。

@Composable
fun ListScreen(qiitaArticles: SnapshotStateList<QiitaArticle>, 
                    selectedItem: QiitaArticle?,
                    onSelectedItemChanged: (QiitaArticle) -> Unit
               ) {
    QiitaItemList(items = qiitaArticles,
        selectedItem = selectedItem,
        onSelectedItemChanged = onSelectedItemChanged
    )
}

ここまでで、ListScreenはHomeScreenで以下の通りパラメータ設定して呼ばれます。

    val qiitaArticles = remember { mutableStateListOf<QiitaArticle>() }
    val selectedArticle = remember { mutableStateOf<QiitaArticle?>(null) }


    ListScreen(
        items = qiitaArticles,
        onItemsChanged = {
            // 既存から特に変更なし。
        },
        selectedItem = selectedArticle,
        onItemSelected = {
            selectedArticle.value = it }
    )

クリックした記事を状態として持つ4: パラメータの削除

そして、ここまで実施したところで、実は一部のパラメータは使わないことがわかります。
どれをクリックしたか、という情報だけが伝わってくれば良いので、一番最初に作成したselectedItemは必要なく、onSelectedItemChangedだけで良いのです。

不要なパラメータはAndroid Studio上でも灰色になりますのでわかりやすいですね。

fun QiitaItemList(items: List<QiitaArticle>,
  selectedItem : QiitaArticle?, //→不要
  onSelectedItemChanged : (QiitaArticle) -> Unit ) {
fun ListScreen(qiitaArticles: SnapshotStateList<QiitaArticle>, 
                    selectedItem: QiitaArticle?, //→不要
                    onSelectedItemChanged: (QiitaArticle) -> Unit
               ) {

さて、ここまででリスト側のComposableができました。

2ペイン化

次に、あまり深く考えず、ListScreenとDetailScreenを横並びにしてみました。

そして、DetailScreen側ではリストでクリックした記事の情報を渡してあげます。

    val qiitaArticles = remember { mutableStateListOf<QiitaArticle>() }
    val selectedArticle = remember { mutableStateOf<QiitaArticle?>(null) }

    Row {
        Box(modifier = Modifier.weight(1.0F)) {
            ListScreen(
                items = qiitaArticles,
                onItemsChanged = {
                    // Fabクリック時の処理
                },
                onItemSelected = {
                    selectedArticle.value = it }
            )
        }
        Box(modifier = Modifier.weight(1.0F)) {
            DetailScreen(url = selectedArticle.value?.url ?: "")
        }
    }
完成形イメージ

navigationに対応する

ここまでの実装で、画面遷移もどきができます。
「画面遷移もどき」と書いたのは、左のペインをクリックすればひとまずそれに応じて右のペインの画面が変わるものの、
戻るボタンをタップするとアプリが閉じるためです。

そこで、ここまでの実装を応用してJetpack ComposeのNavigationライブラリ(navigation-compose)に対応することにしました。

参考リンク

dependenciesの追加

dependencies {
    implementation "androidx.navigation:navigation-compose:2.4.0-beta02"
}

NavControllerの宣言

NavControllerは画面遷移に関する関数を提供するAPIです。NavHostと組み合わせたうえで、Composableから所定の関数を呼ぶことで、画面間の遷移を行うことができます。

val navController = rememberNavController()

これは基本的に上位のComposableに追加します。以下のような並びです。

@Composable
fun HomeScreen() {
    val qiitaArticles = remember { mutableStateListOf<QiitaArticle>() }
    val selectedArticle = remember { mutableStateOf<QiitaArticle?>(null) }

    val navController = rememberNavController()

NavHostの定義

NavHostは画面遷移に関する設定を定義するための仕組みです。
navigation-composeではComposable関数として用意されています。

NavHostのComposableは例えるなら紙芝居の枠で、この枠に収める絵が各画面のComposable関数となります。

今回は、DetailScreenの部分が切り替わりながら表示される、そんなイメージとなります。

URLをパラメータとして渡しながら画面遷移することになります。
下記のように、パラメータに関するキー(今回は"url")を使っています。

具体的な実装は以下の通りです。

    NavHost(navController = navController, startDestination = "detail?url={url}",
        modifier = Modifier.fillMaxSize()
        ) {
        composable("detail?url={url}",
            arguments = listOf(navArgument("url") { defaultValue = "" })
            ) { DetailScreen(url = it.arguments?.getString("url") ?: "") }
    }

NavHostはComposable関数なので、上記の通り、modifierの設定を行うこともできます。

画面遷移の実装

画面遷移はnavController.navigateで実行できます。
2ペイン化までの実装の中で、リストの要素をタップした時の動作は実装できているので、
そこに入れ込めば良さそうですね。

    ListScreen(
        ///略
        onItemSelected = {
            navController.navigate("detail?url=${it.url}")
        }

なお、selectedArticleは不要になったので削除します。
このように、イベントの処理がうまくできるようになっていれば、そこをNavigationに置き換えるのは比較的簡単だというのがわかっていただけるかと思います。

実際の動き

実際に動かしてみるとこうなります。

最終的なイメージ

画面遷移しつつ、ジェスチャーで前の記事に戻っていることがわかるかと思います。

まとめ

ということで、今回はJetpack Compose正式化記念ということで以下をしてきました。

  • ライブラリ類の最新化
  • AndroidViewを使ったWebView表示
  • 2ペイン化と状態ホイスティングの実例
  • navigation-composeによる画面遷移

リスト周りの実装が最低限なので、まだまだやれることは多いです。

  • 無限リスト化
  • DB対応
  • etc...

機会があればもう少し拡張してみようかと思います。

そんな感じで、少しずついろんな拡張が考えられると思います。

ぜひみなさんも色々試して良いComposeライフをお過ごしください。

令和こそこそ噂話

22日目は@yukimurakiさんがAWS関連の記事を書いてくださいます。

アドベントカレンダーも終盤に差し掛かりつつありますが、
明日もぜひご覧いただければと思います!

10
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?