Expandable DropDown with Multiple Selection

Artikel kali ini saya akan membahas salah satu custom view kembali yaitu mengenai Dropdown yang didalamnya kurang lebih memiliki fitur tambahan seperti Transition animasi saat expanded dan collapsed, multiple selection didalam satu dropdown yang me-return collection dan view ini bukan merupakan turunan dari spinner seperti yang biasanya digunakan Android Developer umumnya namun menggunakan RecyclerView yang insyaallah fungsi recycle didalamnya akan membantu mengoptimalkan penggunaan memory.

Namun sepertinya kali ini saya tidak akan menjelaskan terlalu detail hanya ke point-point nya saja dikarenakan artikel ini saya tulis dalam perjalanan. Namun untuk memudahkan pembaca, semua nama variable dan fungsinya sudah saya sederhanakan agar lebih readable dan mudah dipahami kegunaan dari masing-masing variable dan fungsinya.

1. IExpandableItemAdapter (interface untuk kontrak fungsi-fungsi yg akan digunakan)
interface IExpandableItemAdapter {
    fun inflateHeaderView(parent: ViewGroup): View
    fun inflateItemView(parent: ViewGroup): View
    fun bindItemView(itemView: View, position: Int, selected: Boolean)
    fun bindHeaderView(headerView: View, selectedIndices: List<Int>)
//baris ini biarkan dulu selagi kita membuat class yg lainya dulu
    fun onViewStateChanged(headerView: View, state: ExpandableSelectionView.State)
//end
    fun removeFromSelected(index: Int)
    fun addSelected(index: Int)
    fun getItemsCount(): Int
    fun getObjectValue(): String
}
2. ExpandableRecyclerView
Sebenarnya RecyclerView seperti ini pernah saya buat sebelumnya di BottomSheet di Delva pada waktu itu hanya kali ini ditambahkan dengan fungsi untuk menambahkan transisi animasi saja didalamnya. Animation Transition bisa menggunakan TransitionAnimation yg sudah ada pada template.

class ExpandableRecyclerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {

    private var animating = false
    var maxHeight: Int = Int.MAX_VALUE

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val newHeightSpec = when {
            animating -> heightMeasureSpec
            else -> MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
        }
        super.onMeasure(widthMeasureSpec, newHeightSpec)
    }

    fun expand(animationDuration: Long) {
        this.animating = true
        this.expand(maxHeight, animationDuration) {
            this.animating = false
        }
    }

    fun collapse(animationDuration: Long) {
        this.animating = true
        this.collapse(animationDuration) {
            this.animating = false
        }
    }
}

Variabel maxHeight diatas dapat diatur dinamis sesuai dengan kebutuhan UI/UX aplikasi.

3. ExpandableRecyclerAdapter

Class ini merupakan sebuah class adapter pada RecyclerView seperti biasanya yg cukup sederhana. Adapter digunakan untuk per item yang ada dalam collection dropdown.
class ExpandableRecyclerAdapter(
    private var adapter: IExpandableItemAdapter,
    private val itemClickCallback: (Int) -> Unit,
    private val selectedIndexPredicate: (Int) -> Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    @ColorInt
    var dividerColor: Int? = null
    var showDividers: Boolean? = null

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val linearLayout = LinearLayout(parent.context)
        val contentView = adapter.inflateItemView(parent)
        val dividerView = parent.inflate(R.layout.divider_layout)

        showDividers?.let { dividerView.isVisible = it }
        dividerColor?.let { dividerView.setBackgroundColor(it) }
        linearLayout.apply {
            layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
            orientation = LinearLayout.VERTICAL
            addView(dividerView)
            addView(contentView)
        }
        return ViewHolder(linearLayout)
    }

    override fun getItemCount(): Int = adapter.getItemsCount()

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val itemViewGroup = holder.itemView as ViewGroup
        val itemView = itemViewGroup.getChildAt(1)
        val isItemSelected = selectedIndexPredicate(position)
        adapter.bindItemView(itemView, position, isItemSelected)
        holder.itemView.setOnClickListener { itemClickCallback.invoke(position) }
    }

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}

