個人的にかなり簡単な方法でセクションインデックス・スティッキーヘッダー付きのリストビューを作れたので共有します。
前提条件・環境
Android Studio Hedgehog | 2023.1.1 Patch 1
Kotlin 1.9.22
完成イメージ
具体的な実装(UI)
注意点・ポイントはコメントアウトで
<?xml version="1.0" encoding="utf-8"?>
<layout>
<!--DataBindingを使用しているので、ViewModelを宣言しています。-->
<!--ディレクトリ構造を見せないように一部隠してます。**は気にしないでください。-->
<data>
<variable
name="sectionIndexViewModel"
type="**.viewmodel.SectionIndexViewModel" />
</data>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/select_state_setting_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/app_theme"
android:minHeight="?attr/actionBarSize"
app:theme="@style/ToolbarTheme"
app:title="Section Index/ Sticky Header"
app:titleTextColor="@color/white" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!--スティッキーヘッダーをリストの上に固定します。-->
<!--リストの1番上のアイテムがスティッキーヘッダーに重なるように設置します。-->
<TextView
android:id="@+id/sticky_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/grayOut"
android:gravity="center_vertical"
android:minHeight="30dp"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:textColor="@color/black"
app:setTextTypeface="@{sectionIndexViewModel.medium}" />
<!--スティッキーヘッダーの下に重ねるのでandroid:elevation="-1dp"を設定します。-->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/city_list_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:cacheColorHint="#000"
android:elevation="-1dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<!--セクションインデックスの位置は自由に設定してください。今回は全体レイアウトの右側縦中央に設定します。-->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/section_list_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="#00000000"
android:cacheColorHint="#000"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</RelativeLayout>
</LinearLayout>
</layout>
具体的な実装(Model)
//Cityリストとセクションインデックスの要素の構成です。
//nameはCityリストで表示する情報でsectionはセクションインデックスで表示する情報です。
data class ItemModel(
val id: Int = 0,
val name: String = "",
val section: String = "",
val isHeader: Boolean = false
)
//Cityリストのデータです。
//サンプルなのでハードコードです。実際はサーバー側からデータを取得しリストデータを作成してください。
val items = mutableListOf(
ItemModel(id = 0, name = "あ行", section = "あ行", isHeader = true),
ItemModel(id = 1, name = "愛別町", section = "あ行"),
ItemModel(id = 2, name = "旭川市", section = "あ行"),
ItemModel(id = 3, name = "枝幸町", section = "あ行"),
ItemModel(id = 4, name = "遠別町", section = "あ行"),
ItemModel(id = 5, name = "音威子府村", section = "あ行"),
ItemModel(id = 6, name = "小平町", section = "あ行"),
ItemModel(id = 7, name = "か行", section = "か行", isHeader = true),
ItemModel(id = 8, name = "上川町", section = "か行"),
ItemModel(id = 9, name = "上富良野町", section = "か行"),
ItemModel(id = 10, name = "剣淵町", section = "か行"),
ItemModel(id = 11, name = "さ行", section = "さ行", isHeader = true),
ItemModel(id = 12, name = "猿払村", section = "さ行"),
ItemModel(id = 13, name = "士別市", section = "さ行"),
ItemModel(id = 14, name = "占冠村", section = "さ行"),
ItemModel(id = 15, name = "下川町", section = "さ行"),
ItemModel(id = 16, name = "初山別村", section = "さ行"),
ItemModel(id = 17, name = "た行", section = "た行", isHeader = true),
ItemModel(id = 18, name = "鷹栖町", section = "た行"),
ItemModel(id = 19, name = "天塩町", section = "た行"),
ItemModel(id = 20, name = "当麻町", section = "た行"),
ItemModel(id = 21, name = "苫前町", section = "た行"),
ItemModel(id = 22, name = "豊富町", section = "た行"),
ItemModel(id = 23, name = "な行", section = "な行", isHeader = true),
ItemModel(id = 24, name = "中川町", section = "な行"),
ItemModel(id = 25, name = "中頓別町", section = "な行"),
ItemModel(id = 26, name = "中富良野町", section = "な行"),
ItemModel(id = 27, name = "名寄町", section = "な行"),
ItemModel(id = 28, name = "は行", section = "は行", isHeader = true),
ItemModel(id = 29, name = "羽幌町", section = "は行"),
ItemModel(id = 30, name = "浜頓別町", section = "は行"),
ItemModel(id = 31, name = "東神楽町", section = "は行"),
ItemModel(id = 32, name = "東川町", section = "は行"),
ItemModel(id = 33, name = "美瑛町", section = "は行"),
ItemModel(id = 34, name = "美深町", section = "は行"),
ItemModel(id = 35, name = "比布町", section = "は行"),
ItemModel(id = 36, name = "富良野町", section = "は行"),
ItemModel(id = 37, name = "幌加内町", section = "は行"),
ItemModel(id = 38, name = "幌延町", section = "は行"),
ItemModel(id = 39, name = "ま行", section = "ま行", isHeader = true),
ItemModel(id = 40, name = "増毛町", section = "ま行"),
ItemModel(id = 41, name = "南富良野町", section = "ま行"),
ItemModel(id = 42, name = "ら行", section = "ら行", isHeader = true),
ItemModel(id = 43, name = "利尻町", section = "ら行"),
ItemModel(id = 44, name = "利尻富士町", section = "ら行"),
ItemModel(id = 45, name = "留萌町", section = "ら行"),
ItemModel(id = 46, name = "礼文町", section = "ら行"),
ItemModel(id = 47, name = "わ行", section = "わ行", isHeader = true),
ItemModel(id = 48, name = "稚内市", section = "わ行"),
ItemModel(id = 49, name = "和寒市", section = "わ行"),
)
//セクションインデックスのデータです。ハードコードしていますが実際にはCityリストのデータから isHeader = true の要素をリスト化してください。
//リストとセクションインデックスのModelの構造は一緒です。セクションインデックスは isHeader = true の要素を取り出したものです。
//方針としては、セクションインデックスをタップした際に、ItemModelのidのアイテムが一番上に来る位置にスクロールするといった感じです。
val mutableItem = mutableListOf(
ItemModel(id = 0, name = "あ行", isHeader = true),
ItemModel(id = 7, name = "か行", isHeader = true),
ItemModel(id = 11, name = "さ行", isHeader = true),
ItemModel(id = 17, name = "た行", isHeader = true),
ItemModel(id = 23, name = "な行", isHeader = true),
ItemModel(id = 28, name = "は行", isHeader = true),
ItemModel(id = 39, name = "ま行", isHeader = true),
ItemModel(id = 42, name = "ら行", isHeader = true),
ItemModel(id = 49, name = "わ行", isHeader = true),
)
具体的な実装(Activity)
//2つのインターフェースを実装しています。 ScrollToPositionInterface.scrollToPosition はセクションインデックスタップした時の挙動です。
//CityItemTouchListener.onItemTouch は市町村アイテムをタップした時の挙動です。(詳しくは見ませんが今回はタップするとダイアログが表示される仕様です。)
class StickyHeaderAndSectionIndex : AppCompatActivity(), ScrollToPositionInterface,
CityItemTouchListener {
//市町村リストを表示するためのRecyclerViewです。
private lateinit var cityListRecyclerView: RecyclerView
//ViewModelを初期化しています。今回はViewModel自体はそんなに重要な役割はないので詳しくは見ません。
private val sectionIndexViewModel: SectionIndexViewModel by viewModels {
SelectStateViewModelFactory()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//DataBindingを使っています。詳しくは見ません。
val binding = DataBindingUtil.setContentView<ActivityNewSelectStateSettingBinding>(
this, R.layout.activity_new_select_state_setting
)
binding.sectionIndexViewModel = sectionIndexViewModel
binding.lifecycleOwner = this
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
//ビュー達を初期化しています。
cityListRecyclerView = binding.cityListView
val stickyHeader = binding.stickyHeader
val sectionIndexRecyclerView = binding.sectionListView
//アイテム間の区切り線を表示するための設定です。
val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
cityListRecyclerView.addItemDecoration(decoration)
//アダプターの初期化などの処理です。アダプターに市町村リストのアイテムがタップされた際のリスナーのオブジェクトを渡しています。
val cityListAdapter = CityListAdapter(this)
cityListRecyclerView.adapter = cityListAdapter
val items = mutableListOf(
ItemModel(id = 0, name = "あ行", section = "あ行", isHeader = true),
ItemModel(id = 1, name = "愛別町", section = "あ行"),
ItemModel(id = 2, name = "旭川市", section = "あ行"),
ItemModel(id = 3, name = "枝幸町", section = "あ行"),
ItemModel(id = 4, name = "遠別町", section = "あ行"),
ItemModel(id = 5, name = "音威子府村", section = "あ行"),
ItemModel(id = 6, name = "小平町", section = "あ行"),
ItemModel(id = 7, name = "か行", section = "か行", isHeader = true),
ItemModel(id = 8, name = "上川町", section = "か行"),
ItemModel(id = 9, name = "上富良野町", section = "か行"),
ItemModel(id = 10, name = "剣淵町", section = "か行"),
ItemModel(id = 11, name = "さ行", section = "さ行", isHeader = true),
ItemModel(id = 12, name = "猿払村", section = "さ行"),
ItemModel(id = 13, name = "士別市", section = "さ行"),
ItemModel(id = 14, name = "占冠村", section = "さ行"),
ItemModel(id = 15, name = "下川町", section = "さ行"),
ItemModel(id = 16, name = "初山別村", section = "さ行"),
ItemModel(id = 17, name = "た行", section = "た行", isHeader = true),
ItemModel(id = 18, name = "鷹栖町", section = "た行"),
ItemModel(id = 19, name = "天塩町", section = "た行"),
ItemModel(id = 20, name = "当麻町", section = "た行"),
ItemModel(id = 21, name = "苫前町", section = "た行"),
ItemModel(id = 22, name = "豊富町", section = "た行"),
ItemModel(id = 23, name = "な行", section = "な行", isHeader = true),
ItemModel(id = 24, name = "中川町", section = "な行"),
ItemModel(id = 25, name = "中頓別町", section = "な行"),
ItemModel(id = 26, name = "中富良野町", section = "な行"),
ItemModel(id = 27, name = "名寄町", section = "な行"),
ItemModel(id = 28, name = "は行", section = "は行", isHeader = true),
ItemModel(id = 29, name = "羽幌町", section = "は行"),
ItemModel(id = 30, name = "浜頓別町", section = "は行"),
ItemModel(id = 31, name = "東神楽町", section = "は行"),
ItemModel(id = 32, name = "東川町", section = "は行"),
ItemModel(id = 33, name = "美瑛町", section = "は行"),
ItemModel(id = 34, name = "美深町", section = "は行"),
ItemModel(id = 35, name = "比布町", section = "は行"),
ItemModel(id = 36, name = "富良野町", section = "は行"),
ItemModel(id = 37, name = "幌加内町", section = "は行"),
ItemModel(id = 38, name = "幌延町", section = "は行"),
ItemModel(id = 39, name = "ま行", section = "ま行", isHeader = true),
ItemModel(id = 40, name = "増毛町", section = "ま行"),
ItemModel(id = 41, name = "南富良野町", section = "ま行"),
ItemModel(id = 42, name = "ら行", section = "ら行", isHeader = true),
ItemModel(id = 43, name = "利尻町", section = "ら行"),
ItemModel(id = 44, name = "利尻富士町", section = "ら行"),
ItemModel(id = 45, name = "留萌町", section = "ら行"),
ItemModel(id = 46, name = "礼文町", section = "ら行"),
ItemModel(id = 47, name = "わ行", section = "わ行", isHeader = true),
ItemModel(id = 48, name = "稚内市", section = "わ行"),
ItemModel(id = 49, name = "和寒市", section = "わ行"),
)
cityListAdapter.submitList(items)
//アダプターの初期化などの処理です。アダプターにセクションインデックスのアイテムがタップされた際のリスナーのオブジェクトを渡しています。
val sectionIndexAdapter = SectionIndexAdapter(this)
sectionIndexRecyclerView.adapter = sectionIndexAdapter
val mutableItem = mutableListOf(
ItemModel(id = 0, name = "あ行", isHeader = true),
ItemModel(id = 7, name = "か行", isHeader = true),
ItemModel(id = 11, name = "さ行", isHeader = true),
ItemModel(id = 17, name = "た行", isHeader = true),
ItemModel(id = 23, name = "な行", isHeader = true),
ItemModel(id = 28, name = "は行", isHeader = true),
ItemModel(id = 39, name = "ま行", isHeader = true),
ItemModel(id = 42, name = "ら行", isHeader = true),
ItemModel(id = 49, name = "わ行", isHeader = true),
)
sectionIndexAdapter.submitList(mutableItem)
//スクロール位置によってスティッキーヘッダーの表示を変更する処理です。
val listener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val manager = recyclerView.layoutManager as LinearLayoutManager
//現在見えている一番上のアイテムの位置を取得しています。
val firstVisibleItemPosition = manager.findFirstVisibleItemPosition()
val cityListAdapterCasted = recyclerView.adapter as CityListAdapter
val item = cityListAdapterCasted.getItemModel(firstVisibleItemPosition)
stickyHeader.text = item.section
stickyHeader.typeface = MainApplication.getFont(MainApplication.FONT_MEDIUM)
super.onScrolled(recyclerView, dx, dy)
}
}
cityListRecyclerView.addOnScrollListener(listener)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
finish()
return super.onOptionsItemSelected(item)
}
override fun scrollToPosition(position: Int) {
val manager = cityListRecyclerView.layoutManager as LinearLayoutManager
manager.scrollToPositionWithOffset(position, 0)
}
override fun onItemTouch(cityName: String, cityCode: String) {
val adb = AlertDialog.Builder(this)
adb.setTitle("確認")
adb.setMessage("選択した地域を登録しますか?\n選択した地域:$cityName")
val yes = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> }
adb.setPositiveButton("はい", yes)
adb.setNegativeButton("いいえ", null)
val alert = adb.create()
alert.show()
}
}
interface ScrollToPositionInterface {
fun scrollToPosition(position: Int)
}
interface CityItemTouchListener {
fun onItemTouch(cityName: String, cityCode: String)
}
具体的な実装(Adapter)
class CityListAdapter(private val cityItemTouchListener: CityItemTouchListener) :
ListAdapter<ItemModel, CityListAdapter.ItemViewHolder>(ListItemCallback()) {
companion object {
private const val STICKY_HEADER = 0
private const val CITY_ITEM = 1
}
// getItemViewType をオーバーライドすることで、リストのアイテムの種類を判別します。
override fun getItemViewType(position: Int): Int {
val itemModel = getItem(position)
val isHeader = itemModel.isHeader
return if (isHeader) {
STICKY_HEADER
} else {
CITY_ITEM
}
}
//リストのデザインは自由に設定してください。
//スティッキーヘッダーと市町村アイテムのデザインは異なるのでここで分岐の設定を行います。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
return when (viewType) {
STICKY_HEADER -> ItemViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.sticky_header_container, parent, false)
)
CITY_ITEM -> ItemViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.simple_container, parent, false)
)
else -> throw IllegalArgumentException("Invalid view type")
}
}
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
//市町村アイテムをタップした時の挙動を設定しています。ヘッダーには不要なので条件分岐が必要です。
if (!item.isHeader) {
holder.itemView.setOnClickListener {
cityItemTouchListener.onItemTouch(getItem(position).name, "")
}
}
}
class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val nameTextView: TextView =
itemView.findViewById(R.id.text)
fun bind(itemModel: ItemModel) {
nameTextView.text = itemModel.name
nameTextView.typeface = MainApplication.getFont(MainApplication.FONT_MEDIUM)
}
}
private class ListItemCallback : DiffUtil.ItemCallback<ItemModel>() {
override fun areItemsTheSame(oldItem: ItemModel, newItem: ItemModel): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: ItemModel, newItem: ItemModel): Boolean {
return oldItem == newItem
}
}
fun getItemModel(position: Int): ItemModel {
return super.getItem(position)
}
}
class SectionIndexAdapter(
private val scrollToPositionInterface: ScrollToPositionInterface
) :
ListAdapter<ItemModel, SectionIndexAdapter.SectionViewHolder>(ListItemCallback()) {
//レイアウトは自由に設定してください。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
return SectionViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.simple_section_box, parent, false)
)
}
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
holder.bind(getItem(position))
val itemModel = getItem(position)
//リスナーを設定して、タップすると任意のセクションまでスクロールするようにしています。
holder.itemView.setOnClickListener {
scrollToPositionInterface.scrollToPosition(itemModel.id)
}
}
class SectionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val nameTextView: TextView =
itemView.findViewById(R.id.box_text)
fun bind(itemModel: ItemModel) {
nameTextView.text = itemModel.name
nameTextView.typeface = MainApplication.getFont(MainApplication.FONT_MEDIUM)
}
}
private class ListItemCallback : DiffUtil.ItemCallback<ItemModel>() {
override fun areItemsTheSame(oldItem: ItemModel, newItem: ItemModel): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: ItemModel, newItem: ItemModel): Boolean {
return oldItem == newItem
}
}
}