Jetpack Composeを使った学内アプリのレイアウト作成

# 前置き
[FUN Advent Calendar 2020 Part1](https://adventar.org/calendars/5150)、24日目の記事です。

何か技術的な記事を書こうかなと思っていて、一応修士1年だし研究やら就活やら色々やることがあったので、去年のアドカレで扱っていたJetpack Composeを使って何かを試してみようという方針になりました。


# 概要
本記事では、少しは学内受けしそうな内容と弊学の[高度ICT演習](https://www.fun.ac.jp/advanced-ict#1-4)の宣伝（?）も兼ねて、自分自身が参加しているはこだてSweetsのAndroidアプリのレイアウト作成をJetpack Composeを使ってやってみようと思います。コード公開すると問題ありそうなので、レイアウトのみです。

ちなみに、去年のアドカレでも扱っていたJetpack Composeですが、2020年8月27日にようやくアルファ版がリリースされました🎉🎉🎉
2020年12月22日現在、最新バージョンは`1.0.0-alpha09`になっています。

# Android Studioバージョン
一応、この記事を書いた段階でのAndroid Studioバージョンを記しておきます。
**Android Studio Arctic Fox(2020.3.1) Canary 2**

※Canary版じゃないとJetpack Composeを試すことができないわけではありません。[ある程度の制約・条件](https://developer.android.com/jetpack/compose/setup)はあるものの、各自お使いのAndroid Studioでも試すことができます。

# 本題
概要に書いたとおり、はこだてSweetsのAndroidアプリのレイアウトを作成します。
全画面の実装は時間的に厳しかったので、メイン画面のみのレイアウトにしました。
本当は、Playストアの画像とか貼れれば良かったのですが、諸々の事情によりリリースできていないので、下記に貼った画像を元にレイアウトを作っていきます。<font color="Gray">（リリースできていたら、リンク貼ったのに..）</font>本文中では**変数や引数**、`ファイルや関数、component`という表記で区別しています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/225369/10cfa444-be28-1b41-941d-120bc73c5b55.png" width="300">

# 使用ライブラリ
レイアウトの作成に関係のあるライブラリ記述部分が以下になります。

```gradle:build.gradle(app)
def compose_version = "1.0.0-alpha09"
def coil_version = "0.4.0"

implementation "androidx.compose.foundation:foundation:$compose_version"
implementation "androidx.compose.foundation:foundation-layout:$compose_version"
implementation "androidx.compose.runtime:runtime:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation "androidx.compose.animation:animation:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"

implementation "dev.chrisbanes.accompanist:accompanist-coil:$coil_version"
```

各ライブラリのバージョンは本記事を書いた時点での最新バージョンになっています。
最新バージョンについてきになる方は、以下からご確認ください。
 
- [compose](https://developer.android.com/jetpack/androidx/versions/all-channel)
- [accompanist](https://github.com/chrisbanes/accompanist)
    - Jetpack Compose上でURLなどから画像を表示させるライブラリで、Coil/Glide/Picassoの各種ライブラリが用意されている

# レイアウト作成
[以前の記事](https://qiita.com/kota_2402/items/7bbdd87be8024785e25b)でも書いていた通り、Jetpack Composeでは`setContent`内にレイアウトを書いていきます。

```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            SweetsApp(repository)
        }
    }
}

```
今回は、`SweetsApp`という関数内にレイアウトを構築していきます。引数の**repository**は、APIやRoom周りの処理を管理しているクラスインスタンスなので、その中身等は省略しています。

# SweetsApp
SweetsAppは、作成したアプリ内で表示させる大元の画面になっています。

```kotlin:SweetsApp.kt
@Composable
fun SweetsApp(
    repository: Repository
) {
    var currentBottomItem by remember { mutableStateOf(Screen.SWEETS.name)  }
    val screenWidth = AmbientConfiguration.current.screenWidthDp.dp

    SweetsApp_JetpackComposeTheme {
        Surface(color = MaterialTheme.colors.background) {
            val screenWidth = AmbientConfiguration.current.screenWidthDp.dp
            Scaffold(
                topBar = {
                    when(currentBottomItem) {
                        Screen.SWEETS.name -> SweetsAppBar(
                                icons = listOf(
                                    Icons.Outlined.Info, 
                                    Icons.Outlined.Search
                                )
                        )
                        else -> SweetsAppBar()
                    }
                },
                bodyContent = {
                    SweetsAppContent(
                        modifier = Modifier
                            .padding(bottom = BottomNavigationHeight),
                        repository = repository,
                        screenWidth = screenWidth,
                        currentBottomItem = currentBottomItem
                    )
                },

                bottomBar = {
                    SweetsAppBottomNavigation(
                        bottomNavigationButtons = listOf(
                            BottomNavigationButton(
                                vectorResource(id = R.drawable.ic_cake),
                                "スイーツ",
                                Screen.SWEETS.name
                            ),
                            BottomNavigationButton(
                                Icons.Outlined.Place,
                                "マップ",
                                Screen.MAP.name),
                            BottomNavigationButton(
                                Icons.Outlined.FavoriteBorder,
                                "お気に入り",
                                Screen.FAVORITE.name),
                            BottomNavigationButton(
                                vectorResource(id = R.drawable.ic_coupon),
                                "クーポン",
                                Screen.COUPON.name
                            )
                        ),
                        currentBottomItem = currentBottomItem,
                        onClickBottomNavigation = { selectBottomItem ->
                            currentBottomItem = selectBottomItem 
                        }
                    )
                }
            )
        }
    }
}
```

ここでは、**currentBottomItem**・**screenWidth**の変数宣言と`Scaffold`の呼び出しをしています。

### currentBottomItem
**currentBottomItem**は、Bottom Navigationの選択されている場所を管理する変数になっています。[remember](https://developer.android.com/jetpack/compose/state?hl=ja#use-rememeber-to-create-internal-state-in-composable)を使用することによって、**currentBottomItem**の値が更新されると、**currentBottomItem**を使用する部分のレイアウト更新が行われます。これを利用することによって、Bottom Navigationによる画面の切り替えを行っています。


### screenWidth
**screenWidth**は、端末の画面幅を取得した値を保持しています。これを利用し、Grid表示の画像サイズ等を設定しています。


### Scaffold
作成したアプリでは、`Scaffold`を使用し各種レイアウト用関数を呼び出しています。
`Scaffold`は、引数を渡すことによって、アプリ開発の基本的なレイアウト構築してくれます。作成したアプリでは、**topBar**と**bodyContent**と**bottomBar**を引数に渡しています。
**topBar**には、[SweetsAppBar](#sweetsappbar)、**bodyContent**には[SweetsContent](#sweetscontent)、**bottomBar**には[SweetwAppBottomNavigation](#sweetwappbottomnavigation)を引数に渡しています。各引数については、各章にて説明するため、ここでは省略します。

`Scaffold`にはそのほかにも引数として渡すことができる（FAB等）ので、気になる方は調べてみてください。

##### 注意
後述するのですが、`Scaffold`に**topBar**と**bodyContent**と**bottomBar**を引数に渡すことによって、**topBar**と**bodyContent**の配置位置はちょうど良い感じになります（**topBar**の高さ分が**bodyContent**のTop Paddingに追加される）。しかし、**bodyContent**と**bottomBar**の配置位置は良い感じにならず、**bottomBar**の裏側まで**bodyContent**が表示されてしまいます。


# SweetsAppBar
`SweetsAppBar`では、`TopAppBar`を利用しApp barの実装をします。`TopAppBar`を利用することによって、従来のサイズと同様のAppBarを実装できます。

**引数の説明**
**modifier**
親のレイアウト設定（Padding等）を引き継ぐ用

**headerLogo**
AppBarの左側に表示するheader画像

**onHeaderLogoPressed**
headerLogoをクリックした際の処理

**icons**
App Barの右側に表示するアクションiconリスト

```kotlin:SweetsAppBar.kt
@Composable
fun SweetsAppBar(
    modifier: Modifier = Modifier,
    headerLogo: Int = R.drawable.header,
    onHeaderLogoPressed: () -> Unit = {},
    icons: List<ImageVector> = listOf()
) {
    TopAppBar(
        modifier = modifier,
        backgroundColor = primaryColor,
        elevation = 5.dp,
        contentColor = MaterialTheme.colors.onSurface
    ) {
        Image(
            imageVector = vectorResource(id = headerLogo),
            modifier = Modifier
                .padding(start = 12.dp)
                .clickable(onClick = onHeaderLogoPressed)
                .align(Alignment.CenterVertically)
        )

        Row(
            modifier = modifier.align(Alignment.CenterVertically)
        ) {
            for (icon in icons) {
                IconButton(
                    onClick = {  }
                ) {
                    Icon(
                        imageVector = icon,
                        tint = Color.White

                    )
                }
            }
        }
    }
}
```

作成するアプリのApp Barでは、header画像とアクションiconを表示しています。
header画像は`Image`を使用し、**imageVector**にheader画像を渡すことによって表示しています。
アクションiconは`Row`と`IconButton`を使用し、表示しています。
`Row`を使用することによって、子レイアウトを水平方向に配置できるので、受け取った**icons**の要素分だけ子レイアウトに`IconButton`を追加することによって実装しています。また、`IconButton`にonClickを追加しクリック時の処理を追加したり、子レイアウトに`Icon`を追加しiconを表示しています。


# SweetsContent
`SweetsContent`では、App BarとBottom Navigationの間に表示するレイアウトを実装します。

**引数の説明**
※子レイアウトに渡すだけの変数、前述した変数については説明していません
**screenWidth**
端末の画面幅

**currentBottomItem**
Bottom Navigationの選択されている画面名

```kotlin:SweetsContent.kt
@Composable
fun SweetsAppContent(
    modifier: Modifier = Modifier,
    repository: Repository,
    screenWidth: Dp,
    currentBottomItem: String
) {
    val sweetsListScrollState = rememberScrollState(0f)

    when(currentBottomItem) {
        Screen.SWEETS.name -> SweetsScreen(
            repository = repository,
            modifier = modifier,
            screenWidth = screenWidth,
            scroll = sweetsListScrollState
        )

        Screen.MAP.name -> MapScreen(
            repository = repository,
            modifier = modifier
        )

        Screen.FAVORITE.name -> FavoriteScreen(
            repository = repository,
            modifier = modifier
        )

        Screen.COUPON.name -> CouponScreen(
            repository = repository,
            modifier = modifier
        )
    }
}
```

ここでは、選択中の画面名**currentBottomItem**によって、表示する画面の切り替えを行っています。本記事では、`SweetsScreen`のみ後述します。

###  sweetsListScrollState
`SweetsScreen`の子レイアウトにて`ScrollableColumn`を使用するのですが、画面を切り替え、戻った際に元のスクロール状態を保持しておく必要があります。そこで、[rememberScrollState()](https://developer.android.com/reference/kotlin/androidx/compose/foundation/ScrollState)を使用し、**sweetsListScrollState**にてスクロール状態を管理しています。使い方については、[SweetsScreen](#sweetsscreen)にて記述します。

## SweetsScreen
ViewModel内の処理を行い、表示させるスイーツを取得、それを用いて画面への表示を行うようにします。

**引数の説明**
※子レイアウトに渡すだけの変数、前述した変数については説明していません
**repository**
APIやRoom周りの処理を行うクラスインスタンス

**scroll**
スクロール状態の保持

```kotlin:SweetsScreen.kt
@Composable
fun SweetsScreen(
    repository: Repository,
    modifier: Modifier,
    screenWidth: Dp,
    scroll: ScrollState
) {
    val viewModel: SweetsViewModel = viewModel(factory = SweetsViewModel.Factory(repository))
    val sweetsItemList: List<SweetsListItem> by viewModel.sweetsItemList.observeAsState(listOf())

    viewModel.getSweetsItemList()

    SweetsScrollableColumn(
        modifier = modifier,
        scroll = scroll,
        sweetsItemList = sweetsItemList,
        screenWidth = screenWidth,
        onItemClicked = viewModel::onItemClicked

    )
}
```

ここでは、まず`SweetsViewModel`のインスタンスを生成しています。
そして、`viewModel.getSweetsItemList()`を実行し、リストに表示させるスイーツ情報を取得、`SweetsViewModel`内のLivedata型である**sweetsItemList**に保存しています。その変更を[observeAsState()](https://developer.android.com/jetpack/compose/state#viewmodel-and-jetpack-compose)を用いて監視し、その変数を[SweetsScrollableColumn()](#sweetsscrollablecolumn)に渡すことによって、画面にリストを表示しています。

## SweetsScrollableColumn
スイーツリストを表示させ、そのリストをスクロールできるレイアウトを実装します。

**引数の説明**
※子レイアウトに渡すだけの変数、前述した変数については説明していません
**scroll**
スクロール状態の管理用

**screenWidth**
端末画面のwidth

```kotlin
@Composable
fun SweetsScrollableColumn(
    modifier: Modifier = Modifier,
    scroll: ScrollState = rememberScrollState(0f),
    sweetsItemList: List<SweetsListItem>,
    screenWidth: Dp,
    onItemClicked: (SweetsListItem) -> Unit = {}
) {
    ScrollableColumn(
        modifier = modifier,
        scrollState = scroll
    ) {
        SweetsGrid(
            modifier = Modifier,
            sweetsItemList = sweetsItemList,
            layoutWidth = screenWidth / 2,
            elevation = 5.dp,
            onItemClicked = onItemClicked
        )
    }
}
```

ここでは、レイアウトの垂直方向への配置を行っています。
`Column`を使用することによって、垂直方向への配置を行うことができますが、スクロールを行うことができません。そこで、スクロールが可能で垂直方向への配置を行う`ScrollableColumn`を使用します。この`ScrollableColumn`内に、[SweetsGrid](#sweetsgrid)を実装することによって、`SweetsGrid`の高さ分だけスクロールが可能になります。また、`ScrollableColumn`の**scrollState**に、親レイアウトで作成した**scroll**を設定することによって、画面を切り替え戻った場合でも、スクロール位置を保持することができます。


## SweetsGrid
スイーツ情報をGrid上に表示するレイアウトを実装します。

**引数の説明**
※子レイアウトに渡すだけの変数、前述した変数については説明していません
**sweetsItemList**
Grid表示するスイーツ情報（表品名、店舗名、画像URL）のリスト

**rowSize**
表示するGridの列数


```kotlin
@Composable
fun SweetsGrid(
    modifier: Modifier = Modifier,
    sweetsItemList: List<SweetsListItem>,
    layoutWidth: Dp,
    elevation: Dp,
    rowSize: Int = 2,
    onItemClicked: (SweetsListItem) -> Unit = {}
    ) {

    val listChunked = sweetsItemList.chunked(rowSize)

    for (sweetsRowList in listChunked) {
        Row(modifier = modifier) {
            for (sweets in sweetsRowList) {
                SweetsGridItem(
                    sweets = sweets,
                    layoutWidth = layoutWidth,
                    elevation = elevation,
                    onItemClicked = onItemClicked
                )
            }
        }
    }
}
```

[SweetsScrollableColumn](#sweetsscrollablecolumn)にて、垂直方向にレイアウトを配置させるようにしたので、ここでは水平方向にレイアウトを配置させています。
まず、[chunked()](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/chunked.html)を使用します。chunked実行例は以下のようになります。
>val words = "one two three four five six seven eight nine ten".split(' ')
val chunks = words.chunked(3)
println(chunks) // [[one, two, three], [four, five, six], [seven, eight, nine], [ten]]

chunked()を使用し、**sweetsItemList**を**rowSize**ずつのリストに分けることによって、```listChunked = [[SweetsListItem(..), SweetsListItem(..)], [SweetsListItem(..), SweetsListItem(..)]]```のように各行で表示させるスイーツ情報リストができます。そのためこの**listChunked**を使い、1行ごとに[SweetsGridItem](#sweetsgriditem)を`Row`内に実装することによって、水平方向へのレイアウト配置をしています。


## SweetsGridItem
表示させるスイーツリストの1要素のレイアウトを作成します。

**引数の説明**
※前述した変数については説明していません
**sweets**
Grid表示するスイーツ情報（表品名、店舗名、画像URL）

**layoutWidth**
作成する要素のWidth

**elevation**
設定する影のサイズ

**onItemClicked**
要素をクリックした時の処理

```kotlin
@Composable
fun SweetsGridItem(
    modifier: Modifier = Modifier,
    sweets: SweetsListItem,
    layoutWidth: Dp,
    elevation: Dp,
    onItemClicked: (SweetsListItem) -> Unit = {}
) {
    Column(
        modifier.background(Color.White)
            .width(layoutWidth)
            .padding(8.dp)
            .clickable(onClick = { onItemClicked(sweets) }),
    ) {
       Card(
           shape = shapes.large,
           elevation = elevation
       ) {
           CoilImage(
               data = sweets.imagePath,
               contentScale = ContentScale.Crop,
               modifier = modifier.size(layoutWidth - elevation*2)
           )
       }

        Text(
            modifier = modifier.padding(
                top = 4.dp,
                start = 8.dp,
                end = 8.dp
            ),
            text = sweets.sweetsName,
            fontSize = 12.sp,
            color = Color.Black,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )

        Text(
            modifier = modifier.padding(
                horizontal = 8.dp
            ),
            text = sweets.shopsName,
            fontSize = 10.sp,
            color = Color.LightGray,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}
```

ここでは、1要素のレイアウトを作成しています。
作成するアプリ画像より、商品画像・商品名・店舗名が垂直方向に配置されているので、`Column`の中でレイアウトを実装しています。商品画像については、`Card`に影を付け、その中で`CoilImage`を使用し表示しています。`CoilImage`は、`data`に画像URLを渡すだけで画像を表示してくれます（Coil等が対応していないため、すごく助かりました）。`CoilImage`は、Jetpack Composeではないため[こちら](https://github.com/chrisbanes/accompanist/tree/main/coil)から詳細をご確認ください。
商品名・店舗名については、`Text`を使用し**text**に表示するテキストを設定しています。また、**fontSize**でテキストのサイズ変更、**color**でテキストの色変更、**maxLines**で最大行数の設定、**overflow**で文字数が超過する場合の省略設定をしています。

# SweetwAppBottomNavigation
`SweetwAppBottomNavigation`では、Bottom Navigation内に表示するレイアウトを実装します。

**引数の説明**
※子レイアウトに渡すだけの変数、前述した変数については説明していません
**bottomNavigationButtons**
Bottom Navigationに表示する、ボタン情報（icon、表示テキスト、表示する画面名）のリストを受け取る用

```kotlin:SweetwAppBottomNavigation.kt
@Composable
fun SweetsAppBottomNavigation(
    modifier: Modifier = Modifier,
    bottomNavigationButtons: List<BottomNavigationButton> = listOf(),
    currentBottomItem: String,
    onClickBottomNavigation: (String) -> Unit = {}
) {
    BottomNavigation(
        modifier = modifier,
        backgroundColor = Color.White,
        content = {
            for (bottomNavigationButton in bottomNavigationButtons) {
                val tint = if (bottomNavigationButton.name == currentBottomItem) {
                    primaryColor
                } else {
                    Color.Gray
                }
                SweetsBottomNavigationItem(
                    modifier = modifier,
                    bottomNavigationButton = bottomNavigationButton,
                    tint = tint,
                    onClickBottomNavigation = onClickBottomNavigation
                )
            }
        }
    )
}
```

作成したアプリでは、`BottomNavigation`を使用し、この`BottomNavigation`の**content**にレイアウトを実装し、Bottom Navigationを表示しています。**content**には、受け取った**bottomNavigationButtons**の要素分だけ、`SweetsBottomNavigationItem`を追加しています。ただし、表示されている画面の要素は色を変える必要があるので、**bottomNavigationButton.name**と**currentBottomItem**を比較し、`SweetsBottomNavigationItem`を追加する際に引数として渡す色を変えることによって実装しています。

### SweetsBottomNavigationItem
Bottom Navigationの1要素のレイアウトを実装します

**引数の説明**
※前述した変数については説明していません
**bottomNavigationButton**
Bottom Navigationに表示する、ボタン情報（icon、表示テキスト、表示する画面名）を受け取る用

**tint**
要素（Icon、Text）に設定する色情報

**onClickBottomNavigation**
Bottom Navigationが新たに押された時の処理で戻り値として、画面名を返却

```kotlin
@Composable
fun SweetsBottomNavigationItem(
    modifier: Modifier = Modifier,
    bottomNavigationButton: BottomNavigationButton,
    tint: Color = Color.Gray,
    onClickBottomNavigation: (String) -> Unit = {}
){
    BottomNavigationItem(
        icon = {
            Column(
                modifier = modifier
            ) {
                Icon(
                    imageVector = bottomNavigationButton.icon,
                    tint = tint,
                    modifier = modifier
                        .align(Alignment.CenterHorizontally)
                        .size(24.dp)
                )
                Text(
                    text = bottomNavigationButton.text,
                    color = tint,
                    modifier = modifier.align(Alignment.CenterHorizontally),
                    fontSize = 14.sp
                )
            }
        },
        selected = true,
        onClick = {
            onClickBottomNavigation(bottomNavigationButton.name)
        }
    )
}
```

`SweetsBottomNavigationItem`では、`BottomNavigationItem`を使用し各要素のレイアウトを実装しています。`BottomNavigationItem`を使用することによって、従来と同様のリップルが表示されるようになります。この`BottomNavigationItem`の**icon**内に各要素を実装します。作成するアプリの各要素では、アイコンの下にテキストを表示させています。そのため、子レイアウトを垂直方向に配置できる`Column`を使用し、子レイアウトに受け取ったicon画像を表示させる`Icon`と受け取ったiconテキストを表示させる`Text`を実装しています。その際、`Icon`に`Text`受け取った**tint**を設定しています。
`BottomNavigationItem`の**onClick**内で、画面名（bottomNavigationButton.name）を引数に**onClickBottomNavigation**を実行し、[SweetsApp](#sweetsapp)内の**currentBottomItem**が更新されることによって、Bottom Navigationによる画面の切り替えを実装しています。


# 完成
ここまでの実装で、下記画像のようなレイアウトを作ることができます。（もとのアプリ画像と多少違うところは見逃してください）多少の違いはあるものの、最低限レイアウト自体はできたと思っています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/225369/b9a99108-a84a-9334-f238-f2147bcd643b.png" width="300">

多少の違いはあるものの、最低限レイアウト自体はできたと思っています。本記事では、`MapScreen`などに触れていないのですが、各関数を作成さえすれば、Bottom Navigationで画面切り替えを試すことができるかなと思います。今回は基本的にクリック時の処理を書いていないので、実際にはその辺りの処理を書く必要が出てくるかなと思います。

# まとめ
本記事では、はこだてSweetsのAndroidアプリのレイアウトを作成しました。やってみると自由度が高い分、好きに実装できるので実装していてとても楽しかったです。自分は以前SwiftUIに触れていた時期があったので、考え方などがほぼ同じだったりするので、その時の知識が活きた場面が多々ありました。なので、SwiftUIとJetpack Composeのどちらかさえ触っていれば、もう片方を触れるときにかなり実装がスムーズになるのではと思いました。

はこだてSweetsのAndroidアプリでは、直近でJetpack Composeを使用して開発する話は出てきていないので、今回は完全に個人で試してみたって感じになっています。どこかで、がっつりJetpack Compose使ったプロダクトとか参加してみたい気持ちになれました。

そういえば、明日が[FUN Advent Calendar 2020 Part1](https://adventar.org/calendars/5150)の最終日です。明日は、はまですね。きっと**トリ**にふさわしい内容を書いてくれるはず。では、最終日もお楽しみに！

# 参考サイト
Jetpack Compose Samples：https://github.com/android/compose-samples
State and Jetpack Compose：https://developer.android.com/jetpack/compose/state#viewmodel-and-jetpack-compose