はじめに

Google Nowには、次のようにリストを表示するカードがあります。

このようなカードを作ろうと思ったときに、ViewGroupにaddViewしたり、ListViewをスクロールさせなくしてカードにしてみたりとやり方はいくつかあると思うのですが、これをもっと簡単に作れたらいいのになぁと思い、カスタムビューを作ってみましたので簡単に紹介したいと思います。

実現したいこと

今回作るカスタムビューで実現したいのは次のとおりです。

  • リストアイテムのViewを生成・保持
  • リストアイテムの追加・変更・削除
  • リストアイテムの表示数を制限
  • リストアイテムの表示数を制限している場合、削除したら次の項目を表示する
  • カードのヘッダー、フッター

Viewの構成

今回作るカスタムビューは、次のような構成にします。

Viewの構成.png

まず、カードを作るのでCardViewで囲み、その上にRelativeLayoutを乗せます。
その上に乗っている上下にあるViewはヘッダーとフッターになる予定のものです。
ヘッダーとフッターはViewにしておくことで後で差し替えられるようにし、デフォルトではTextViewを表示します。
そしてメインとなるリストはLinearLayoutを利用します。

基礎となるViewを作る

先ほどのViewの構成どおりに、まずは基礎となるViewを作っていきます。

ListCardView.kt
class ListCardView : CardView {
    private val defaultPadding = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16f, resources.displayMetrics).toInt()

    private var rootView: RelativeLayout = RelativeLayout(context).apply {
        layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    }
    var headerView: View? = TextView(context).apply {
        id = R.id.header_view
        layoutParams = RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
        setPadding(defaultPadding, defaultPadding, defaultPadding, defaultPadding)
    }
    private var listView: LinearLayout = LinearLayout(context).apply {
        id = R.id.list_view
        orientation = LinearLayout.VERTICAL
        layoutParams = RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
            addRule(RelativeLayout.BELOW, R.id.header_view)
        }
    }
    var footerView: View? = TextView(context).apply {
        id = R.id.footer_view
        gravity = Gravity.END
        setPadding(defaultPadding, defaultPadding, defaultPadding, defaultPadding)
        layoutParams = RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
            addRule(RelativeLayout.BELOW, R.id.list_view)
        }
    }
}

headerViewとfooterViewは後から差し替えができるように公開しておき、それ以外は公開する必要はないので private にしています。
そして、これらのViewを順番に追加していきます。

ListCardView.kt
class ListCardView : CardView {
    constructor(context: Context?) : super(context) {
        initialize()
    }
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        initialize(attrs)
    }
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initialize(attrs)
    }

    private fun initialize(attrs: AttributeSet? = null) {
        rootView.addView(headerView)
        rootView.addView(listView)
        rootView.addView(footerView)
        addView(rootView)
    }
}

これは普段xmlでやっている作業をプログラムで書いているだけなので、特に難しいことはしていません。

Adapterを作る

ListViewやRecyclerViewなどと同じように、Adapterを使ってリストのアイテムを操作できるようにします。

ListCardView.kt
abstract class Adapter {
    private val observable = AdapterDataObservable()
    // リストの1つのアイテムのViewを生成
    abstract fun createView(position: Int, viewGroup: ViewGroup?): View
    // 生成したViewに対してバインドしてViewを返却する
    abstract fun bindView(position: Int, view: View): View
    // 生成したViewに表示する情報を返却する
    abstract fun getItem(position: Int): Any
    // 生成したViewのIdを返却する
    abstract fun getId(position: Int): Long
    // リストの個数を返却する
    abstract fun getCount(): Int
    // Viewの変更を反映する
    fun notifyDataSetChanged() {
        observable.notifyChanged()
    }
    // Viewの変更通知を受け取るObserverを登録する
    fun registerObserver(observer: AdapterDataObserver) {
        observable.registerObserver(observer)
    }
}

ListViewやRecyclerViewと同じような感覚で利用できるように、メソッド名はそれらで利用されているものに合わせて作っています。

リストアイテムのViewを生成・保持する

リストアイテムのViewを保持するための変数を用意します。

ListCardView.kt
private val listItemCache = LongSparseArray<View>()
private val dividerCache = LongSparseArray<View>()

リストアイテムの追加・削除などがしやすいようにリストアイテムとdividerを別々に保持しています。
次に、Adapterのセット時にAdapterを元にViewを生成・保持する処理を書きます。

ListCardView.kt
var adapter: Adapter? = null
    set(value) {
        value?.let {
            // キャッシュをクリア
            listItemCache.clear()
            dividerCache.clear()
            // アイテムを生成・保持
            for (position in 0 until it.getCount()) {
                listItemCache.put(it.getId(position), it.createView(position, this))
                dividerCache.put(position.toLong(), createDividerView())
            }
            dividerCache.put(listItemCache.size().toLong(), createDividerView())
        }
        field = value
    }

