How to make Expandable RecyclerView using Kotlin

How to make Expandable RecyclerView using Kotlin

Today, I’m going to show you how to make an expandable RecyclerView with smooth animation without using 3rd party libraries.

We’re going to use a custom class named ExpandableLayout, created by SilenceDut.

It’s a custom LinearLayout class that handles the expand-collapse animations using the ValueAnimator.

Making Expandable RecyclerView

First, let’s add the custom class ExpandableLayout in the project.

Go to your package name on the left side and press right click > New > Kotlin File/Class

Give the name ExpandableLayout, choose Class and press OK.

Inside this file, paste the following code:

/**
 * Created by SilenceDut.
 */
class ExpandableLayout : LinearLayout {
    private val PREINIT = -1
    private val CLOSED = 0
    private val EXPANDED = 1
    private val EXPANDING = 2
    private val CLOSING = 3
    var mExpandState = 0
    private var mExpandAnimator: ValueAnimator? = null
    private var mParentAnimator: ValueAnimator? = null
    private var mExpandedViewHeight = 0
    private var sIsInit = true
    private var mExpandDuration = EXPAND_DURATION
    private var mExpandWithParentScroll = false
    private var mExpandScrollTogether = false
    private var mOnExpandListener: OnExpandListener? = null

    constructor(context: Context?) : super(context) {
        init(null)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        init(attrs)
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}

    private fun init(attrs: AttributeSet?) {
        isClickable = true
        orientation = VERTICAL
        this.clipChildren = false
        this.clipToPadding = false
        mExpandState = PREINIT
        if (attrs != null) {
            val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ExpandableLayout)
            mExpandDuration = typedArray.getInt(R.styleable.ExpandableLayout_expDuration, EXPAND_DURATION)
            mExpandWithParentScroll = typedArray.getBoolean(R.styleable.ExpandableLayout_expWithParentScroll, false)
            mExpandScrollTogether = typedArray.getBoolean(R.styleable.ExpandableLayout_expExpandScrollTogether, false)
            typedArray.recycle()
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val childCount = childCount
        check(childCount == 2) { "ExpandableLayout must has two child view !" }
        if (sIsInit) {
            (getChildAt(0).layoutParams as MarginLayoutParams).bottomMargin = 0
            val marginLayoutParams = getChildAt(1).layoutParams as MarginLayoutParams
            marginLayoutParams.bottomMargin = 0
            marginLayoutParams.topMargin = 0
            marginLayoutParams.height = 0
            mExpandedViewHeight = getChildAt(1).measuredHeight
            sIsInit = false
            mExpandState = CLOSED
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        }
    }

    private fun verticalAnimate(startHeight: Int, endHeight: Int) {
        val mViewParent = parent as ViewGroup
        val distance = (y + measuredHeight + mExpandedViewHeight - mViewParent.measuredHeight).toInt()
        val target = getChildAt(1)
        mExpandAnimator = ValueAnimator.ofInt(startHeight, endHeight)
        mExpandAnimator?.addUpdateListener(AnimatorUpdateListener { animation ->
            target.layoutParams.height = animation.animatedValue as Int
            target.requestLayout()
        })
        mExpandAnimator?.addListener(object : AnimatorListenerAdapter() {
            override fun onAnimationEnd(animation: Animator) {
                super.onAnimationEnd(animation)
                if (endHeight - startHeight < 0) {
                    mExpandState = CLOSED
                    if (mOnExpandListener != null) {
                        mOnExpandListener!!.onExpand(false)
                    }
                } else {
                    mExpandState = EXPANDED
                    if (mOnExpandListener != null) {
                        mOnExpandListener!!.onExpand(true)
                    }
                }
            }
        })
        mExpandState = if (mExpandState == EXPANDED) CLOSING else EXPANDING
        mExpandAnimator?.duration = mExpandDuration.toLong()
        if (mExpandState == EXPANDING && mExpandWithParentScroll && distance > 0) {
            mExpandAnimator = parentScroll(distance)
            val animatorSet = AnimatorSet()
            if (mExpandScrollTogether) {
                animatorSet.playSequentially(mExpandAnimator, mParentAnimator)
            } else {
                animatorSet.playTogether(mExpandAnimator, mParentAnimator)
            }
            animatorSet.start()
        } else {
            mExpandAnimator?.start()
        }
    }

    private fun parentScroll(distance: Int): ValueAnimator? {
        val mViewParent = parent as ViewGroup
        mParentAnimator = ValueAnimator.ofInt(0, distance)
        mParentAnimator?.addUpdateListener(object : AnimatorUpdateListener {
            var lastDy = 0
            var dy = 0
            override fun onAnimationUpdate(animation: ValueAnimator) {
                dy = animation.animatedValue as Int - lastDy
                lastDy = animation.animatedValue as Int
                mViewParent.scrollBy(0, dy)
            }
        })
        mParentAnimator?.duration = mExpandDuration.toLong()
        return mExpandAnimator
    }

    fun setExpand(expand: Boolean) {
        if (mExpandState == PREINIT) {
            return
        }
        getChildAt(1).layoutParams.height = if (expand) mExpandedViewHeight else 0
        requestLayout()
        mExpandState = if (expand) EXPANDED else CLOSED
    }

    val isExpanded: Boolean
        get() = mExpandState == EXPANDED

    private fun toggle() {
        if (mExpandState == EXPANDED) {
            close()
        } else if (mExpandState == CLOSED) {
            expand()
        }
    }

    private fun expand() {
        verticalAnimate(0, mExpandedViewHeight)
    }

    private fun close() {
        verticalAnimate(mExpandedViewHeight, 0)
    }

