Android の ExpandableListView って見た目をほぼ iOS の UITableView にできるよねって話です。
左:Android (ExpandableListView)、 右:iOS (UITableView)
iOS の UITableView は、特に難しいことをしなくてもセクション + 行 というのが作れますが、Android の場合は、ExpandableListView 用のアダプターを作成する必要があります。
ちょっぴり戸惑った記憶があるので残しておきます。
なお、あくまで見た目の話なので、ExpandableListView は、セクションをタップするとそのリストを閉じられたり、UITableView は、リストを横にスライドしたときの動作を簡単に設定できたりと両者でできることは異なります。
ソースコード
MainActivity
ExpandableListView を表示するだけです。ソースコードにコメントをしました。
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
で表示するためのデータ(セクションと行)を提供し、対応するビューを生成します。
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)!!
}
SectionHolder
と RowHolder
は、それぞれセクションと行のビューを保持するための内部クラスです。
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)を設定しています。
<?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>
<?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>
テスト アプリの見た目
さいごに
見た目を同じにしたい場合は、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