10
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-24

前置き

FUN Advent Calendar 2020 Part1、24日目の記事です。

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

概要

本記事では、少しは学内受けしそうな内容と弊学の高度ICT演習の宣伝(?)も兼ねて、自分自身が参加しているはこだて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を試すことができないわけではありません。ある程度の制約・条件はあるものの、各自お使いのAndroid Studioでも試すことができます。

本題

概要に書いたとおり、はこだてSweetsのAndroidアプリのレイアウトを作成します。
全画面の実装は時間的に厳しかったので、メイン画面のみのレイアウトにしました。
本当は、Playストアの画像とか貼れれば良かったのですが、諸々の事情によりリリースできていないので、下記に貼った画像を元にレイアウトを作っていきます。(リリースできていたら、リンク貼ったのに..)本文中では変数や引数ファイルや関数、componentという表記で区別しています。

使用ライブラリ

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

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
  • accompanist
    • Jetpack Compose上でURLなどから画像を表示させるライブラリで、Coil/Glide/Picassoの各種ライブラリが用意されている

レイアウト作成

以前の記事でも書いていた通り、Jetpack ComposeではsetContent内にレイアウトを書いていきます。

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

        setContent {
            SweetsApp(repository)
        }
    }
}

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

SweetsApp

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

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 
                        }
                    )
                }
            )
        }
    }
}

ここでは、currentBottomItemscreenWidthの変数宣言とScaffoldの呼び出しをしています。

currentBottomItem

currentBottomItemは、Bottom Navigationの選択されている場所を管理する変数になっています。rememberを使用することによって、currentBottomItemの値が更新されると、currentBottomItemを使用する部分のレイアウト更新が行われます。これを利用することによって、Bottom Navigationによる画面の切り替えを行っています。

screenWidth

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

Scaffold

作成したアプリでは、Scaffoldを使用し各種レイアウト用関数を呼び出しています。
Scaffoldは、引数を渡すことによって、アプリ開発の基本的なレイアウト構築してくれます。作成したアプリでは、topBarbodyContentbottomBarを引数に渡しています。
topBarには、SweetsAppBarbodyContentにはSweetsContentbottomBarにはSweetwAppBottomNavigationを引数に渡しています。各引数については、各章にて説明するため、ここでは省略します。

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

注意

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

SweetsAppBar

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

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

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

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

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

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はRowIconButtonを使用し、表示しています。
Rowを使用することによって、子レイアウトを水平方向に配置できるので、受け取ったiconsの要素分だけ子レイアウトにIconButtonを追加することによって実装しています。また、IconButtonにonClickを追加しクリック時の処理を追加したり、子レイアウトにIconを追加しiconを表示しています。

SweetsContent

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

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

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

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()を使用し、sweetsListScrollStateにてスクロール状態を管理しています。使い方については、SweetsScreenにて記述します。

SweetsScreen

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

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

scroll
スクロール状態の保持

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()を用いて監視し、その変数をSweetsScrollableColumn()に渡すことによって、画面にリストを表示しています。

SweetsScrollableColumn

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

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

screenWidth
端末画面のwidth

@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の高さ分だけスクロールが可能になります。また、ScrollableColumnscrollStateに、親レイアウトで作成したscrollを設定することによって、画面を切り替え戻った場合でも、スクロール位置を保持することができます。

SweetsGrid

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

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

rowSize
表示するGridの列数

@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にて、垂直方向にレイアウトを配置させるようにしたので、ここでは水平方向にレイアウトを配置させています。
まず、chunked()を使用します。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()を使用し、sweetsItemListrowSizeずつのリストに分けることによって、listChunked = [[SweetsListItem(..), SweetsListItem(..)], [SweetsListItem(..), SweetsListItem(..)]]のように各行で表示させるスイーツ情報リストができます。そのためこのlistChunkedを使い、1行ごとにSweetsGridItemRow内に実装することによって、水平方向へのレイアウト配置をしています。

SweetsGridItem

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

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

layoutWidth
作成する要素のWidth

elevation
設定する影のサイズ

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

@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ではないためこちらから詳細をご確認ください。
商品名・店舗名については、Textを使用しtextに表示するテキストを設定しています。また、fontSizeでテキストのサイズ変更、colorでテキストの色変更、maxLinesで最大行数の設定、overflowで文字数が超過する場合の省略設定をしています。

SweetwAppBottomNavigation

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

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

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を使用し、このBottomNavigationcontentにレイアウトを実装し、Bottom Navigationを表示しています。contentには、受け取ったbottomNavigationButtonsの要素分だけ、SweetsBottomNavigationItemを追加しています。ただし、表示されている画面の要素は色を変える必要があるので、bottomNavigationButton.namecurrentBottomItemを比較し、SweetsBottomNavigationItemを追加する際に引数として渡す色を変えることによって実装しています。

SweetsBottomNavigationItem

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

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

tint
要素(Icon、Text)に設定する色情報

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

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

完成

ここまでの実装で、下記画像のようなレイアウトを作ることができます。(もとのアプリ画像と多少違うところは見逃してください)多少の違いはあるものの、最低限レイアウト自体はできたと思っています。

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

まとめ

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

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

そういえば、明日がFUN Advent Calendar 2020 Part1の最終日です。明日は、はまですね。きっとトリにふさわしい内容を書いてくれるはず。では、最終日もお楽しみに!

参考サイト

Jetpack Compose Samples:https://github.com/android/compose-samples
State and Jetpack Compose:https://developer.android.com/jetpack/compose/state#viewmodel-and-jetpack-compose

10
4
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
4