+6

Expandable List using ListAdapter - Android

Hôm nay mình muốn chia sẻ với các bạn một ví dụ về Expandable List sử dụng ListAdapter + Data Binding và multi-type trong RecyclerView.

Các bạn có thể tham khảo source code của mình tại đây!

Mình đang sử dụng Gradle 6.5Android Studio 4.1 nhé.

Ví dụ này sẽ hiển thị một danh sách các Section, khi chúng ta tap vào một Section nó sẽ mở rộng ra và hiển các Item của nó.

Dependencies

Đầu tiên chúng ta sẽ cần một vài dependencies trong build.gradle của module như sau:


dependencies {

    implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.4.10'
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.2.1'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.2'
    implementation 'androidx.activity:activity-ktx:1.1.0'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
    implementation 'androidx.recyclerview:recyclerview:1.1.0'
    implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
}

Section Model

Chúng ta sẽ có một Section model chứa các thông tin:

data class Section(
    val id: Int,
    val name: String,
    val isExpandable: Boolean,
    val items: List<Item>
) {
    data class Item(
        val id: Int,
        val name: String
    )
}

Các bạn chú ý 2 thuộc tính sau nhé:

  • isExpandable: Xác định trạng thái có thể mở rộng. true nếu đang thu gọn, false nếu đang mở rộng.
  • items: Danh sách các item sẽ hiển thị khi list mở rộng.

Ở đây mình khuyến khích sử dụng data class để tiện cho việc compare trong DiffUtil sau này.

ViewHolder

Tiếp theo, chúng ta sẽ có 2 ViewHolder để hiển thị cho Section và các Item của nó:

SectionViewHolder

Mình sẽ tạo một layout là item_section.xml chứa một TextView để hiển thị name của Section và một AppCompatImageView để hiển thị expand icon, cái này thay đổi theo isExpandable trong Section model.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="section"
            type="com.example.expandable_list_adapter_example.Section" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?selectableItemBackground"
        android:gravity="center"
        android:orientation="horizontal"
        android:padding="16dp">

        <TextView
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="@{section.name}"
            android:textAppearance="@style/TextAppearance.AppCompat.Medium"
            android:textStyle="bold"
            tools:text="Section 0" />

        <androidx.appcompat.widget.AppCompatImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:sectionExpandable="@{section.expandable}"
            tools:srcCompat="@drawable/ic_baseline_expand_more_24" />
    </LinearLayout>

</layout>

Mình có thêm một BindingAdaptersectionExpandable để cập nhật expand icon khi isExpandable thay đổi.

class SectionViewHolder(
    private val binding: ItemSectionBinding,
    private val onSectionClickListener: (Section) -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.root.setOnSingleClickListener {
            binding.section?.let {
                onSectionClickListener(it)
            }
        }
    }

    fun bind(section: Section) {
        binding.section = section
    }

    object Binding {

        @JvmStatic
        @BindingAdapter("sectionExpandable")
        fun setSectionExpandable(imageView: AppCompatImageView, isExpandable: Boolean) {
            if (isExpandable) {
                imageView.setImageResource(R.drawable.ic_baseline_expand_more_24)
            } else {
                imageView.setImageResource(R.drawable.ic_baseline_expand_less_24)
            }
        }
    }
}

Để hiển thị các Item của Section, mình sẽ xây dựng thêm một ViewHodder khác là:

SectionItemViewHolder

Đối với Section Item mình chỉ cần một TextView để hiển thị name của Item đó.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="sectionItem"
            type="com.example.expandable_list_adapter_example.Section.Item" />
    </data>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingStart="32dp"
        android:paddingTop="16dp"
        android:paddingEnd="16dp"
        android:paddingBottom="16dp"
        android:text="@{sectionItem.name}"
        android:textAppearance="@style/TextAppearance.AppCompat.Small"
        tools:text="Item 0" />
</layout>
class SectionItemViewHolder(
    private val binding: ItemSectionItemBinding
) : RecyclerView.ViewHolder(binding.root) {

    fun bind(sectionItem: Section.Item) {
        binding.sectionItem = sectionItem
    }
}

ListAdapter

Bây giờ, chúng ta sẽ xử lý ListAdapter để tích hợp với các ViewHolder trên.

Lý do để mình sử dụng ListAdapter đó là nó tích hợp DiffUtil để compare sự khác nhau của list được submit. Các bạn có thể tham khảo thêm ở đây.

Đầu tiên, chúng ta sẽ cần cung cấp cho nó một DiffUtil.ItemCallback như sau:

class MainListAdapter(
    private val onSectionClickListener: (Section) -> Unit
) : ListAdapter<Any, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
    
    companion object {
        private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Any>() {

            override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
                return when {
                    oldItem is Section && newItem is Section -> {
                        oldItem.id == newItem.id
                    }
                    oldItem is Section.Item && newItem is Section.Item -> {
                        oldItem.id == newItem.id
                    }
                    else -> false
                }
            }

            @SuppressLint("DiffUtilEquals")
            override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
                return when {
                    oldItem is Section && newItem is Section -> {
                        oldItem == newItem
                    }
                    oldItem is Section.Item && newItem is Section.Item -> {
                        oldItem == newItem
                    }
                    else -> false
                }
            }

        }
    }
}

Trong Adapter sẽ có 2 types, một cho Section và một cho Section Item.

Để có thể display 2 types này trong RecyclerView thì Adapter cần xác định chúng thông qua việc override getItemViewType(_: Int) : Int method.