4. attrs.xml

Tambahkan beberapa atrribute yang dapat memudahkan handling view cukup pada layout xml ini saja tanpa melulu melalui code.

<style name="ExpandableRecyclerView">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
</style>
<style name="ExpandableRecyclerView.Scrollbars">
<item name="android:scrollbars">vertical</item>
</style>
<declare-styleable name="ExpandableSelectionView">
<attr name="title" />
<attr format="dimension" name="maximumHeight"/>
<attr format="reference" name="bg"/>
<attr format="color" name="dividerColor"/>
<attr format="boolean" name="dividerVisibility"/>
<attr format="boolean" name="scrollBarsVisibility"/>
<attr format="boolean" name="isMultiple"/>
<attr format="integer" name="animDuration"/>
</declare-styleable>

5. ExpandableSelectionView

Ini merupakan class utama dari custom view ini.
Beberapa fungsi didalamnya antara lain :
* mengatur background dari dropdown
* visibilitas scrollbar
* visibilitas divider per item dan warnanya
* mengontrol state (collapsed & expanded)
* menginisialisasi variabel lain yang dibutuhkan seperti selectedItem(/s), maxHeight, adapter dll.

abstract class ExpandableSelectionView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

    private val contentLayout: LinearLayout
    private val itemsRecyclerView: ExpandableRecyclerView
    private val errorLabel: AppCompatTextView
    private var headerView: View
    private var currentState: State = State.Collapsed
    private var drawableBackground: Drawable? = getDrawable(context, R.drawable.bg_expandable_selection_view)
    private var showScrollBars = true
    private var showDividers = true
    private var dividerColor = Color.parseColor("#668b999f")
    private var maxHeight = Int.MAX_VALUE
    private var animationDuration = DEFAULT_ANIMATION_DURATION
    private var selectedIndices = mutableListOf<Int>()
    private var IExpandableSelectionAdapter: IExpandableItemAdapter? = null
    private var recyclerAdapter: ExpandableRecyclerAdapter? = null

    var isMultiple = false

    init {
        attrs?.let { extractAttributes(it) }
        this.orientation = VERTICAL
        contentLayout = LinearLayout(context)
        contentLayout.apply {
            orientation = VERTICAL
            background = drawableBackground
        }
        val recyclerStyle = when {
            showScrollBars -> R.style.ExpandableRecyclerView_Scrollbars
            else -> R.style.ExpandableRecyclerView
        }
        itemsRecyclerView = ExpandableRecyclerView(ContextThemeWrapper(context, recyclerStyle))
        itemsRecyclerView.maxHeight = maxHeight
        errorLabel = inflate(R.layout.error_field_layout) as AppCompatTextView
        headerView = View(context)
        this.addView(contentLayout)
        this.addView(errorLabel)
    }

    fun setAdapter(adapter: IExpandableItemAdapter) {
        this.IExpandableSelectionAdapter = adapter
        val recyclerAdapter = ExpandableRecyclerAdapter(
            adapter,
            ::handleItemClick,
            ::isSelected
        ).also {
            it.showDividers = showDividers
            it.dividerColor = dividerColor
        }
        setRecyclerAdapter(recyclerAdapter)
        addContentViews(adapter)
        initState()
    }

    fun setError(errorStr: String?) {
        errorLabel.apply {
            isGone = (errorStr == null)
            text = errorStr
        }
    }

    fun setState(state: State) {
        if (currentState == state) return
        toggleAndSetState()
    }

    internal fun getSelectedIndices(): List<Int> = selectedIndices

    open fun clearSelection() {
        selectedIndices.clear()
        IExpandableSelectionAdapter?.bindHeaderView(headerView, selectedIndices)
        recyclerAdapter?.notifyDataSetChanged()
    }

    private fun extractAttributes(attrs: AttributeSet) {
        context.withStyledAttributes(attrs, styleable.ExpandableSelectionView, 0, 0) {
            drawableBackground = getDrawable(styleable.ExpandableSelectionView_bg) ?: drawableBackground
            maxHeight = getLayoutDimension(ExpandableSelectionView_maximumHeight, maxHeight)
            showDividers = getBoolean(ExpandableSelectionView_dividerVisibility, showDividers)
            showScrollBars = getBoolean(ExpandableSelectionView_scrollBarsVisibility, showScrollBars)
            dividerColor = getColor(ExpandableSelectionView_dividerColor, dividerColor)
            animationDuration = getInteger(ExpandableSelectionView_animDuration, animationDuration.toInt()).toLong()
            isMultiple = getBoolean(ExpandableSelectionView_isMultiple, isMultiple)
        }
    }

    private fun initState() {
        this.currentState = State.Collapsed
        IExpandableSelectionAdapter?.apply {
            bindHeaderView(headerView, selectedIndices)
            onViewStateChanged(headerView, currentState)
        }
        itemsRecyclerView.isGone = true
    }

    private fun addContentViews(adapter: IExpandableItemAdapter) {
        headerView = adapter.inflateHeaderView(this)
        headerView.setOnClickListener { onHeaderClicked() }
        contentLayout.apply {
            removeAllViews()
            addView(headerView)
            addView(itemsRecyclerView)
        }
    }

    fun getAdapter(): IExpandableItemAdapter?{
        return this.IExpandableSelectionAdapter
    }

    private fun setRecyclerAdapter(recyclerAdapter: ExpandableRecyclerAdapter) {
        this.recyclerAdapter = recyclerAdapter
        val linearLayoutManager = LinearLayoutManager(context)
        itemsRecyclerView.apply {
            adapter = recyclerAdapter
            itemAnimator = null
            layoutManager = linearLayoutManager
        }
    }

    private fun onHeaderClicked() {
        toggleAndSetState()
    }

    private fun toggleAndSetState() {
        when (currentState) {
            is State.Expanded -> collapse()
            is State.Collapsed -> expand()
        }
        currentState = !currentState
        IExpandableSelectionAdapter?.onViewStateChanged(headerView, currentState)
    }

    private fun expand() {
        itemsRecyclerView.apply {
            scrollToPosition(0)
            expand(animationDuration)
        }
    }

    private fun collapse() {
        itemsRecyclerView.collapse(animationDuration)
    }

    abstract fun handleItemClick(index: Int)

    protected fun isSelected(index: Int) = selectedIndices.contains(index)

    protected fun selectItem(index: Int) {
        selectedIndices.add(index)
        IExpandableSelectionAdapter?.apply {
            addSelected(index)
            bindHeaderView(headerView, selectedIndices)
        }
        recyclerAdapter?.notifyItemChanged(index)
    }

    protected fun unSelectItem(index: Int) {
        selectedIndices.remove(index)
        IExpandableSelectionAdapter?.apply {
            removeFromSelected(index)
            bindHeaderView(headerView, selectedIndices)
        }
        recyclerAdapter?.notifyItemChanged(index)
    }

    sealed class State {
        object Expanded : State()
        object Collapsed : State()

        operator fun not(): State =
            when (this) {
                Expanded -> Collapsed
                Collapsed -> Expanded
            }
    }

    companion object {
        private const val DEFAULT_ANIMATION_DURATION = 300L
    }
}

