様々な要素を組み合わせた一覧画面の構成
次の図のように、グリッドやリストを組み合わせた画面を作ることを考えます。
RecyclerView を使う場合、Groupie を使うとかなり楽にこの画面構成を実現可能です。 Jetpack Compose を使う場合、1.1.0-beta03 から LazyVerticalGrid の機能が充実したことで、Groupie と同じような画面構成が作れるようになりました。
この記事では、先の図に示したグリッドとリストを組み合わせた画面を Groupie と Jetpack Compose のそれぞれで実現する方法を解説します。
前提とするアーキテクチャ
AAC ViewModel で画面の状態を表すオブジェクトを管理し、Activity や Fragment でその変更を監視して表示を更新するようなアーキテクチャを想定します。
この例では StateFlow
を使いますが、LiveData
に読み替えてもよいでしょう。
data class SampleState( val gridData: List<String>, val listData: List<String>, ) class SampleViewModel : ViewModel() { private val stateMutation: MutableStateFlow<SampleState> = MutableStateFlow( SampleState( gridData = listOf("Foo", "Bar", "Baz", "Qux", "Quux", "Foobar", "Corge", "Grault", "Garply"), listData = listOf("Hoge", "Fuga", "Piyo", "Hogera"), ) ) val states: StateFlow<SampleState> = stateMutation.asStateFlow() }
Groupie で実現する手順
ViewBinding *1 を使いつつ、次のような画面を SampleActivity
のレイアウトとして定義します。
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" > <androidx.recyclerview.widget.RecyclerView android:id="@+id/mainContentView" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
次に RecyclerView
の Adapter
を定義します。Groupie が提供している GroupieAdapter
などを使います。
class SampleAdapter : GroupieAdapter()
RecyclerView
には GridLayoutManager
を設定します。グリッドの列数を柔軟に変えるため、先ほど作った SampleAdapter
の spanSizeLookup
を GridLayoutManager
に渡します。
今回は最大列数を 3 列に設定しています。
// グリッドの列数 const val SPAN_COUNT = 3 class SampleActivity : AppCompatActivity(R.layout.activity_sample) { private val binding: ActivitySampleBinding by viewBinding(ActivitySampleBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { // ... val sampleAdapter: SampleAdapter = SampleAdapter() binding.mainContentView.layoutManager = GridLayoutManager(this, SPAN_COUNT).apply { spanSizeLookup = sampleAdapter.spanSizeLookup } // ... } }
あとは、ViewModel が通知する状態オブジェクトを監視し、状態オブジェクトから View を生成するオブジェクトを作り SampleAdapter
に渡して表示を更新するだけです。
SampleAdapter
には次のようなデータクラスのインスタンスを渡します。これで1つのデータに対応した View が作成できます。それぞれ、文字列を表示する TextView
が一つあるレイアウトを定義しているものとします。
sealed class MainContent<V : ViewBinding> : BindableItem<V> data class GridItem( val text: String, ) : MainContent<ViewGridItemBinding>() { override fun bind(viewBinding: ViewGridItemBinding, position: Int) { viewBinding.label.text = text } override fun getLayout(): Int = R.layout.view_grid_item override fun initializeViewBinding(view: View): ViewGridItemBinding = ViewGridItemBinding.bind(view) override fun getSpanSize(spanCount: Int, position: Int): Int = 1 // 3 (SPAN_COUNT) 列のうち1列をつかって GridItem を表示する } data class ListItem( val text: String, ) : MainContent<ViewListItemBinding>() { override fun bind(viewBinding: ViewListItemBinding, position: Int) { viewBinding.label.text = text } override fun getLayout(): Int = R.layout.view_list_item override fun initializeViewBinding(view: View): ViewListItemBinding = ViewListItemBinding.bind(view) override fun getSpanSize(spanCount: Int, position: Int): Int = SPAN_COUNT // 3 (SPAN_COUNT) 列のうち 3 列をつかって ListItem を表示する }
状態オブジェクトにはグリッド表示するデータとリスト表示するデータが別々のプロパティとして定義してあります。これらを上記の GridItem
または ListItem
に変換し 1 つの List<MainContent>
にマージずれば、SampleAdapter
の更新ができます。
class SampleActivity : AppCompatActivity(R.layout.activity_sample) { private val viewModel: SampleViewModel by viewModels() private val binding: ActivitySampleBinding by viewBinding(ActivitySampleBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { // ... val sampleAdapter: SampleAdapter = SampleAdapter() lifecycleScope.launch { viewModel.states.collect { state: SampleState -> // 状態オブジェクトの監視 // 状態オブジェクトから Groupie の BindableItem を生成し 1 つの List にまとめ、Adapter に与える adapter.update(state.convertToMainContentItems()) } } } } fun SampleState.convertToMainContentItems(): List<MainContent<*>> = gridData.map { data -> GridItem(data) } + listData.map { data -> ListItem(data) }
Jetpack Compose で実現する手順
Jetpack Compose では LazyVerticalGrid
コンポーザブルを使って縦方向にスクロール可能なグリッド表示を作ります。
グリッドとリストの表示を組み合わせる場合、Jetpack Compose のバージョンは 1.1.0-beta03
以上である必要があります。
ただし Jetpack Compose のバージョンと Kotlin のバージョンの組み合わせによってはコンパイルエラーとなって利用できない組み合わせがあることに注意します*2。
Jetpack Compose を使う場合は Groupie の場合に比べて準備するものが圧倒的に少なくなります。必要なものは LazyVerticalGrid
と、状態オブジェクトが持っているグリッド表示するデータとリスト表示するデータを対応するコンポーザブルに変換する記述だけです。
Groupie と異なり、グリッド表示用のデータとリスト表示用のデータは別々に扱うことができます。
// グリッドの列数 const val SPAN_COUNT = 3 class SampleActivity : ComponentActivity() { private val viewModel: SampleViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { // ... setContent { SampleScreen(viewModel) } } @Composable fun SampleScreen(viewModel: SampleViewModel) { ConstraintLayout { val states = viewModel.states.collectAsState() // 状態オブジェクトの監視 val gridItem = states.value.gridData val listItem = states.value.listData val gridRef = createRef() SampleVerticalGrid( gridItem = gridItem, listItem = listItem, modifier = Modifier.constrainAs(gridRef) { start.linkTo(parent.start) end.linkTo(parent.end) top.linkTo(parent.top) bottom.linkTo(parent.bottom) } ) } } @Composable fun SampleVerticalGrid( gridItem: List<String>, listItem: List<String>, modifier: Modifier = Modifier, ) { LazyVerticalGrid( cells = GridCells.Fixed(count = SPAN_COUNT), modifier = modifier, ) { items(items = gridItem) { text -> Text(text = text) } items(items = listItem, spans = { GridItemSpan(SPAN_COUNT) }) { text -> Text(text = text) } } } }
より複雑な UI の構築
今回は状態オブジェクトがシンプルで文字列のリストしか保持していませんでしたが、実際にはもっと複雑なデータを取り扱うはずです。 リストの一部だけを表示するようなケースでは、次のようなデータ型を定義し状態オブジェクトから取り出すときに表示するデータだけを抽出すれば、Groupie でも Jetpack Compose でも UI のロジックとして表示・非表示を切り替えるロジックは不要です。 また状態オブジェクトを作り込んでおくことで、どういう状態のときにどういう表示用のデータが取得できればよいかをユニットテストで検証可能になります。
data class Data( val text: String, val isVisible: Boolean ) data class SampleState( val gridData: List<Data>, val listData: List<Data>, ) { fun getVisibleGridData(): List<String> = gridData.filter { data -> data.isVisible }.map { data -> data.text } fun getVisibleListData(): List<String> = listData.filter { data -> data.isVisible }.map { data -> data.text } }
*1:https://github.com/wada811/ViewBinding-ktx
*2:Jetpack Compose Compiler の 1.1.0-beta03 では Kotlin 1.5.31 までが要求されるのに対し、1.1.0-beta04 以降は Kotlin 1.6.0 が要求されます。また 1.1.0-rc02 では Kotlin 1.6.10 が要求されます。コンパイラのエラーを無視するオプションを有効化しても、Kotlin 1.5.x を利用する状態で 1.1.0-beta04 以降を使おうとすると、Jetpack Compose Compiler に必要な API 郡がなくコンパイラが動きません。