1

I have managed to create an automatic layout update. I have a list of CardView that is displayed inside a RecyclerView. Upon clicking a CardView, said CardView will expand and cause other potentially expanded CardView to retract. I have managed to do that by:

  • Maintaining a variable that holds the position of the CardView to be expanded inside my RecyclerView's adapter
  • Updating said variable when a click happens inside any of the CardView
  • If another CardView is expanded, retract the previous CardView and expand the newly clicked CardView

The code of doing the above can be seen below:

// Inside RecyclerView.Adapter
private var expandedViewHolder = -1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val cardView = LayoutInflater.from(parent.context)
        .inflate(R.layout.recyclerview_cardview, parent, false) as CardView
    val holder = ViewHolder(cardView)

    cardView.setOnClickListener {
        notifyItemChanged(expandedPosition)
        expandedPosition = if (expandedPosition == holder.adapterPosition) -1 else holder.adapterPosition
        notifyItemChanged(expandedPosition)
    }
    return holder
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.headline.text = dataset[position].name
    holder.desc.text = dataset[position].desc
    if (position == expandedPosition) {
        holder.desc.visibility = View.VISIBLE
    }
    else {
        holder.desc.visibility = View.GONE
    }
}

As can be seen, I am forcing the RecyclerView to re-trigger onBindViewHolder using notifyItemChanged(). Inside onBindViewHolder, a CardView's description's visibility is going to be altered accordingly. onBindViewHolder automatically redraws the new layout with an automatic transition/animation.

However, there is a slight problem with retraction when a CardView is expanded and another is retracted like below (note that expanding and retracting one CardView creates no problem):

Sample GIF

If you look closely, upon retraction, the text is partially visible when the CardView's height is fully retracted. Ideally, I want the text to be invisible (alpha set to 0) much faster (like probably 10ms). Thus, my questions are:

  • How do I smoothly retract a CardView while expanding another CardView and adjusting the duration of the alpha value? (The problem, as can be seen from the gif, is that the animation is visually unappealing.)
  • Is there a better way to achieve the simple animation I want besides using notifyItemChanged that re-triggers onBindViewHolder?

Here's a clearer picture of what I mean by the text's visibility being partially visible:

Clearer Picture

Richard
  • 7,037
  • 2
  • 23
  • 76
  • Do you want to keep state of expanded card view after it has been recycled (When user scrolls down)? – toffor Feb 10 '20 at 10:25
  • @toffor Yes, I do want to keep the expanded state after it has been recycled. – Richard Feb 10 '20 at 11:03
  • 1
    android:animateLayoutChanges="true" in your card view and inside text based card click visible gone the tetxview it will work – ThavaSelvan Feb 10 '20 at 11:17
  • Did you try calling ``TransitionManager.beginDelayedTransition(cardView, Slide())`` before ``holder.desc.visibility = View.GONE`` – Perihan Mirkelam Feb 03 '21 at 22:50
  • if `notifyItemChanged(expandedPosition)` the value is `-1` won't it cause ArrayOutofBoundException ? For my case your code doesn't work. – Sharp Edge Jun 11 '21 at 14:24

1 Answers1

1

Okey there are some tricky cases with visibility animations. For example you can take a look this post. Layout animation not working on first run

android:animateLayoutChanges="true"

animateLayoutChanges is enough most of the time. But in recyclerView, developer should restore the previous state of viewHolder because recyclerView reuses view holders so if you do not check the real state of view holder according to position it can be wrong (For example two different position uses same holder and one of them expanded and the other collapsed). Now there is a new problem, which is if you use animate layout changes there no easy way to expand or collapse description text without animation. You can try to disable, enable parent layout transition but its not working for me.

Another way is changing textViews height 0 to TextView.getHeight() or opposite but in this case you don't know textviews height because its height is 0 and you don want to expand it unless user click it. But you can find TextView's desired height. This is working code for me. Please let me know if it helps you.

class Adapter : RecyclerView.Adapter<Adapter.Holder>() {

    private var expandedHolderPosition = -1

    private var itemList = emptyList<Item>()

    private var getViewHolder: (position: Int) -> Holder? = { null }

