Infinito Nirone 7

白羽の矢を刺すスタイル

グリッド表示とリスト表示を組み合わせた構造の画面を作る場合の Groupie と JetpackCompose の対比

様々な要素を組み合わせた一覧画面の構成

次の図のように、グリッドやリストを組み合わせた画面を作ることを考えます。

f:id:KeithYokoma:20220106173409j:plain
画面構成

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>

次に RecyclerViewAdapter を定義します。Groupie が提供している GroupieAdapter などを使います。

class SampleAdapter : GroupieAdapter()

RecyclerView には GridLayoutManager を設定します。グリッドの列数を柔軟に変えるため、先ほど作った SampleAdapterspanSizeLookupGridLayoutManager に渡します。 今回は最大列数を 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 郡がなくコンパイラが動きません。