くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

RecyclerViewのGridLayoutについて

GridLayoutはリストを格子状に並べるという形式です

以前RecyclerViewについての記事を投稿した際にLayoutManagerにGridLayoutManagerというパターンがあるというのを記載しています

kumaskun.hatenablog.com

今回はGridLayoutManagerについて少し深掘りしようと思います

全てのセルが同じViewHolderで構成されているパターン

まずは全てのセルが同じViewHolderで構成されるパターンです

基本的にLinerLayoutManagerと作り方は同じでAdapterを作成してRecyclerViewと紐付けをします

ただ、GridLayoutManagerは紐付け方が異なるのでそこにフォーカスを当てて説明します

Adapterやセル(アイテム)のレイアウトは他のリスト表示の時と変わらないので下記まとめています

class HomeAdapter(private val countList: List<Int>): RecyclerView.Adapter<HomeAdapter.CountListRecyclerViewHolder>() {
    private lateinit var listener: HomeAdapterListener
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeAdapter.CountListRecyclerViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val view = inflater.inflate(R.layout.grid_list, parent, false)
        return CountListRecyclerViewHolder(view)
    }

    override fun getItemCount(): Int {
        return countList.size
    }

    override fun onBindViewHolder(holder: CountListRecyclerViewHolder, position: Int) {
        holder.countText.text = countList[position].toString()

        holder.itemView.setOnClickListener {
            listener.contentTapped(position)
        }
    }

    class CountListRecyclerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var countText: TextView = itemView.findViewById(R.id.gridTextView)
    }

    fun setListener(listener: HomeAdapterListener) {
        this.listener = listener
    }

    interface HomeAdapterListener {
        fun contentTapped(position: Int)
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    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="wrap_content"
    app:cardCornerRadius="6dp"
    app:cardElevation="6dp"
    app:contentPadding="8dp"
    app:cardUseCompatPadding="true">
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <View
            android:id="@+id/image_view"
            android:layout_width="match_parent"
            android:layout_height="160dp"/>

        <TextView
            android:id="@+id/gridTextView"
            android:textSize="20sp"
            android:textColor="@android:color/black"
            android:textAlignment="center"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </LinearLayout>
</androidx.cardview.widget.CardView>

異なるのはLayoutManagerです。格子状に並べるためにはGridLayoutManagerを使用します

val countList = listOf(1, 2, 3, 4, 5, 6, 7, 8)
val adapter = HomeAdapter(countList)
homeRecyclerView.adapter = adapter
homeRecyclerView.layoutManager = GridLayoutManager(
    requireContext(),
    2,
    RecyclerView.VERTICAL,
    false
)

adapter.setListener(object: HomeAdapter.HomeAdapterListener {
    override fun contentTapped(position: Int) {
        // セルをタップした時の処理
    }
})

今回のGridLayoutManagerで横に2つずつ並べるようなリスト表示の設定を行っています(GridLayoutManagerの初期化の引数の2つ目でspanCountを指定します)

ビルドしてみると次のような表示になります

セルのマージンを調整する

GridLayoutは基本的にはセル間のマージンがありません。マージンを追加する場合には例えばセルの右側に10dpマージンを追加するなどの方法が考えられます

しかし、その方法だと全てのセルに同じマージンが追加されることになります。特定のセルのみマージンを変更したい場合には別の方法で対応する必要があります

今回はItemDecorationを使う方法で対応します

ItemDecorationクラスはアイテムの装飾を行います。例えば、区切り線や今回のようにマージンを追加したりできます

ItemDecorationを継承したカスタムクラスを作成して、RecyclerViewに追加します

class CustomGridItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
    private val margin = 120

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val params = view.layoutParams as? GridLayoutManager.LayoutParams
        val manager = parent.layoutManager as? GridLayoutManager
        val spanIndex = params?.spanIndex ?: 0
        val spanSize = params?.spanSize ?: 1
        val spanCount = manager?.spanCount ?: 1

        // セルのindex
        val itemPosition = parent.getChildAdapterPosition(view)

        // セルのindexが2番目以下
        val isTop = itemPosition < 2

        val isStart = spanIndex == 0
        val isEnd = spanIndex + spanSize == spanCount

        // 今回はviewHolderに応じて処理を行っているが、1つのviewHolderで1つのクラスを作成しても問題ない
        when (parent.findContainingViewHolder(view)) {
            is HomeAdapter.CountListRecyclerViewHolder -> {
                // outRect.set(leftのマージン, topのマージン, rightのマージン, bottomのマージン)
                outRect.set(0, if (isTop) margin else 0, 0, 0)
            }
        }
    }
}