class MainListAdapter(
    private val onSectionClickListener: (Section) -> Unit
) : ListAdapter<Any, RecyclerView.ViewHolder>(DIFF_CALLBACK) {

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is Section -> {
                ViewType.SECTION
            }
            is Section.Item -> {
                ViewType.SECTION_ITEM
            }
            else -> super.getItemViewType(position)
        }
    }

    object ViewType {
        const val SECTION = 101
        const val SECTION_ITEM = 102
    }
}

Tiếp theo, chúng ta sẽ tạo ViewHolder tướng ứng với các ViewType đã định nghĩa và bind data cho chúng.

class MainListAdapter(
    private val onSectionClickListener: (Section) -> Unit
) : ListAdapter<Any, RecyclerView.ViewHolder>(DIFF_CALLBACK) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            ViewType.SECTION -> {
                val itemSectionBinding = ItemSectionBinding.inflate(inflater, parent, false)
                SectionViewHolder(itemSectionBinding, onSectionClickListener)
            }
            ViewType.SECTION_ITEM -> {
                val itemSectionItemBinding = ItemSectionItemBinding.inflate(inflater, parent, false)
                SectionItemViewHolder(itemSectionItemBinding)
            }

            else -> throw(Throwable("View type not matching"))
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (val item = getItem(position)) {
            is Section -> (holder as? SectionViewHolder)?.bind(item)
            is Section.Item -> (holder as? SectionItemViewHolder)?.bind(item)
        }
    }
}

MainViewModel

Bây giờ, mình sẽ sử dụng ViewModel để xử lý dữ liệu cho Section cũng như cập nhật lại Section list khi có action expanding.

class MainViewModel sẽ như sau:

class MainViewModel : ViewModel() {

    private var sectionList: List<Section> = listOf()

    val sectionListLiveData = MutableLiveData<List<Section>>(sectionList)

    val isRefreshing = MutableLiveData<Boolean>(false)

    fun refresh() {
        isRefreshing.value = true
        getSectionList()
    }

    fun getSectionList() {
        sectionList = fakeSectionList()
        sectionListLiveData.value = sectionList
        isRefreshing.value = false
    }

    fun expand(sectionId: Int) {
        sectionList = sectionList.map { section ->
            if (section.id == sectionId) {
                section.copy(
                    isExpandable = !section.isExpandable
                )
            } else {
                section.copy(
                    isExpandable = true
                )
            }
        }
        sectionListLiveData.value = sectionList
    }

    private fun fakeSectionList(): List<Section> {
        val sectionList: MutableList<Section> = mutableListOf()
        repeat(Random.nextInt(0, 100)) { index ->
            val section = Section(
                id = index,
                name = "Section $index",
                items = fakeSectionItemList(),
                isExpandable = true
            )
            sectionList.add(section)
        }
        return sectionList
    }

    private fun fakeSectionItemList(): List<Section.Item> {
        val sectionItemList: MutableList<Section.Item> = mutableListOf()
        repeat(Random.nextInt(0, 10)) { index ->
            val sectionItem = Section.Item(
                id = index,
                name = "Item $index"
            )
            sectionItemList.add(sectionItem)
        }
        return sectionItemList
    }
}
  • getSectionList(): Random một Section list
  • expand(_: Int): Cập nhật trạng thái khi một section expading.
  • refresh(): Refresh lại Section list.

Cuối cùng chúng ta sẽ apply tất cả chúng vào RecyclerView trong MainActivity.

MainActivity

activity_main.xml

Trong activity_main.xml layout, mình chỉ có một RecyclerViewSwipeRefreshLayout phục vụ cho việc refresh lại Section list.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.example.expandable_list_adapter_example.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

        <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:id="@+id/swipeRefreshLayout"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:onRefreshListener="@{() -> viewModel.refresh()}"
            app:refreshing="@{viewModel.isRefreshing()}">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/recyclerView"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layoutManager="LinearLayoutManager"
                app:sectionList="@{viewModel.sectionListLiveData}" />
        </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
  • app:onRefreshListener="@{() -> viewModel.refresh()}" nhận action refresh từ user và gọi refresh() method.
  • app:refreshing="@{viewModel.isRefreshing()}" hiển thị icon loading theo isRefreshing().
  • app:sectionList="@{viewModel.sectionListLiveData}" là một BindingAdapter mình định nghĩa trong MainActivity.Binding, để submit một list mới khi sectionListLiveData changed.

MainActivity

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = viewModel
        binding.lifecycleOwner = this
        binding.executePendingBindings()

        binding.recyclerView.adapter = MainListAdapter(
            onSectionClickListener = { section ->
                viewModel.expand(section.id)
            }
        )

        viewModel.getSectionList()
    }

    object Binding {

        @JvmStatic
        @BindingAdapter("sectionList")
        fun setSectionList(recyclerView: RecyclerView, sectionList: List<Section>) {
            val list = mutableListOf<Any>()
            val mainListAdapter = recyclerView.adapter as? MainListAdapter

            sectionList.forEach { section ->
                list.add(section)
                if (!section.isExpandable) {
                    list.addAll(section.items)
                }
            }

            mainListAdapter?.submitList(list)
        }
    }
}

Trong BindingAdapter sectionList, mình sẽ check isExpandable để add hoặc không add section item.

Khi isExpandablefalse, nghĩa là đang mở rộng thì chúng ta sẽ add các Section Item vào. Và ngược lại, chúng ta chỉ add Section thôi.

Túm lại

Mình đã giới thiệu xong một example về Expandable List. Có thể đây không phải cách tối ưu nhất khi xử lý vấn đề này. Nhưng hi vọng nó sẽ giúp một chút gì cho các bạn.

Nếu có bất kì vấn đề nào hoặc góp ý cho bài viết, hãy comment dưới nhé...

Cám ơn các bạn đã đọc đến đây.

Thank you and Happy coding!!!

Source Code


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí