0

I have a nested recyclerview. Parent recyclerview adapter can add a data to child by a button, then child recyclerview adapter can remove a data in child by a button. The preview is like below.

The preview

Adding data seems fine, but when removing data i got index out of bound. I have called notifyDataSetChanged() in add or remove function.

My parent adapter code :

class FormPlanParentAdapter :
    RecyclerView.Adapter<FormPlanParentAdapter.ViewHolder>() {

    private val data: MutableList<MutableList<ItemPlan>> = mutableListOf()

    private val viewPool = RecyclerView.RecycledViewPool()

    fun setData(newData: List<MutableList<ItemPlan>>) {
        data.clear()
        data.addAll(newData)
        notifyDataSetChanged()
    }

    fun getData(): String = Gson().toJson(data)

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

    override fun getItemCount(): Int = data.size

    override fun onBindViewHolder(
        holder: ViewHolder,
        position: Int
    ) {
        val listPlans = data[position]
        val childLayoutManager = LinearLayoutManager(
            holder.recyclerView.context,
            LinearLayoutManager.VERTICAL,
            false
        )

        val childAdapter = FormPlanChildAdapter(listPlans, object : PlanParentCallback {
            override fun deletePlan(position: Int) {
                listPlans.removeAt(position)
                notifyDataSetChanged()
            }
        })

        holder.recyclerView.apply {
            layoutManager = childLayoutManager
            adapter = childAdapter
            setRecycledViewPool(viewPool)
        }

        holder.textView.text = "Hari ke ${position + 1}"

        holder.imageButton.setOnClickListener {
            listPlans.add(ItemPlan())
            notifyDataSetChanged()
        }
    }

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val recyclerView: RecyclerView = itemView.rv_plan_child
        val textView: TextView = itemView.tv_days_of
        val imageButton: ImageButton = itemView.ib_add
    }

    interface PlanParentCallback {
        fun deletePlan(position: Int)
    }

}

My child adapter code :

class FormPlanChildAdapter(
    private val listPlans: List<ItemPlan>,
    private val callback: FormPlanParentAdapter.PlanParentCallback
) :
    RecyclerView.Adapter<FormPlanChildAdapter.ViewHolder>() {

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

    override fun getItemCount(): Int = listPlans.size

    override fun onBindViewHolder(
        holder: ViewHolder,
        position: Int
    ) {
        val itemPlan = listPlans[position]
        holder.etPlan.setText(itemPlan.plan)
        holder.etPlan.doAfterTextChanged {
            listPlans[position].plan = it.toString()
        }

        if (position == 0) {
            holder.ibDelete.invisible()
        } else {
            holder.ibDelete.visible()
        }

        holder.ibDelete.setOnClickListener {
            if (listPlans.size > 1) {
                callback.deletePlan(position)
            }
        }
    }

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val etPlan: EditText = itemView.et_plan
        val ibDelete: ImageButton = itemView.ib_delete
    }
}

Any help appreciated, thank you.

Erwin Kurniawan A
  • 958
  • 2
  • 9
  • 17

2 Answers2

1

Reason: The old TextWatchers and adapter position mess up when the dataset changed.

notifyDatasetChanged triggers onBindViewHolder, and then EditText's TextWatcher is being notified because of this line holder.etPlan.setText(itemPlan.plan)

Since etPlan has already an active TextWatcher from previous binding, it tries to notify it with the old position of the adapter.

Quick answer:

Keep the TextWatcher in your ViewHolder, remove it from EditText on onBindViewHolder, and set it to the EditText again after setting the EditText's value.

So, update your child adapter's onBindViewHolder as

    override fun onBindViewHolder(
            holder: ViewHolder,
            position: Int
    ) {
        val itemPlan = listPlans[position]
        holder.etPlan.removeTextChangedListener(holder.textWatcher)
        holder.etPlan.setText(itemPlan.plan)
        holder.textWatcher = holder.etPlan.doAfterTextChanged {
            listPlans[position].plan = it.toString()
        }

       ...
    }

and also update it's ViewHolder like:

    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val etPlan: EditText = itemView.et_plan
        val ibDelete: ImageButton = itemView.ib_delete
        var textWatcher: TextWatcher? = null
    }

But, it's not recommended setting listeners on onBindViewHolder due to performance concerns. Please check this and this.

Here is another workaround:

Move all the TextWatcher things to Adapter via a listener, and prevent notifying the TextWatcher if it's not necessary.

interface TextChangeListener {
    fun onTextChange(text: String, position: Int)
}

class FormPlanChildAdapter(...) :
    RecyclerView.Adapter<FormPlanChildAdapter.ViewHolder>(), TextChangeListener {

    override fun onBindViewHolder(
        holder: ViewHolder,
        position: Int
    ) {
        val itemPlan = listPlans[position]
        holder.setPlan(itemPlan.plan)
        
        ...
    }

    override fun onTextChanged(text: String, position: Int) {
        listPlans[position].plan = text
    }

    inner class ViewHolder(itemView: View, textChangeListener: TextChangeListener) :
        RecyclerView.ViewHolder(itemView) {
        private var disableTextChangeListener = false
        private val etPlan: EditText = itemView.et_plan
        val ibDelete: ImageButton = itemView.ib_delete

        init {
            etPlan.doAfterTextChanged {
                if (!disableTextChangeListener) {
                    textChangeListener.onTextChanged(it.toString(), adapterPosition)
                }
            }
        }

        fun setPlan(plan: String, notifyTextChangeListener: Boolean = false) {
            if (notifyTextChangeListener) {
                etPlan.setText(plan)
            } else {
                disableTextChangeListener = true
                etPlan.setText(plan)
                disableTextChangeListener = false
            }
        }
    }
}
fatih ergin
  • 265
  • 4
  • 10
  • Thanks for the answer, it was because the listener! Also thanks for mentioning about performance concerns. But i can't implement the another answer, when i add the interface in adapter class, it errors "There's a cycle in the inheritance hierarchy for this type". Do you know why? – Erwin Kurniawan A Nov 21 '20 at 23:27
  • You may have defined `TextChangeListener` inside `FormPlanChildAdapter` which implements `TextChangeListener`. Interfaces shouldn't be placed inside of the class that is inheriting from. – fatih ergin Nov 21 '20 at 23:47
  • I see, so where should it be placed? – Erwin Kurniawan A Nov 22 '20 at 00:35
  • Anywhere you want, except to inside of FormPlanChildAdapter. You can prefer putting it to the same file with the adapter just like the answer of this question. – fatih ergin Nov 22 '20 at 01:18
0

Please try doing

listPlans.removeAt(position)
notifyItemRemoved(position)

instead of

listPlans.removeAt(position)
notifyDataSetChanged()
Sarah Khan
  • 836
  • 7
  • 11