Adapterのセット時には既にセットされていたキャッシュがあるかもしれないので、まずはキャッシュを削除しておきます。
次に、リストのアイテム数分、Adapter#createView()を使ってViewを生成し、それをキャッシュしておきます。
dividerも同様にキャッシュをしますが、dividerはリストの個数よりも1つ余分に必要なため、for文の外で1つだけdividerを追加しています。

リストの変更をViewに反映する

変更通知処理を書くためのinterfaceと、実際に通知するためのObservableを作成します。

ListCardView.kt
private class AdapterDataObservable : Observable<AdapterDataObserver>() {
    fun notifyChanged() {
        for (i in mObservers.indices.reversed()) {
            mObservers[i].onChanged()
        }
    }
}

interface AdapterDataObserver {
    fun onChanged()
}

ここで言うObservableはRxJavaやDataBindingのObservableではなく、android.databaseのObservableです。
このObservableはRecyclerViewで利用されているものと同じです。

次に、実際にリストの変更をViewに反映していきます。

ListCardView.kt
fun onAdapterChanged(adapter: Adapter) {
    val diff = adapter.getCount() - listItemCache.size()
    if (diff < 0) {
        for (i in listItemCache.size() - 1 downTo (listItemCache.size() - Math.abs(diff))) {
            listItemCache.removeAt(i)
            dividerCache.removeAt(i)
        }
    } else if (diff > 0) {
        val startPosition = if (listItemCache.size() == 0) 0 else listItemCache.size()
        for (i in startPosition until listItemCache.size() + Math.abs(diff)) {
            listItemCache.put(adapter.getId(i), adapter.createView(i, this@ListCardView))
            dividerCache.put(i.toLong(), createDividerView())
        }
        dividerCache.put(listItemCache.size().toLong(), createDividerView())
    }
    listView.removeAllViews()
    for (i in 0 until listItemCache.size()) {
        listView.addView(dividerCache.valueAt(i))
        listView.addView(adapter.bindView(i, listItemCache.get(adapter.getId(i))))
    }
    listView.addView(dividerCache.valueAt(listItemCache.size()))
}

少し複雑なことをしていますが、ここでは次のような処理を行っています。

  1. adapterのアイテム個数とキャッシュの個数の差分をとる
  2. 差分がマイナスの場合、差分の個数分キャッシュから削除する
  3. 差分がプラスの場合、差分の個数分Viewをキャッシュする
  4. LinearLayoutから一旦全てのViewを削除
  5. dividerをLinearLayoutにaddViewする
  6. キャッシュからViewを取得し、Adapter#bindViewメソッドを通してViewにバインドした後にaddView
  7. LinearLayoutの一番下にdividerをaddView

これらの処理によって、リストアイテムの追加・削除・変更処理のすべてに対応しています。
もしリストアイテムの個数が多くなるのであればRecyclerViewのように一部のViewのみに反映するようなメソッドを用意する必要があるかと思いますが、今回のようなカードに表示する程度であれば、これで問題無いかと思います。

そして、先ほどのAdapterセット時の処理の後にViewの変更通知を処理を登録し、最後に変更を通知します。

ListCardView.kt
var adapter: Adapter? = null
    set(value) {
        value?.let {
            //..省略..

            it.registerObserver(object : AdapterDataObserver {
                override fun onChanged() {
                    onAdapterChanged(it)
                }
            })
            it.notifyDataSetChanged()
        }
        field = value
    }

これで、adapterをセットしたときとnotifyDataSetChangedメソッドを呼び出したときにAdapterの内容がViewに反映されるようになりました。

リストアイテムの表示数を制限する

まずは、制限数を保持する変数を用意します。

ListCardView.kt
private var listLimit = 0

AdapterからlistLimitを操作できるようにするため、Adapterにも変数を用意しておきます。
また、セット後すぐにViewに状態が反映されるようにするため、セット後にnotifyDataSetChanged()を実行します。

ListCardView.kt
abstract class Adapter {
    var listLimit: Int = 0
        set(value) {
            field = value
            notifyDataSetChanged()
        }
}

Adapterのセット時にはListCardViewに既に設定されている値をadapterに反映します。

