Rows in GridLayoutManager expand but do not shrink when item content changes

69 Views Asked by At

I have a RecyclerView with a GridLayoutManager. Every item has a button and a invisible content. When the user presses the button, the content switches its visibility.

When the content is invisible and the user press the button for the first time, the item expands, and so do the other items on the same row

Status before pressing any button

Status after pressing a button

The problem arises after the user presses again the button: the row doesn't shrink

The row doesn't shrink

The same happens for every row

Status after pressing the button again

The code I'm using is quite simple... A very basic adapter

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/box"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    android:layout_margin="8dp"
    android:layout_weight="1"
    android:background="#00ff00"
    android:orientation="vertical"
    android:padding="8dp">

    <com.google.android.material.button.MaterialButton
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:text="Press me"
        android:visibility="visible" />

    <View
        android:id="@+id/hidden_view"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="#0000ff"
        android:visibility="gone" />

</LinearLayout>
class TestAdapter : RecyclerView.Adapter<ViewHolder>() {

    val items = arrayOf(false, false, false, false)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(
            ItemTestBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

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

     override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.itemBinding.hiddenView.isVisible = items[position]

        holder.itemBinding.button.setOnClickListener {
            items[position] = !items[position]
            notifyItemChanged(position)
        }

        return
    }
}

open class ViewHolder internal constructor(val itemBinding: ItemTestBinding) :
    RecyclerView.ViewHolder(itemBinding.root)

In a very basic layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:background="#ff0000"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
        app:layout_constraintTop_toTopOf="parent"
        app:spanCount="2"
        tools:itemCount="4"
        tools:listitem="@layout/item_test" />

    <View
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="#ffff00"
        app:layout_constraintTop_toBottomOf="@id/recycler"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.recycler.adapter = TestAdapter()
    }
}

I'm expecting to have the row shrink to the lower item

Please, note that when using a LinearLayout or a GridLayoutManager with a spanCount = 1, the behaviour is correct:

Start

Press

Press again

The problem seems to be related to this one:

Items height in GridLayoutManager after dynamic action but that solution is not working here

This is a minimal project that replicates the problem:

https://mega.nz/file/R7IgmSwD#mwy28aMvMTagfkzSNjArIHANI5cUskG8vN7ZrVp-u94

2

There are 2 best solutions below

1
On BEST ANSWER

After the hint of @RyanM and @Biscuit, I found a solution (or a workaround, I'm not sure about how to consider that).

When I update the item I do not just need to notify the recycler about the fact that I updated that single item, but also that all the items on the same row are updated: when an item is expanded, all the other items on the row change their height. So, calling

notifyItemChanged(position)

will make the layout manager recalculate that item height, but all the other items on the row will keep the "expanded" height, so the row won't shrink.

Calling

notifyDataSetChanged()

will force the GridLayoutManager to recalculate all the view sizes and will make the row shrink as expected.

This is the working code for onBindViewHolder

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.itemBinding.hiddenView.isVisible = items[position]

    holder.itemBinding.button.setOnClickListener {
        items[position] = !items[position]

        // Just a tiny optimization, that's needed only when shrinking
        if (!items[position])
            notifyDataSetChanged()
    }

    return
}
8
On

class MyAdapter: ListAdapter<Int, MyAdapter.MyViewHolder>(MyDiffUtil) {
    private val openedView = mutableSetOf<Int>()

    private fun <E> MutableSet<E>.addOrRemove(item: E) {
        if(contains(item)) remove(item) else add(item)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
        return MyViewHolder(view)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        getItem(position)?.let {
            holder.bind(it, openedView.contains(it)) {
                openedView.addOrRemove(it)
                notifyItemChanged(position)
                if(position%2==0) {
                    notifyItemChanged(position+1)
                } else {
                    notifyItemChanged(position-1)
                }
            }
        }
    }

    class MyViewHolder(private val view: View): RecyclerView.ViewHolder(view) {
        private val mainText = view.findViewById<TextView>(R.id.main_text)
        private val mainButton = view.findViewById<Button>(R.id.main_button)
        private val toggleView = view.findViewById<LinearLayoutCompat>(R.id.toggle_view)

        fun bind(number: Int, isOpen: Boolean, clickListener: () -> Unit) {
            mainText.text = "Hello World $number"
            mainButton.setOnClickListener { clickListener() }
            toggleView.isVisible = isOpen
        }
    }

    object MyDiffUtil: DiffUtil.ItemCallback<Int>() {
        override fun areItemsTheSame(oldItem: Int, newItem: Int): Boolean = oldItem == newItem
        override fun areContentsTheSame(oldItem: Int, newItem: Int): Boolean = oldItem == newItem
    }
}

2 things to note:

  1. The condition to display is wether the item is in the openedView
  2. When the click happened you need to update both the item in the row which is the small calculation for 2 columns

If you have more columns you can just adapt it.