前置き
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
という表記で区別しています。
使用ライブラリ
レイアウトの作成に関係のあるライブラリ記述部分が以下になります。
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
内にレイアウトを書いていきます。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SweetsApp(repository)
}
}
}
今回は、SweetsApp
という関数内にレイアウトを構築していきます。引数のrepositoryは、APIやRoom周りの処理を管理しているクラスインスタンスなので、その中身等は省略しています。
SweetsApp
SweetsAppは、作成したアプリ内で表示させる大元の画面になっています。
@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を使用することによって、currentBottomItemの値が更新されると、currentBottomItemを使用する部分のレイアウト更新が行われます。これを利用することによって、Bottom Navigationによる画面の切り替えを行っています。
screenWidth
screenWidthは、端末の画面幅を取得した値を保持しています。これを利用し、Grid表示の画像サイズ等を設定しています。
Scaffold
作成したアプリでは、Scaffold
を使用し各種レイアウト用関数を呼び出しています。
Scaffold
は、引数を渡すことによって、アプリ開発の基本的なレイアウト構築してくれます。作成したアプリでは、topBarとbodyContentとbottomBarを引数に渡しています。
topBarには、SweetsAppBar、bodyContentにはSweetsContent、bottomBarには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リスト
@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の選択されている画面名
@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
スクロール状態の保持
@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
の高さ分だけスクロールが可能になります。また、ScrollableColumn
のscrollStateに、親レイアウトで作成した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()を使用し、sweetsItemListをrowSizeずつのリストに分けることによって、listChunked = [[SweetsListItem(..), SweetsListItem(..)], [SweetsListItem(..), SweetsListItem(..)]]
のように各行で表示させるスイーツ情報リストができます。そのためこのlistChunkedを使い、1行ごとにSweetsGridItemをRow
内に実装することによって、水平方向へのレイアウト配置をしています。
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、表示テキスト、表示する画面名)のリストを受け取る用
@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が新たに押された時の処理で戻り値として、画面名を返却
@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内の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