【Kotlin】ListViewをカンタンなモデルで理解しよう【Androidアプリ開発】

【Kotlin】ListViewをカンタンなモデルで理解しよう【Androidアプリ開発】

ここではAndroidのListViewを、カンタンなモデルを使って実装してみる。本記事の完成イメージがこちら。

ゴール
ゴール

標準に搭載されているタイムゾーンの配列をTimeZone.getAvailableIDs()で取得して、一覧表示する単純な仕様となっている。APIなど複雑な処理はないのでリストビューの理解だけに集中できるように進めていく。

ListViewのレイアウトファイルを作る

配置したListViewの行のレイアウトファイルを作りたい場合は、ListViewをクリックして、listitemの項目の...をクリック。開いたダイアログの右上から、New layout Fileを選択して作成すると簡単だ。


こんな感じにレイアウトする。


リストの行のタップ領域高さは、48dp以上が推奨されているようだ。あまりに狭いと、ユーザビリティが悪くなるので気をつけよう。


基本のListView

BaseAdapterクラスを継承した、TimeZoneAdapterクラスを作成する。

class TimeZoneAdapter(private val context: Context,
                      private val timeZones: Array<String> = TimeZone.getAvailableIDs())
    : BaseAdapter() {


    private val inflater = LayoutInflater.from(context)

    // インデックスp0にある行のビューを返す
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val view = convertView ?: inflater.inflate(R.layout.list_time_zone_row, parent, false)
        val timeZoneId = getItem(position)
        val timeZone = TimeZone.getTimeZone(timeZoneId)

        val timeZoneLabel = view.findViewById<TextView>(R.id.timeZone)
        timeZoneLabel.text = "${timeZone.displayName}(${timeZone.id})"

        val clock = view.findViewById<TextClock>(R.id.clock)
        clock.timeZone = timeZone.id

        return view
    }

    // インデックスp0にあるデータを返す
    override fun getItem(position: Int) = timeZones[position]

    // 行を識別するためのユニーク値
    override fun getItemId(position: Int) = position.toLong()

    // リスト表示するデータ件数
    override fun getCount() = timeZones.size

}

getViewの部分が一番混乱するかもしれない。getViewは次のような働きをする。


getViewは、positionに指定された行のViewを返す。Viewが生成されていないならinflaterで生成し、すでに存在すれば、そのViewを使い回す。また、実際の値をリストに設定する処理もここで書く。

ViewHolderで速度改善

実はさきほどのプログラムは、次の部分に問題がある。

val timeZoneLabel = view.findViewById<TextView>(R.id.timeZone)

スクロールするたびに、findViewByIdを呼び出すのはコストがかかるのだ。つまりスクロール動作が遅くなる。そこで登場するのがViewHolderパターンだ。


Viewにはtagプロパティがあり、そこに任意のオブジェクトを1つ持つことができる。これを利用して、あらかじめtimeZoneLabelなどのインスタンスを生成して持たせておくのだ。convertViewはリサイクルされるので、tagに持たせたオブジェクトも再利用されるはずである。そして次のコードが、 ViewHolderパターンに書き換えたものだ。

class TimeZoneAdapter(private val context: Context,
                      private val timeZones: Array<String> = TimeZone.getAvailableIDs())
    : BaseAdapter() {


    private val inflater = LayoutInflater.from(context)

    // インデックスp0にある行のビューを返す
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val view = convertView ?: createView(parent)
        val timeZoneId = getItem(position)
        val timeZone = TimeZone.getTimeZone(timeZoneId)

        val viewHolder = view.tag as ViewHolder

        @SuppressLint("SetTextI18n")
        viewHolder.name.text = "${timeZone.displayName}(${timeZone.id})"
        viewHolder.clock.timeZone = timeZone.id

        return view
    }

    // インデックスp0にあるデータを返す
    override fun getItem(position: Int) = timeZones[position]

    // 行を識別するためのユニーク値
    override fun getItemId(position: Int) = position.toLong()

    // リスト表示するデータ件数
    override fun getCount() = timeZones.size


    private fun createView(parent: ViewGroup?) : View {
        val view = inflater.inflate(R.layout.list_time_zone_row, parent, false)
        view.tag = ViewHolder(view)
        return view
    }



    private class ViewHolder(view: View) {
        val name = view.findViewById<TextView>(R.id.timeZone)
        val clock = view.findViewById<TextClock>(R.id.clock)
    }

}

ListViewの動作がなんか重いと思ったら、ここを見直してみよう。

MainActivityにListViewを実装する

最後に、アクティビティにListView実装しよう。

package com.apppppp.trylistview

import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.widget.ListView

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)


        val list = findViewById<ListView>(R.id.clockList)
        val adapter = TimeZoneAdapter(this)

        list.adapter = adapter

        list.setOnItemClickListener { _, _, position, _ ->

            val timeZone = adapter.getItem(position)
            println(timeZone)
        }

    }
}

これで完成。


createViewが呼び出される回数を確認

createViewが何回呼び出されるか確認するために、カウンターを追加して測定してみた。Viewがリサイクルされるならば、カウンターの値は一定値以上にはならないはずだ。

private var count = 0

private fun createView(parent: ViewGroup?) : View {
    println(count++)

    val view = inflater.inflate(R.layout.list_time_zone_row, parent, false)
    view.tag = ViewHolder(view)
    return view
}

リストをスクロールすると11回まで呼び出され、それ以上は呼び出されなかった。これで、Viewがリサイクルされていることが確認できた。


今回のプロジェクトファイルはGitHubへ上げておいたのでご参考に。

▼ こんな記事も書いてます。

「キッチンノート.fun」という料理サイトを立ち上げました!このサイトで紹介していた料理記事は、そちらへ移動しました。
記事に関するご質問などがあれば、
Twitter または お問い合わせ までご連絡ください。
関連記事