LoginSignup
1
0

(たぶん)かなり簡単なリストのセクションインデックスとスティッキーヘッダーの実装

Last updated at Posted at 2024-02-03

個人的にかなり簡単な方法でセクションインデックス・スティッキーヘッダー付きのリストビューを作れたので共有します。

前提条件・環境

Android Studio Hedgehog | 2023.1.1 Patch 1
Kotlin 1.9.22

完成イメージ

(Gifに変換した際に画像が粗くなりました・・。)
index_section_and_sticky_header (1).gif

具体的な実装(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
        }

    }

}

1
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
1
0