    override fun getItemCount() = itemList.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val inflatedView = parent.inflate(R.layout.item, false)
        return Holder(inflatedView)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        val currentItem = itemList[position]
        holder.bindItem(currentItem)
    }

    fun updateList(items: List<Item>) {
        this.expandedHolderPosition = -1
        this.itemList = items
        this.notifyDataSetChanged()
    }

    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        getViewHolder = { position ->
            recyclerView.findViewHolderForAdapterPosition(position) as? Holder
        }
    }

    inner class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {

        private val textTitle = itemView.textTitle
        private val textDescription = itemView.textDescription
        private var desiredTextViewHeight = 0
        private val isExpanded get() = adapterPosition == expandedHolderPosition


        fun bindItem(item: Item) {

            textTitle.text = item.title.toString()

            textDescription.setText(item.description)

            textDescription.post {

                desiredTextViewHeight = with(textDescription) {
                    lineHeight * lineCount + layout.bottomPadding - layout.topPadding
                }

                if (isExpanded) {
                    showDescription(false)
                } else {
                    hideDescription(false)
                }
            }

            itemView.setOnClickListener {

                val position = adapterPosition

                //Its in open state close it.
                if (isExpanded) {
                    expandedHolderPosition = -1
                    hideDescription(true)
                } else {
                    getViewHolder(expandedHolderPosition)?.hideDescription(true)
                    showDescription(true)
                    expandedHolderPosition = position
                }
            }
        }

        private fun hideDescription(animate: Boolean) {
            logDebug("hideDescription")
            if (animate) {
                changeHeightWithAnimation(desiredTextViewHeight, 0)
            } else {
                updateDescriptionHeight(0)
            }
        }

        private fun showDescription(animate: Boolean) {
            logDebug("showDescription")
            if (animate) {
                changeHeightWithAnimation(0, desiredTextViewHeight)
            } else {
                updateDescriptionHeight(desiredTextViewHeight)
            }
        }

        private fun changeHeightWithAnimation(from: Int, to: Int) {
            val animator = ValueAnimator.ofInt(from, to)
            animator.duration = 300
            animator.addUpdateListener { animation: ValueAnimator ->
                updateDescriptionHeight(animation.animatedValue as Int)
            }
            animator.start()
        }

        private fun updateDescriptionHeight(newHeight: Int) {
            textDescription.updateLayoutParams<ViewGroup.LayoutParams> {
                height = newHeight
            }
        }

    }
}

data class Item(val title: Int, @StringRes val description: Int = R.string.lorem)

Layout file for user item.

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:orientation="vertical">

        <TextView
            android:id="@+id/textTitle"
            android:layout_width="match_parent"
            android:layout_height="48dp"
            android:gravity="center_vertical"
            tools:text="Title" />

        <TextView
            android:id="@+id/textDescription"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:gravity="center_vertical"
            tools:layout_height="wrap_content"
            tools:text="Description" />

    </LinearLayout>

</com.google.android.material.card.MaterialCardView>
toffor
  • 1,219
  • 1
  • 12
  • 21
  • Thank you for your answer. I will check it out later. For now, though, I still don't understand how the link you provided is relevant to my case in particular. In the link you provided, the animation **does not run** because the OP set the view's visibility to `View.GONE` and animated on a view that is not there. In my scenario, upon setting view's visibility to `View.GONE`, it does not immediately hide the view (instead, it creates that fade out animation which I don't want). I suspect this has something to do with `onBindViewHolder` redrawing mechanics. Could you explain why this happened? – Richard Feb 11 '20 at 04:36
  • Link is for explaining why i code like above. For your question can you try recyclerView.setItemAnimator(null) or adapter.notifyDataSetChanged() instead of adatepr.notifyItemChanged(position)? Because; notifyItemChanged() method runs with animation. – toffor Feb 11 '20 at 07:30
  • Your solution above is great. It works. However, there's a slight bug: when you use `getViewHolder`, the `ViewHolder` is sometimes not visible and might cause it not to run `hideDescription`. One such scenario is when I expand a `CardView` on top of the `RecyclerView`, and then expand another one below. Sometimes, there will be two `CardView` expanded. – Richard Feb 12 '20 at 14:51
  • I also have a few question: `1.` What is `updateList` for? It doesn't seem to be used. `2.` I tried getting `desiredTextViewHeight` without using `.post{}`. However, it returned me an error saying that `layout` must not be null. When I tried it with `.post{}`, it works perfectly. What does `.post{}` do to make the layout somehow not null? – Richard Feb 12 '20 at 14:52
  • 1. a) Its not important to run hideDescription on a invisible view holder because its already invisible and when it becomes visible it will start with collapsed state. Because we are checking in bindItem. b) I do not know why, i should check. 2. a) updateList for setting the new data to adapter, you can use another name or solution. Note that itemList is empty when adapter first initialized. b) View.post runs after that view completes its initialization. It can be helpful similar cases. 3) if your problem still exists i can try an example with itemAnimator. – toffor Feb 12 '20 at 15:09
  • `ViewHolder` does not start with a collapsed state if expanded previously. I tried expanding one ViewHolder and then scrolling down (until that ViewHolder is recycled for other use). Then, if I happen to expand another ViewHolder *that was not the previous recycled ViewHolder*, there will be two ViewHolders that are expanded if I scroll up again. – Richard Feb 12 '20 at 15:14
  • Then i might have missed the case, i will checkout now. – toffor Feb 12 '20 at 15:17
  • There is a good explanation in here https://stackoverflow.com/a/13840315/7652387. And also i will try to figure out if i can use itemAnimator in this case. – toffor Feb 12 '20 at 15:23
  • 1
    I eventually used your approach to construct my own solution. I figured that `itemAnimator` is not the right place to place animation that is not related to the changing of data in ViewHolder. – Richard Feb 25 '20 at 14:02