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.