ListCardView.kt
var adapter: Adapter? = null
    set(value) {
        value?.let {
            it.listLimit = listLimit

また、Adapterに変更通知が来た場合にはAdapterの内容をListCardViewのlistLimitに反映します。

ListCardView.kt
fun onAdapterChanged(adapter: Adapter) {
    listLimit = adapter.listLimit

このlistLimitが0の場合はキャッシュサイズをそのまま返却し、listLimitが設定されている場合はlistLimitを返却する拡張関数を作ります。

ListCardView.kt
private fun LongSparseArray<View>.limit(): Int {
    return if (listLimit == 0) {
        size()
    } else {
        if (listLimit < size()) {
            listLimit
        } else {
            size()
        }
    }
}

そして、Viewに反映する処理を行っているところの size()limit() に変えることで、listLimitが設定されている場合はその数だけViewを表示するようにします。

ListCardView.kt
for (i in 0 until listItemCache.limit()) {
    listView.addView(dividerCache.valueAt(i))
    listView.addView(adapter.bindView(i, listItemCache.get(adapter.getId(i))))
}

これで、リストアイテムの表示数を制限することと、リストアイテムが削除されたときは自動で次のアイテムを表示することのそれぞれのパターンに対応できました。

xmlから属性を変更できるようにする

これはカスタムビューを作る上での話なのでサクッと説明します。

まず、attrs.xmlにxmlで設定したい属性を定義します。

attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ListCardView">
        <attr name="headerText" format="string"/>
        <attr name="headerTextColor" format="color"/>
        <attr name="footerText" format="string"/>
        <attr name="footerTextColor" format="color"/>
        <attr name="android:divider"/>
        <attr name="listLimit" format="integer"/>
    </declare-styleable>
</resources>

そして、AttributeSetがセットされている場合はそこから取得してViewに反映します。

ListCardView.kt
private fun initialize(attrs: AttributeSet? = null) {
    attrs?.let {
        context.obtainStyledAttributes(attrs, R.styleable.ListCardView).apply {
            if (headerView is TextView) {
                (headerView as TextView).run {
                    text = getString(R.styleable.ListCardView_headerText)
                    setTextColor(getColor(R.styleable.ListCardView_headerTextColor, Color.BLACK))
                }
            }
            if (footerView is TextView) {
                (footerView as TextView).run {
                    text = getString(R.styleable.ListCardView_footerText)
                    setTextColor(getColor(R.styleable.ListCardView_footerTextColor, Color.BLACK))
                }
            }
            dividerColor = getColor(R.styleable.ListCardView_android_divider, Color.LTGRAY)
            listLimit = getInteger(R.styleable.ListCardView_listLimit, 0)
        }
    }
}

サンプルを作って実行してみる

簡単なAdapterを作ります。

SimpleAdapter.kt
class SimpleAdapter(
        private val inflater: LayoutInflater, private val list: List<String>,
        private val onItemClickListener: (position: Int, adapter: ListCardView.Adapter) -> Unit
) : ListCardView.Adapter() {
    override fun createView(position: Int, viewGroup: ViewGroup?): View {
        return inflater.inflate(android.R.layout.simple_list_item_1, viewGroup, false)
    }

    override fun bindView(position: Int, view: View): View {
        view.findViewById<TextView>(android.R.id.text1).apply {
            text = getItem(position).toString()
            setOnClickListener {
                onItemClickListener.invoke(position, this@SimpleAdapter)
            }
        }
        return view
    }

    override fun getItem(position: Int): Any {
        return list[position]
    }

    override fun getId(position: Int): Long {
        return position.toLong()
    }

    override fun getCount(): Int {
        return list.size
    }

}

Listを作ってAdapterにセットします。

val list = ArrayList<String>().apply {
    for (i in 0..5) {
        add("test$i")
    }
}
holder.binding.listCardView.adapter = SimpleAdapter(LayoutInflater.from(this@MainActivity), list, { pos, adapter ->
    list.removeAt(pos)
    adapter.notifyDataSetChanged()
})

あとはもろもろ必要なものを実装した結果のサンプルがこんな感じです。

見た目がすごく雑ですが、必要な機能はだいたいそろっているのであとは見た目やアニメーションを良い感じに整えればGoogle Nowのリストカードのようなものが出来るのではないかなと思います。

おわりに

AdapterにセットしたものをViewに反映するためにはどうすれば良いのか、Viewをどう再利用するのが良いのかなど悩みどころが非常に多くあり、まだまだ改善の余地があるかなとは思いますが、当初の目的であったRecyclerViewにリストカードを表示するViewを作ることは無事に達成できたので良かったです。
このCustomViewを作る上で、RecyclerViewの中の実装を読んで参考にしていたのですが、読んでみると難しいことには間違いないのですが全く読めないわけではなく勉強になる部分が結構多いので、とりあえず読んでみるのは大事だなと実感しました。

今回作ったサンプルはGitHubに公開しているので、良ければご参考ください。

syarihu/ListCardView
https://github.com/syarihu/ListCardView