0
0

ExpandableListView ≒ UITableView

Posted at

Android の ExpandableListView って見た目をほぼ iOS の UITableView にできるよねって話です。

左:Android (ExpandableListView)、 右:iOS (UITableView)
cropped.png

iOS の UITableView は、特に難しいことをしなくてもセクション + 行 というのが作れますが、Android の場合は、ExpandableListView 用のアダプターを作成する必要があります。
ちょっぴり戸惑った記憶があるので残しておきます。

なお、あくまで見た目の話なので、ExpandableListView は、セクションをタップするとそのリストを閉じられたり、UITableView は、リストを横にスライドしたときの動作を簡単に設定できたりと両者でできることは異なります。

ソースコード

MainActivity

ExpandableListView を表示するだけです。ソースコードにコメントをしました。

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

      // テストデータ準備
      val list = List(26) { section ->
         TestSection("Section ${'A' + section}", List(3) { row -> TestRow("Row ${row + 1}") })
      }

      // アダプターを設定
      val listView = findViewById<android.widget.ExpandableListView>(R.id.expandableListView)
      listView.setAdapter(TestAdapter(list))
      
      // セクションのクリックリスナーは何もしないように設定
      listView.setOnGroupClickListener { _, _, _, _ -> true }

      // すべてのセクションを開いた状態に設定
      for (i in 0 until listView.expandableListAdapter.groupCount){ listView.expandGroup(i) }

      // 行のクリックリスナーを設定
      listView.setOnChildClickListener { _, _, groupPosition, childPosition, _ ->
         val row = list[groupPosition].rows[childPosition]
         Log.d("ChildClick", "Clicked: ${list[groupPosition].sectionName} ${row.rowName}")
         true
      }
   }
}

カスタムアダプター (TestAdapter)

ExpandableListView のためのカスタムアダプター (TestAdapter) の定義です。

このアダプターは、ExpandableListView で表示するためのデータ(セクションと行)を提供し、対応するビューを生成します。

TestAdapter.kt
data class TestRow(val rowName : String)
data class TestSection(val sectionName : String, val rows : List<TestRow>)
class TestAdapter(private val listData : List<TestSection>) : BaseExpandableListAdapter() {
   inner class SectionHolder(cell: View){
      val sectionTextView = cell.findViewById<TextView>(R.id.sectionTitle)!!
   }
   inner class RowHolder(cell:View){
      val rowTextView = cell.findViewById<TextView>(android.R.id.text1)!!
   }
   override fun getGroupCount(): Int = listData.size

   override fun getChildrenCount(groupPosition: Int): Int = listData[groupPosition].rows.size

   override fun getGroup(groupPosition: Int): Any = listData[groupPosition]

   override fun getChild(groupPosition: Int, childPosition: Int): Any = listData[groupPosition].rows[childPosition]

   override fun getGroupId(groupPosition: Int): Long = groupPosition.toLong()

   override fun getChildId(groupPosition: Int, childPosition: Int): Long = childPosition.toLong()

   override fun hasStableIds(): Boolean = false

   override fun isChildSelectable(groupPosition: Int, childPosition: Int): Boolean = true

   override fun getGroupView(groupPosition: Int, isExpanded: Boolean, convertView: View?, parent: ViewGroup?): View {
      val view : View
      val viewHolder : SectionHolder
      when (convertView){
         null -> {
            val inflater = LayoutInflater.from(parent?.context)
            view = inflater.inflate(R.layout.listview_section, parent, false)
            viewHolder = SectionHolder(view)
            view.tag = viewHolder
         }
         else -> {
            view = convertView
            viewHolder = view.tag as SectionHolder
         }
      }
      viewHolder.sectionTextView.text = listData[groupPosition].sectionName
      return view
   }

   override fun getChildView(groupPosition: Int, childPosition: Int, isLastChild: Boolean, convertView: View?, parent: ViewGroup?): View {
      val view : View
      val viewHolder : RowHolder
      when (convertView){
         null -> {
            val inflater = LayoutInflater.from(parent?.context)
            view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false)
            viewHolder = RowHolder(view)
            view.tag = viewHolder
         }
         else -> {
            view = convertView
            viewHolder = view.tag as RowHolder
         }
      }
      viewHolder.rowTextView.text = listData[groupPosition].rows[childPosition].rowName
      return view
   }

コードの説明

カスタムアダプター (TestAdapter) のコードを説明していきます。

1. データクラスの定義

data class TestRow(val rowName : String)
data class TestSection(val sectionName : String, val rows : List<TestRow>)

TestRow は、行を表すデータクラスで、rowName という名前のプロパティを持っています。
TestSection は、セクションを表すデータ クラスで、sectionName というセクション名のプロパティと、rows という行のリストを持っています。

