Jetpack Composeでプレゼン資料を作ってみた

この記事は[NTTテクノクロス Advent Calendar 2022](https://qiita.com/advent-calendar/2022/ntt-tx)の8日目です。

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

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

Jetpack ComposeはAndroid界隈では話題を大きく集めていますね。
DroidKaigiやAndroid Dev Summitなど、国内外含め各種カンファレンス、勉強会でも話題に事欠きません。

ここ三年間Jetpack Composeについての記事を出していますので、今年も続けてJetpack Composeの記事を書いていきます。

なお、[技術書典13](https://techbookfest.org/event/tbf13)で宣言的UIに関する本を共同執筆し、自身はJetpack Composeを担当しました。
こちらもぜひお手にとっていただければと思います。
- [宣言的UIアプリ開発食べくらべ](https://techbookfest.org/product/i26qH6sfdvfucWuyaCYH6q?productVariantID=1TjCkN4JWFr8q2xp4wfGTm)
<img width="250" src="https://techbookfest.org/api/image/fGPDMeyViaQ323am4gFBtv.png">

# はじめに
- Jetpack Composeは昨年の7月に正式リリースされて、2022/11時点で1.3系が安定版となっています。
- 2022/11に社内の勉強会で「ざっくり最新Jetpack Compose 2022秋」というテーマで　LTを行ないました。
- その発表の中で、Jetpack Composeとは、というのを示すために実際にComposeを使うのがいいんじゃね？と考え、 **発表資料をアプリとしました**。
- ぜひ皆さんも真似してみてくださいね！

# 今回のサンプルコード
- サンプルコードは[こちらのリポジトリ](https://github.com/etctaro-tech/PresentationApp)にあります。
- なお、元々のコードでは社内向けの情報も含んでいますが、ソースコードの公開にあたって、一部情報を編集しています。このため、「社内で実施した発表内容」という前提で読むと違和感があるかもしれませんがご了承ください。

# 対象読者
- Composeの実装を始めたばかりの人
- Navigationの使い方の一例を知りたい人
- 新しい発表の仕方を模索している人

# アプリで資料にした内容について
今回はLT:7分で、以下のようなスライド構成となりました。（なお、ゆるめでも許される勉強会です。）

- タイトル
- 目次
- 自己紹介
- Jetpack Composeについて
- Jetpack Composeは流行っている、という話
- 最新動向：Material3に正式対応
- 最新動向：Jetpack Compose 1.2-1.3系の話
- まとめ
- 参考資料
- Happy Composing

以降は各スライドの実装についてピックアップしながら説明します。
（事細かには記載しません。詳細はGitHubで公開するサンプルコードをご覧ください。）

発表の際は端末でこのアプリを動作させて、Device Mirroringの機能で画面に表示、
可能な限り大きめにしてスクリーン共有でプレゼン、という感じです。

以下の動画の通り。
![発表した内容の全体イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/603c04a1-75dd-d427-32e3-033ae732e9b7.gif)

それぞれのスライドについては、[リポジトリのこちら](https://github.com/etctaro-tech/PresentationApp/tree/main/screenshots)を参照してください。

# 全体的：スライドの遷移について
![スライドの遷移](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/58931ac8-2bc8-c124-37f7-2bb94c902b7c.png)

- 左右矢印ボタンをBottomAppBarに表示して、タップするとスライドを移動する、というものにしました。
- ページ番号をStateとして持っておいて、左矢印をタップするとデクリメント、右矢印をタップするとインクリメントする。
- ページ番号に合わせてNavHostで表示する画面(Composable)を切り替える。
- また、各スライドごとにスタイルを変える可能性があること、画面が狭くなるとプレゼンしづらくなることから、下部にだけAppBarを入れました
- 今回はスライドの遷移のためにしか使っていませんが、NavControllerを各コンポーザブルに渡せば、スライド内から他のスライドに遷移する、などといった動きもできます。(普通のアプリなので、バックキーで戻ることもできる。)

## NavHost

- NavHostは表示するComposableを次々に切り替えて表示するための部分です。Jetpack ComposeではNavHostもComposable関数です。
- 私が説明するときは、しばしば紙芝居の木枠に例えます。（が、通じるかな・・・）
- Navigationコンポーネントを使うことで、ページの遷移が共通的に実装することができ、非常に見通し良くなります。

```Kotlin
// それぞれの画面に対応するrouteを保持するためのクラス
sealed class Screen(val route: String) {
    object Start : Screen("start")
    object Index : Screen("index")
    object Introduction : Screen("introduction")
    object AboutCompose  : Screen("aboutCompose")
    object ComposeTrend : Screen("composeTrend")
    object Material3  : Screen("material3")
    object ComposeLatest  : Screen("composeLatest")
    object Conclusion  : Screen("conclusion")
    object Reference  : Screen("reference")
    object End : Screen("end")
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PresentationsNavHost() {

    // このリストの順にページを表示する。必要に応じて並び替えや挿入が可能。
    val items = listOf(
        Screen.Start,
        Screen.Index,
        Screen.Introduction,
        Screen.AboutCompose,
        Screen.ComposeTrend,
        Screen.Material3,
        Screen.ComposeLatest,
        Screen.Conclusion,
        Screen.Reference,
        Screen.End
    )

    // 現在のページを保持するState。Saveableにすることでバックグラウンドになった際もStateが保持される。
    var currentPage by rememberSaveable { mutableStateOf(0) }
    val navController = rememberNavController()

    Scaffold(
        bottomBar = {
            BottomAppBar() {
                IconButton(onClick = {
                    currentPage--
                    navController.navigate(items[currentPage].route)
                }, enabled = currentPage > 0
                ) {
                    Icon(Icons.Filled.ArrowBack, contentDescription = "back page")
                }
                Text("${currentPage + 1}")
                IconButton(onClick = {
                    currentPage++
                    navController.navigate(items[currentPage].route)
                }, enabled = currentPage < items.size - 1
                ) {
                    Icon(Icons.Filled.ArrowForward, contentDescription = "back page")
                }
            }
        }
    ) {
        NavHost(navController, startDestination = items[0].route, Modifier.padding(it)) {
            composable(Screen.Start.route) { StartPage() }
            composable(Screen.Introduction.route) { IntroductionPage() }
            composable(Screen.Index.route) { IndexPage() }
            composable(Screen.AboutCompose.route) { AboutComposePage() }
            composable(Screen.ComposeTrend.route) { ComposeTrendPage() }
            composable(Screen.Material3.route) { Material3Page() }
            composable(Screen.ComposeLatest.route) { ComposeLatestPage() }
            composable(Screen.Conclusion.route) { ConclusionPage() }
            composable(Screen.Reference.route) { ReferencePage() }
            composable(Screen.End.route) { EndPage() }
        }
    }
}
```

# ソースコードの表示、コピペ

![ソースコードの表示](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/17fab23e-3168-9c8f-e356-6a53ff03ba88.png)

- このUIはこんなソースからできている、というものを表示するための機能を追加してみました。コピペ機能も追加。
- SelectionContainerで囲むことで、その中のComposableの選択が可能となります。
- Android Studioから端末・エミュレータなどで動かすと、コピーした内容をPC側で扱うことも可能です。
- ダークモードにも対応。

![ソースコードの表示(ダークモード)](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/f981a94d-1f7a-cb06-b253-a81dd0e4f8dd.png)


## ソースコードを表示する部分
- 四角い枠を表示します。
- これらは共通的に使うため、common.ktというファイルに書きました。

```Kotlin
@Composable
fun SourceCodeViewer(code: String) {
    SelectionContainer {
        Text(
            text = code,
            style = Typography.bodyMedium,
            modifier = Modifier
                .background(color = if (isSystemInDarkTheme()) Color.Black else Color.White )
                .verticalScroll(rememberScrollState())
                .border(width = 2.dp, color = if (!isSystemInDarkTheme()) Color.Black else Color.White)
                .padding(3.dp)
        )
    }
}

@Composable
fun ShowCodeButton(showSource: Boolean, onChecked: (Boolean) -> Unit) {
    IconToggleButton(checked = showSource, onCheckedChange = onChecked) {
        if (showSource) {
            Icon(Icons.Filled.Favorite, contentDescription = "show Source")
        } else {
            Icon(Icons.Outlined.FavoriteBorder, contentDescription = "hide Source")
        }

    }
}
```

## 上記を利用する側

- 使う側はこのような感じです。showSourceというStateで表示を切り替えているだけです。
- 本当は各スライドにこの機能をつけようとしたが時間切れ＆画面によってはだいぶ長くなったので断念。
- この場合、共通的な機能になるのでAppBarにボタンを用意しても良さそうです。

```Kotlin
@Composable
fun AboutComposePage() {
    var showSource by remember { mutableStateOf(false) }
    var showMessage by remember { mutableStateOf(false) }

    Row(modifier = Modifier) {

        var textState by remember { mutableStateOf("") }
        Column {
            Text(text = "Jetpack Composeについて", style = Typography.headlineLarge)
            Button(onClick = { showMessage = true }) {
                Text(text = "Sample")
            }
            TextField(value = textState,
                onValueChange = { textState = it },
                label = { Text(text = "Sample") })
        }
        Column {
            Row(modifier = Modifier) {
                ShowCodeButton(showSource, { showSource = it })
                if (showSource) {
                    SourceCodeViewer(
"""@Composable
fun AboutComposePage() {
    var textState by remember { mutableStateOf("") }
    var showMessage by remember { mutableStateOf(false) }
    Column {
        Text(text = "Jetpack Composeについて", style = Typography.headlineLarge)
        Button(onClick = { showMessage = true }) {
            Text(text = "Sample")
        }
        TextField(value = textState,
            onValueChange = { textState = it },
            label = { Text(text = "Sample") })
    }
}"""
                    )
                }
            }
            if (showMessage) {
                Text(text = "UIの実装を説明・共有するのが容易な実装方法", style = Typography.headlineSmall)
            }
        }
    }
}
```

# Jetpack Composeは流行っている、という話
- このスライドでは [AccompanistのWebView](https://github.com/google/accompanist/tree/main/web)を使って、Android Dev SummitやDroidKaigiのWebサイトを表示できるようにしました。
    - ※Accompanist＝Jetpack Composeで標準ではまだ実装されていないAPIを今後本体に取り込まれることを前提として開発されているライブラリ群
- 実装としては単にURLを指定してあげるだけで特別難しいことは何もないです。
- ただし、試してみたところ、WebViewのロードが完了するまで周辺のComposableを巻き込んでブランクの状態になるようです。
    - 通信状況が悪い場合致命的になり得る。
    - 調べてみましたが、[同じ状況の人が見つかった](https://stackoverflow.com/questions/74392879/accompanist-webview-prevents-other-composables-from-showing)くらいで対策は見つからずでした。

```kotlin
    val webViewState = rememberWebViewState("https://developer.android.com/events/dev-summit/technical-talks#modern-android-development")

    Column {
        Row {
            Button(onClick = { webViewState.content = WebContent.Url("https://developer.android.com/events/dev-summit/technical-talks#modern-android-development")}) {
                Text("Android Dev Summit")
            }
            Button(onClick = { webViewState.content = WebContent.Url("https://droidkaigi.jp/2022/timetable") }) {
                Text("DroidKaigi")
            }
        }
        WebView(webViewState)
    }
```

# 最新動向：Material3に正式対応
このスライドでは、比較のためMaterial3とそれ以前のButton, FABのデフォルトを並べてみました。
その上で、端末(Pixel4A、Android13)の背景を変更してみることで、Material Youの影響でどのように変わるか、という点を比較表示しました。

※そもそもMaterial3になるにあたってボタンの見た目も大きく変わっていますね。

![茶色主体の色味の画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/0a814135-5607-ad83-b0c3-1481c10929e6.png)

![青緑主体の色味の画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/0839f1fb-cab5-77b5-1cb3-d6ce8de931ae.png)

## build.gradleのdependencies部分

- 下記の通り、material3とmaterialのアーティファクトが両方とも入った状態にしました。
- Flamingoからは新規のComposeアプリの作成で、material3がデフォルトでdependenciesに入っている状態となっています。materialの方はあとから追加したものです。

```gradle
dependencies {
    //...略
    implementation platform('androidx.compose:compose-bom:2022.10.00')
    implementation 'androidx.compose.material3:material3'
    implementation 'androidx.compose.material:material' //後から追加
    //...略    
}
```

## Buttonの並び
- materialの方については、full qualifierにしていますが、このようにすることで異なるバージョンのUIを並べることができます。

```kotlin
    Row() {
        Column(modifier = Modifier.padding(5.dp)) {
            Button(onClick = { /*TODO*/ }) {
                Text(text = "M3")
            }
            FloatingActionButton(onClick = { /*TODO*/ }) {
                Image(Icons.Filled.Lock, contentDescription = "")
            }
        }
        Column(modifier = Modifier.padding(5.dp)) {
            androidx.compose.material.Button(onClick = { /*TODO*/ }) {
                Text(text = "M2")
            }
            androidx.compose.material.FloatingActionButton(onClick = { /*TODO*/ }) {
                Image(Icons.Filled.Lock, contentDescription = "")
            }
        }
    }
```

# 最新動向：Jetpack Compose 1.2-1.3系の話
Jetpack Compose 1.2-1.3系の新しいAPIをいくつか紹介しました。

- 発表時にはビジュアル的にわかりやすい、LazyGrid/LazyStaggeredGridとPullRefreshについて紹介。
- PullRefreshについては、実施するとGridの列数をランダムに変えて反映する、という実装にしてみました。
- 全て記載すると長くなるので、ここではLazyVerticalGridとLazyVerticalStaggeredGridの実装についてのみ記載します。
- 必要に応じてGitHubのコードも参照してください。

## LazyVerticalGrid
- 0-100まで連番をつけて、ドロイドくんの画像を一つ配置、その下に番号を表示して、それをグリッド状に並べるという実装です。

![LazyVerticalGrid](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/09b33847-1fac-2e4f-cc82-6f4f0e91057b.png)

```kotlin
    // Listに表示するデータ
    val items = (0..100).toList()            
    var gridColumnCount by remember { mutableStateOf(5) }

    LazyVerticalGrid(
        columns = GridCells.Fixed(gridColumnCount)
    ) {
        items(items) {
            Column(
                modifier = Modifier
                    .padding(3.dp)
                    .background(Color.Green)
            ) {
                Image(
                    painter = painterResource(id = R.drawable.ic_launcher_foreground),
                    contentDescription = "$it"
                )
                Text("$it")
            }
        }
    }
```


## LazyVerticalStaggeredGrid

- 0-100まで連番をつけて、3の倍数の時だけドロイドくんの画像を縦に二つ並べる、そうでないときは一つだけ並べるという実装です。
- ExperimantalなAPIであることを除けば、LazyGridとほとんど全く違いはありません。
- VerticalStaggeredだと以下の通り、最初は横並びに並べて、その次の行からは隙間を埋めるようにセルを敷き詰めます。

![LazyVerticalStaggeredGrid](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/1bc951be-a293-1e7e-c49d-556f78176f8a.png)

```kotlin
    // Listに表示するデータ
    val items = (0..100).toList()            
    var gridColumnCount by remember { mutableStateOf(5) }

    LazyVerticalStaggeredGrid(
        columns = StaggeredGridCells.Fixed(gridColumnCount)
    ) {
        items(items) {
            Column(
                modifier = Modifier
                    .padding(3.dp)
                    .background(Color.Green)
            ) {
                if (it % 3 == 0)
                    Image(
                        painter = painterResource(id = R.drawable.ic_launcher_foreground),
                        contentDescription = "$it"
                    )
                Image(
                    painter = painterResource(id = R.drawable.ic_launcher_foreground),
                    contentDescription = "$it"
                )
                Text("$it")
            }
        }
    }
```


# 参考資料のスライド
参考資料のスライドについては、Powerpointなどでは普通にリンクを貼ればOKというところですね。
しかし、エミュレータ上でそのページが見えてもしょうがないので、今回はタップするとクリップボードにURLがコピーされるようにしました。

![タップするとURLをコピペ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/ba9df071-57e7-536c-5118-a5874694c460.png)


```Kotlin
    Column(modifier = Modifier) {
        val clipboardManager: ClipboardManager = LocalClipboardManager.current

        Text(text = "参考資料", style = Typography.headlineLarge)
        Text(text = "・公式サイト", style = Typography.headlineMedium, modifier = Modifier.clickable { clipboardManager.setText(
            AnnotatedString("https://developer.android.com/jetpack/compose?hl=ja")
        ) })
        /// 以下略
    }
```

# 新しく画面を作るときの流れ

- スライド用の新しいComposableを作成する
    - 内容を考える。
    - 一旦はそのスライド単独で内容を実装する。(後からサイズの調整などは必要。)
- 作成したComposableを表示できるようにNavHostなどを設定する。

```Kotlin
/*
    こんな感じの内容を発表予定
    - 新しいページの作り方について
    - ほげほげ
 */

@Composable
fun NewPage() {
    ///頑張って実装する
}
```

```Kotlin
sealed class Screen(val route: String) {
    // ...既存のページ群
    object NewPage : Screen("newPage")
}

fun PresentationsNavHost() {

    // このリストの順にページを表示するイメージ
    val items = listOf(
        //...既存のページ群
        Screen.NewPage
    )
//...
    Scaffold(
        // 略
    ) {
        NavHost(navController, startDestination = items[0].route, Modifier.padding(it)) {
            //...既存のページ群
            composable(Screen.NewPage.route) { NewPage() }
        }
```

上記の通り、ページをひとつ加えるためにそれほど手間はないです。
（もちろん、各ページについては頑張って実装しましょう。）

# 雑感
資料を後から読み返す時に面倒（PDF/PowerpointのようにWebサービスで直接見られるようなものではない）ということを除けば、
個人的にはこの発表方法もしっくりきています。

- 新しいAPIを紹介したいときなどに、実装を用意すればそれがそのまま説明資料になる。パワポで説明するためにアニメーションを用意する必要ない。
- 使い回しが利く。新しいAPIの紹介用にComposableを少し追加するだけでも、発表としての体裁が整う。
- 端末と発表資料がいい感じに連動できる。(MaterialYouの説明が良い例)
- Navigationを工夫しておけばスライドの追加・入れ替えもそれほど苦ではない。（私の実装はもう少し工夫の余地ありだと思いますが。）

一方で、如何ともし難い課題もあります。

- 自分の環境に完全にフィットしている形でアプリを実装するので、他の人で同じように動かせるとは限らない。
- 他の人に対する資料の共有方法が独特にならざるを得ない。
- というか、アプリ・ソースコードだけ共有しても発表内容を見返せない人がほとんどなので、画面のSSをまとめた資料が別途必要になるかも。
    - リポジトリにscreenshotsディレクトリとか追加してもよいが、結局そこにアクセスして見る人がどれくらいいるか。

# おわりに
そんなわけで、Jetpack Composeを使って変な発表をしてみた、という内容でした。
皆さんも、独自の発表方法を探ってみてはいかがでしょうか。

# 令和こそこそ噂話

9日目は@y-hirokiさんがAzureのVM関連の記事を書いてくださいます。

アドベントカレンダーは中盤に入りました。さらにギアを上げていきますので、ぜひこの後の当社の皆さんの記事をみてくださいね！