今回は0番目と1番目の上部に120のマージンを表示させるようにしました(getItemOffsetsはアイテムの上下左右に空間を設ける処理を行います)

ItemDecorationを継承したカスタムクラスを作成したら次のようにRecyclerViewに追加します

homeRecyclerView.addItemDecoration(CustomGridItemDecoration(requireContext()))

これで次のように特定のセルにマージンを追加できます

1列に表示させる数を変更する

先程までは1列に2つ表示させるという規則的なリストを作成しました

ただ、1列目は1つ、2列目は2つというように不規則に表示させたい場合もあると思います

ここでは不規則に表示させるパターンについて説明しようと思います

1列に表示させる数を設定するためにはGridLayoutManagerクラスのSpanSizeLookupを使います。 SpanSizeLookupのgetSpanSizeで列に何個表示させるかを指定します

val layoutManager = GridLayoutManager(requireContext(), 2).apply {
    spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int {
   // 2番目のセルは2つ分のサイズを使用して、それ以外は1つ分(半分)のサイズを使用する
            if (position == 2) {
                return 2
            }
            return 1
        }
    }
}

homeRecyclerView.layoutManager = layoutManager

今回の場合にはGridLayoutManager(requireContext(), 2)で1列に2つ表示するように設定した上で2番目は2つ表示させるサイズを1つのセルに使い、 それ以外は1個分のサイズつまり画面の半分のサイズでセルを表示させるようにspanSizeLookupを設定しています

ビルドすると次のようになります

こうすることで不規則な個数を表示させることができますが、if (position == 2)のようにpositionを固定できない場合にはどうしますか?むしろ、固定できない場合が多いと思います

そのような場合にはViewTypeで区別することができます

まずAdapterでは他のAdapterとは重複しないViewTypeを指定します。 Int型を指定しなければいけないのでレイアウトIDを指定することが多い気がしますが、重複しなければいいです

override fun getItemViewType(position: Int): Int {
    return R.layout.grid_list
}

そしてadapterのViewTypeを元にしてアイテムのサイズを指定します。 この場合には条件分岐をposition固定にしなくていい反面、Adapterを複数作る必要があります

val layoutManager = GridLayoutManager(requireContext(), 2).apply {
    spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int {
            return when (adapter.getItemViewType(position)) {
                R.layout.grid_list -> 1 
            }
        }
    }
}

ItemDecorationの補足

ここで少しItemDecorationについて補足します。ItemDecorationの大まかな概要は次のようになります

class CustomGridItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
    // アイテムの上下左右へ空白(透明)を設ける
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
    }

    // アイテムが描画される前に描画されるため、RecyclerViewの下に装飾を表示
    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDraw(c, parent, state)
    }

    // アイテムが描画された後に描画されるため、RecyclerViewの上に装飾を表示
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
    }
}

onDrawOveronDrawは次のイメージです

少し前のItemDecorationの解説でで区切り線を追加したりするという説明をしたと思います

区切り線を入れたい場合は、DividerItemDecorationという既成のItemDecorationがAPIに用意されています

val deco = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
homeRecyclerView.addItemDecoration(deco)

区切り線をカスタマイズしたい場合やRecyclerViewをカスタマイズしたい場合には、getItemOffsetでサイズを指定してonDrawもしくはonDrawOver(あるいは両方)で描画処理を行うというということを 意識して実装しましょう!

参照

qiita.com

star-zero.medium.com

star-zero.medium.com