2. アダプタークラス TestAdapter

class TestAdapter(private val listData : List<TestSection>) : BaseExpandableListAdapter() {

TestAdapter は、BaseExpandableListAdapter を継承しており、ExpandableListView に表示するためのデータとビューを提供します。
listData は、TestSection のリストで、各セクションとその中の行データを保持します。

3. ViewHolderの定義:

inner class SectionHolder(cell: View){
    val sectionTextView = cell.findViewById<TextView>(R.id.sectionTitle)!!
}
inner class RowHolder(cell:View){
    val rowTextView = cell.findViewById<TextView>(android.R.id.text1)!!
}

SectionHolderRowHolder は、それぞれセクションと行のビューを保持するための内部クラスです。

4. アダプターの基本的なメソッドの実装

// セクションの数
override fun getGroupCount(): Int = listData.size
// 指定されたセクションに属する行の数
override fun getChildrenCount(groupPosition: Int): Int = listData[groupPosition].rows.size
// 指定されたセクションのデータ
override fun getGroup(groupPosition: Int): Any = listData[groupPosition]
// 指定されたセクション内の行データ
override fun getChild(groupPosition: Int, childPosition: Int): Any = listData[groupPosition].rows[childPosition]
// セクションと行の一意のID
override fun getGroupId(groupPosition: Int): Long = groupPosition.toLong()
override fun getChildId(groupPosition: Int, childPosition: Int): Long = childPosition.toLong()
// 固定 ID を使用するかどうかを設定
override fun hasStableIds(): Boolean = false
// 行が選択可能かどうかを設定
override fun isChildSelectable(groupPosition: Int, childPosition: Int): Boolean = true

5. ビューを提供するメソッドの実装

今回は、セクションのレイアウトのみカスタムして、行のほうはデフォルトで用意されている android.R.layout.simple_list_item_1 を 使用しています。

セクション
override fun getGroupView(groupPosition: Int, isExpanded: Boolean, convertView: View?, parent: ViewGroup?): View {
   val view : View
   val sectionHolder : SectionHolder
   when (convertView){
      null -> {
         val inflater = LayoutInflater.from(parent?.context)
         view = inflater.inflate(R.layout.listview_section, parent, false)
         sectionHolder = SectionHolder(view)
         view.tag = sectionHolder
      }
      else -> {
         view = convertView
         sectionHolder = view.tag as SectionHolder
      }
   }
   sectionHolder.sectionTextView.text = listData[groupPosition].sectionName
   return view

getGroupView() は、セクションのビューを提供します。再利用可能なビューがない場合は、新しいビューを生成し、SectionHolder にセクション名を設定します。

override fun getChildView(groupPosition: Int, childPosition: Int, isLastChild: Boolean, convertView: View?, parent: ViewGroup?): View {
   val view : View
   val rowHolder : RowHolder
   when (convertView){
      null -> {
         val inflater = LayoutInflater.from(parent?.context)
         view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false)
         rowHolder = RowHolder(view)
         view.tag = rowHolder
      }
      else -> {
         view = convertView
         rowHolder = view.tag as RowHolder
      }
   }
   rowHolder.rowTextView.text = listData[groupPosition].rows[childPosition].rowName
   return view
}

getChildView() は、行のビューを提供します。再利用可能なビューがない場合は、新しいビューを生成し、RowHolder に行の名前を設定します。

セクションのレイアウト

単純に TextView が一つあるレイアウトです。android:background に角が丸くなった長方形リソース(res/drawable/section_header.xml)を設定しています。

res/layout/listview_section.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="@drawable/section_header">

    <TextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:id="@+id/sectionTitle"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:layout_marginTop="4dp"
        android:layout_marginBottom="4dp"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        android:textColor="@android:color/white"
        android:textSize="16sp"
        android:textStyle="bold" />
</androidx.constraintlayout.widget.ConstraintLayout>
res/drawable/section_header.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/black" />
    <corners android:radius="8dp" />
</shape>

テスト アプリの見た目

testapp.png

さいごに

見た目を同じにしたい場合は、UI フレームワークとか使用すればいいじゃない?とも思いますが、そこまではいいんだよなってやつです。

冒頭スクリーンショットのアプリは、これなので動作を見たい方は使ってやってください。150円の有料アプリですが、電車1駅分ぐらいの価値はあるかな~と思っています。

Android:
https://play.google.com/store/apps/details?id=com.jyro.nettorechner

iOS:
https://apps.apple.com/jp/app/%E6%89%8B%E5%8F%96%E3%82%8A%E8%A8%88%E7%AE%97%E6%A9%9F/id1441410311

参考URL

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0