6. ExpandableDropdown

Class ini yang akan kita panggil sebagai view di layout xml nantinya dan merupakan turunan dari expandableSelectionnView diatas dengan beberapa implememntation diantaranya untuk listener selection, meng handle beberapa fungsi untuk kebutuhan single dan multiple selection.
class ExpandableDropDown @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ExpandableSelectionView(context, attrs, defStyleAttr) {

    var autoCollapseOnSelection = true
    var selectionListener: ((List<Int>) -> Unit)? = null
    val selectedIndices: List<Int>
        get() = getSelectedIndices()
    val selectedIndex: Int?
        get() = getSelectedIndices().firstOrNull()

    override fun handleItemClick(index: Int) {
        if (isMultiple) {
            autoCollapseOnSelection = false
            if (isSelected(index)) unSelectItem(index)
            else selectItem(index)
            notifySelectionListener()
        } else {
            if (isSelected(index)) {
                unSelectItem(index)
                notifySelectionListener(null)
            } else {
                if (getSelectedIndices().isNotEmpty()) unSelectItem(getSelectedIndices().first())
                selectItem(index)
                notifySelectionListener(index)
            }
            if (autoCollapseOnSelection) setState(State.Collapsed)
        }
    }

    fun selectIndices(indices: List<Int>, notifyListener: Boolean = true) {
        if (isMultiple) {
            indices.filterNot(::isSelected).forEach(::selectItem)
            if (notifyListener) notifySelectionListener()
        } else {
            if (!isSelected(indices.first())) {
                if (getSelectedIndices().isNotEmpty()) unSelectItem(getSelectedIndices().first())
                selectItem(indices.first())
                if (notifyListener) notifySelectionListener(indices.first())
            }
        }
    }