    override fun performClick(): Boolean {
        toggle()
        return super.performClick()
    }

    interface OnExpandListener {
        fun onExpand(expanded: Boolean)
    }

    fun setOnExpandListener(onExpandListener: OnExpandListener?) {
        mOnExpandListener = onExpandListener
    }

    fun setExpandScrollTogether(expandScrollTogether: Boolean) {
        mExpandScrollTogether = expandScrollTogether
    }

    fun setExpandWithParentScroll(expandWithParentScroll: Boolean) {
        mExpandWithParentScroll = expandWithParentScroll
    }

    fun setExpandDuration(expandDuration: Int) {
        mExpandDuration = expandDuration
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        if (mExpandAnimator != null && mExpandAnimator!!.isRunning) {
            mExpandAnimator!!.cancel()
        }
        if (mParentAnimator != null && mParentAnimator!!.isRunning) {
            mParentAnimator!!.cancel()
        }
    }

    companion object {
        private val TAG = ExpandableLayout::class.java.simpleName
        private const val EXPAND_DURATION = 300
    }
}

Now, go to res folder and right-click on the values folder > New > Values resource file.

In the new window, give the name attr and press OK

…and paste the following code:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ExpandableLayout">
        <attr name="expDuration" format="integer|reference"/>
        <attr name="expWithParentScroll" format="boolean"/>
        <attr name="expExpandScrollTogether" format="boolean"/>
    </declare-styleable>
</resources>

Go to your Activity’s XML file and paste the following code to add a RecyclerView

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/itemsrv"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/colorPrimary" />

</RelativeLayout>

And for the cell of the RecyclerView:

<?xml version="1.0" encoding="utf-8"?>
<com.example.expandablerecyclerviewexample.ExpandableLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/expand_layout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@android:color/black">

    <RelativeLayout
        android:id="@+id/question_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/question_textview"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/colorPrimary"
            android:gravity="start"
            android:padding="8dp"
            android:text="Question"
            android:textColor="@android:color/white"
            android:textSize="17sp" />

    </RelativeLayout>

    <LinearLayout
        android:id="@+id/answer_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/question_textview"
        android:background="@color/colorPrimaryDark"
        android:padding="10dp">

        <TextView
            android:id="@+id/answer_textview"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="5dp"
            android:text="Answer"
            android:textColor="@android:color/white"
            android:textSize="16sp" />

    </LinearLayout>


</com.example.expandablerecyclerviewexample.ExpandableLayout>

With ExpandableLayout you need to have 2 layouts inside. One for the cell, in this example is the Question, and one for the view that expand-collapse below the cell when the user clicks on it (Answer).

Set up your RecyclerView in your Activity (MainActivity.kt).

class MainActivity : AppCompatActivity() {
    var itemsData = ArrayList<DataModel>()
    lateinit var adapter: RVAdapter
    lateinit var mcontext: Context

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        mcontext = this.baseContext

        adapter = RVAdapter(itemsData)
        val llm = LinearLayoutManager(this)

        itemsrv.setHasFixedSize(true)
        itemsrv.layoutManager = llm
        getData()
        itemsrv.adapter = adapter
    }

    private fun getData() {
        itemsData = ArrayList()
        itemsData = Data.items
        adapter.notifyDataSetChanged()
        adapter = RVAdapter(itemsData)
    }
}

And for the RecyclerViewAdapter:

class RVAdapter(private val itemsCells: ArrayList<DataModel>) :
    RecyclerView.Adapter<RVAdapter.ViewHolder>() {

    // Save the expanded row position
    private val expandedPositionSet: HashSet<Int> = HashSet()
    lateinit var context: Context

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

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val v = LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_cell, parent, false)
        val vh = ViewHolder(v)
        context = parent.context
        return vh
    }

    override fun getItemCount(): Int {
        return itemsCells.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        // Add data to cells
        holder.itemView.question_textview.text = itemsCells[position].question
        holder.itemView.answer_textview.text = itemsCells[position].answer

        // Expand when you click on cell
        holder.itemView.expand_layout.setOnExpandListener(object :
            ExpandableLayout.OnExpandListener {
            override fun onExpand(expanded: Boolean) {
                if (expandedPositionSet.contains(position)) {
                    expandedPositionSet.remove(position)
                } else {
                    expandedPositionSet.add(position)
                }
            }
        })
        holder.itemView.expand_layout.setExpand(expandedPositionSet.contains(position))
    }
}

Done!

You can find the final project here

Did you find this tutorial helpful?



If you have any questions, please feel free to leave a comment below

Subscribe
Notify of
guest
10 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Artenes

Nice implementation. I’ve also made one some time ago, but it is very simples compared to yours: https://github.com/Artenes/recycler-view-expand-collapse.

Will study your code later to learn more on how to properly implement this expansion animation.

Ayush Jain

Nice read but just curious that why to do so much hard work because we can use one boolean variable in each item of list to make it’s expanded part to visible / invisible. And recyclerview gives pretty nice animation also for that i.e. slide up/down.

MobiAndy

hi,
Nice to read I need clarity if it is possible to add a list in the child (Recycler view inside another Recycler View) kindly make us a tutorial.

paresh

what is Arraylist
i am geetting error unresolved referrence
which library to import for this

paresh

sorry i mean i was asking about Arraylist of DataModel
what is DataModel ..
we can have Arraylist of String or Arraylist of Int etc so got confused at what is DataModel.
I have writen code from the above article .
but now clear after downloading your code its data class you declared,
now it working well.
Thank you John for this article & code !

Den

how to make it possible to open only one at a time item?