    override fun clearSelection() {
        super.clearSelection()
        if (isMultiple) selectionListener?.invoke(emptyList())
        else notifySelectionListener(null)
    }

    private fun notifySelectionListener(index: Int? = null) {
        selectionListener?.invoke(selectedIndices)
    }
}

7. ExpandableDropDownAdapter

Ini merupakan class terakhir dari cutomView pada artikel kali ini. 
Class adapter ini meng handle untuk header sekaligus masing-masing item didalam collection list pada dropdown.
class ExpandableDropDownAdapter<TObject : Any> (
    private val items: List<TObject>,
    private var hint: String? = null,
    private val textField: String? = null,
    private val valueField: String? = null
) : IExpandableItemAdapter {

    @DrawableRes
    var selectedStateResId: Int? = null
    @DrawableRes
    var collapsedStateResId: Int? = null
    @DrawableRes
    var expandedStateResId: Int? = null
    var selectedItem: TObject? = null
    var selectedItems : ArrayList<TObject?> = ArrayList()

    private fun getPropertyValue(obj: TObject?, fieldName: String?): Any? {
        var result: Any? = null
        lets(obj, fieldName, { o, f ->
            try {
                result = o.getPropertyValue(f)
            } catch (e: Exception) {
                e.printStackTrace()
            }
        })
        return result
    }

    override fun inflateHeaderView(parent: ViewGroup): View {
        val view = parent.inflate(R.layout.basic_expandable_header_layout)
        view.headerTV.hint = hint
        return view
    }

    override fun inflateItemView(parent: ViewGroup) = parent.inflate(R.layout.basic_expandable_item_layout)

    override fun bindItemView(itemView: View, position: Int, selected: Boolean) {
        val item = items[position]
        item.let {
            if (item is IEnum) itemView.itemNameTV.text = (item as IEnum).toDescription() else {
                if (!textField.isNullOrEmpty()) itemView.itemNameTV.text = getPropertyValue(item, textField).toString()
                else itemView.itemNameTV.text = item.toString()
            }
        }
        itemView.selectionIV.apply {
            setImageResource(selectedStateResId ?: R.drawable.ic_selected)
            isVisible = selected
        }
    }

    override fun getObjectValue():String{
        var str = ""
        for(i in selectedItems){
            str += getPropertyValue(i,valueField).toString()+","
        }
        return str
    }

    override fun addSelected(index: Int) {
        selectedItems.add(items[index])
    }

    override fun removeFromSelected(index : Int){
        if(selectedItems.size==1) selectedItems.clear()
        else selectedItems.remove(items[index])
    }

    override fun bindHeaderView(headerView: View, selectedIndices: List<Int>) {
        if (selectedIndices.isEmpty()) {
            headerView.headerTV.text = null
            selectedItem = null
        } else {
            for(i in selectedIndices) selectedItem = items[i]
            selectedItem?.let {
                if (items is IEnum) headerView.headerTV.text = (items as IEnum).toDescription()
                else headerView.headerTV.text = selectedIndices.joinToString { getPropertyValue(items[it],textField).toString() }
            }
        }
    }

    override fun getItemsCount() = items.size

    override fun onViewStateChanged(headerView: View, state: ExpandableSelectionView.State) {
        val imageView = headerView.findViewById<ImageView>(R.id.listIndicatorIV)
        imageView.setImageResource(
            when (state) {
                ExpandableSelectionView.State.Expanded -> expandedStateResId ?: R.drawable.ic_expanded_arrow
                ExpandableSelectionView.State.Collapsed -> collapsedStateResId ?: R.drawable.ic_collapsed_arrow
            }
        )
    }
}

Last but not least yaitu penggunaan singkatnya :

a. Layout pada xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="@dimen/space_l">

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="@dimen/space_s"
            android:text="I'm the Single, I can be loyal to one Item :"
            tools:ignore="HardcodedText" />

        <com.pertamina.jarvis.view.dropdown.ExpandableDropDown
            android:id="@+id/selection_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="@dimen/space_l"
            app:bg="@drawable/bg_expandable_selection_view"
            app:dividerVisibility="true"
            app:scrollBarsVisibility="true"
            app:isMultiple="false"/>

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="@dimen/space_s"
            android:text="I'm the Multiple, I have many Items :"
            tools:ignore="HardcodedText" />


        <com.pertamina.jarvis.view.dropdown.ExpandableDropDown
            android:id="@+id/selection_view_multi"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:bg="@drawable/bg_expandable_selection_view"
            app:dividerVisibility="true"
            app:scrollBarsVisibility="true"
//MULTIPLE (default nya single)
            app:isMultiple="true"/>

    </LinearLayout>
</androidx.core.widget.NestedScrollView>

b. Activity
private fun initSelectionView() {
//SINGLE SELECTION
        val items = arrayListOf(
            OptionStub(0, "One"),
            OptionStub(1, "Two"),
            OptionStub(2, "Three"),
            OptionStub(3, "Four"),
            OptionStub(4, "Five")
        )

        selection_view.apply {
            setAdapter(
                ExpandableDropDownAdapter(
                    items, "Select one of me..",
                    OptionStub<*>::text.name,
                    OptionStub<*>::value.name
                )
            )
            selectionListener = {
                if (isMultiple) Toast.makeText(this@DropdownMainActivity, "SelectedIndex is $it", Toast.LENGTH_SHORT).show()
                else Toast.makeText(this@DropdownMainActivity, "selected item value are " + selection_view.getAdapter()?.getObjectValue(), Toast.LENGTH_SHORT).show()
            }
        }

//MULTIPLE SELECTION

        selection_view_multi.apply {
            setAdapter(
                ExpandableDropDownAdapter(
                    items, "Select many of me..",
                    OptionStub<*>::text.name,
                    OptionStub<*>::value.name
                )
            )
            selectionListener = {
                if (isMultiple) Toast.makeText(this@DropdownMainActivity, "SelectedIndex is $it", Toast.LENGTH_SHORT).show()
                else Toast.makeText(this@DropdownMainActivity, "selected item value are " + selection_view.getAdapter()?.getObjectValue(), Toast.LENGTH_SHORT).show()
            }
        }
    }


NB : Dropdown ini juga sudah bisa menggunakan model OptionStub<*> yang telah biasa digunakan pada template.

Sekian untuk artikel kali ini, semoga tidak ada yg terlewat.

Seperti biasanya, CMIIW.

Rizky Agung Ramadhan has written 10 articles

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>