Странная проблема с RecyclerView: его LayoutManager продолжает обрабатывать дочерние элементы, даже после того как они были удалены с экрана

Я экспериментирую с функционалом на экране, где RecyclerView расположен в нижней части.

XML:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/parent_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipChildren="false">

    <!-- RecyclerView bound to the bottom -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:background="#EEEEEE"
        android:clipToPadding="false"
        android:clipChildren="false" />
</RelativeLayout>

enter image description here

Юзер может перетащить элемент из RecyclerView и дропнуть его в RelativeLayout. Все работает правильно, но если юзер быстро перетянет и бросит элемент, приложение падает.

Похоже, что краш возникает из-за race condition: LayoutManager RecyclerView продолжает обрабатывать дочерние элементы в тот момент, когда параметры элемента уже поменялись (с RecyclerView.LayoutParams на RelativeLayout.LayoutParams после удаления). Другими словами, элемент удаляется из адаптера (и меняет тип параметров), пока RecyclerView ещё не завершил внутренние обновления. Из-за этого происходит некорректное приведение типов, и приложение крашится.

Есть минимальный воспроизводимый пример, который можно просто скопировать в проект, чтобы было понятнее, в чём проблема:

import android.annotation.SuppressLint
import android.graphics.Canvas
import android.graphics.Point
import android.os.Bundle
import android.view.DragEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs

class MainActivity4 : AppCompatActivity() {
    private lateinit var parentLayout: RelativeLayout
    private lateinit var recyclerView: RecyclerView
    private lateinit var adapter: DraggableAdapter
    private var removalTriggered = false

    private var parentLeft = 0
    private var parentTop = 0
    private var recyclerLeft = 0
    private var recyclerTop = 0

    @SuppressLint("ClickableViewAccessibility")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main4)

        parentLayout = findViewById(R.id.parent_layout)
        recyclerView = findViewById(R.id.recyclerView)
        recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)

        adapter = DraggableAdapter(removed = {
            removalTriggered = false
        })

        recyclerView.adapter = adapter

        // Get parent's location on screen.
        val parentLoc = IntArray(2)
        parentLayout.getLocationOnScreen(parentLoc)
        parentLeft = parentLoc[0]
        parentTop = parentLoc[1]

        // Get recyclerView's location on screen.
        val recLoc = IntArray(2)
        recyclerView.getLocationOnScreen(recLoc)
        recyclerLeft = recLoc[0]
        recyclerTop = recLoc[1]

        parentLayout.setOnDragListener { view, event ->
            when (event.action) {
                DragEvent.ACTION_DRAG_STARTED -> true

                DragEvent.ACTION_DRAG_ENTERED -> {
                    view.invalidate()
                    true
                }

                DragEvent.ACTION_DRAG_LOCATION -> true

                DragEvent.ACTION_DROP -> {
                    // Retrieve the original view from localState.
                    val draggedView = event.localState as? View

                    draggedView?.let { view ->
                        // Remove the view from its old parent.
                        (view.parent as? ViewGroup)?.removeView(view)

                        // Retrieve the stored initial touch offset from the view's tag.
                        @Suppress("UNCHECKED_CAST")
                        val initialTouch = view.getTag(R.id.initial_touch_point) as? Pair<Int, Int>
                        // Use the stored touch offsets, defaulting to the center if not available.
                        val touchOffsetX = initialTouch?.first ?: (view.width / 2)
                        val touchOffsetY = initialTouch?.second ?: (view.height / 2)

                        // Calculate new position based on the drop coordinates and the initial touch offsets.
                        // event.x and event.y are assumed to be in the coordinate space of the parent layout.
                        var newX = event.x - touchOffsetX
                        var newY = event.y - touchOffsetY

                        // Optionally, clamp the new position so the view stays within the parent's bounds.
                        newX = newX.coerceIn(0f, (parentLayout.width - view.width).toFloat())
                        newY = newY.coerceIn(0f, (parentLayout.height - view.height).toFloat())

                        view.x = newX
                        view.y = newY

                        // Update layout parameters if needed.
                        view.layoutParams = RelativeLayout.LayoutParams(view.width, view.height)
                        parentLayout.addView(view)
                        view.visibility = View.VISIBLE
                        view.bringToFront()
                    }

                    true
                }

                DragEvent.ACTION_DRAG_ENDED -> {
                    view.invalidate()
                    true
                }

                else -> false
            }
        }

        var itemTouchHelper: ItemTouchHelper? = null

        val swipeCallback: ItemTouchHelper.SimpleCallback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.UP) {
            override fun onMove(
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                target: RecyclerView.ViewHolder
            ): Boolean = false

            override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
                return 1f
            }

            override fun onChildDraw(
                c: Canvas,
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                dX: Float,
                dY: Float,
                actionState: Int,
                isCurrentlyActive: Boolean
            ) {
                // Let the default implementation update the translation.
                super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)

                // Define a removal threshold: 80% of the item's height.
                val removalThreshold = viewHolder.itemView.height * 0.8f

                // Get the current vertical translation.
                val currentTranslationY = viewHolder.itemView.translationY

                // If the view's vertical translation meets/exceeds the threshold, trigger removal.
                if (abs(currentTranslationY) >= removalThreshold && !removalTriggered && isCurrentlyActive) {
                    removalTriggered = true

                    recyclerView.post {
                        onSwiped(viewHolder = viewHolder, direction = ItemTouchHelper.UP)
                    }
                }
            }

            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                val view: View = viewHolder.itemView
                val position: Int = viewHolder.bindingAdapterPosition

                if (position != RecyclerView.NO_POSITION) {
                    adapter.removeItem(position)
                    view.visibility = View.INVISIBLE
                }

                val shadowBuilder = MyDragShadowBuilder(view = view)
                view.startDragAndDrop(null, shadowBuilder, view, 0)
            }
        }

        itemTouchHelper = ItemTouchHelper(swipeCallback)
        itemTouchHelper.attachToRecyclerView(recyclerView)
    }
}

private class MyDragShadowBuilder(view: View) : View.DragShadowBuilder(view) {
    override fun onProvideShadowMetrics(shadowSize: Point, shadowTouchPoint: Point) {
        val width = view.width
        val height = view.height
        shadowSize.set(width, height)
        val clampedX = width / 2
        val clampedY = height / 2
        shadowTouchPoint.set(clampedX, clampedY)
    }

    override fun onDrawShadow(canvas: Canvas) {
        view.draw(canvas)
    }
}

class DraggableAdapter(
    val removed: () -> Unit
) : RecyclerView.Adapter<DraggableAdapter.DraggableViewHolder>() {

    // Use a mutable list so we can remove items.
    private val items: MutableList<String> = mutableListOf()

    init {
        for (i in 0 until 10) {
            items.add("Drag me $i")
        }
    }

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

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: DraggableViewHolder, position: Int) {
        holder.textView.text = items[position]
    }

    fun removeItem(position: Int) {
        if (position in 0 until items.size) {
            items.removeAt(position)
            notifyItemRemoved(position)
        }

        removed()
    }

    class DraggableViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView)
}

Ошибка:

FATAL EXCEPTION: main (Ask Gemini)
                 Process: com.dragndrop, PID: 12824
                 java.lang.ClassCastException: android.widget.RelativeLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams
                    at androidx.recyclerview.widget.RecyclerView.getChildViewHolderInt(RecyclerView.java:5422)
                    at androidx.recyclerview.widget.RecyclerView$6.getChildViewHolder(RecyclerView.java:1043)
                    at androidx.recyclerview.widget.ChildHelper.findHiddenNonRemovedView(ChildHelper.java:257)
                    at androidx.recyclerview.widget.RecyclerView$Recycler.getScrapOrHiddenOrCachedHolderForPosition(RecyclerView.java:7409)
                    at androidx.recyclerview.widget.RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline(RecyclerView.java:6890)
                    at androidx.recyclerview.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:6853)
                    at androidx.recyclerview.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:6849)
                    at androidx.recyclerview.widget.LinearLayoutManager$LayoutState.next(LinearLayoutManager.java:2422)
                    at androidx.recyclerview.widget.LinearLayoutManager.layoutChunk(LinearLayoutManager.java:1722)
                    at androidx.recyclerview.widget.LinearLayoutManager.fill(LinearLayoutManager.java:1682)
                    at androidx.recyclerview.widget.LinearLayoutManager.onLayoutChildren(LinearLayoutManager.java:747)
                    at androidx.recyclerview.widget.RecyclerView.dispatchLayoutStep2(RecyclerView.java:4737)
                    at androidx.recyclerview.widget.RecyclerView.onMeasure(RecyclerView.java:4133)
                    at android.view.View.measure(View.java:27853)
                    at android.widget.RelativeLayout.measureChildHorizontal(RelativeLayout.java:735)
                    at android.widget.RelativeLayout.onMeasure(RelativeLayout.java:481)
                    at android.view.View.measure(View.java:27853)
                    at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7403)
                    at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
                    at androidx.appcompat.widget.ContentFrameLayout.onMeasure(ContentFrameLayout.java:141)
                    at android.view.View.measure(View.java:27853)
                    at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7403)
                    at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1552)
                    at android.widget.LinearLayout.measureVertical(LinearLayout.java:842)
                    at android.widget.LinearLayout.onMeasure(LinearLayout.java:721)
                    at android.view.View.measure(View.java:27853)
                    at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7403)
                    at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
                    at android.view.View.measure(View.java:27853)
                    at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7403)
                    at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1552)
                    at android.widget.LinearLayout.measureVertical(LinearLayout.java:842)
                    at android.widget.LinearLayout.onMeasure(LinearLayout.java:721)
                    at android.view.View.measure(View.java:27853)
                    at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:7403)
                    at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
                    at com.android.internal.policy.DecorView.onMeasure(DecorView.java:1390)
                    at android.view.View.measure(View.java:27853)
                    at android.view.ViewRootImpl.performMeasure(ViewRootImpl.java:4882)
                    at android.view.ViewRootImpl.measureHierarchy(ViewRootImpl.java:3461)
                    at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:3767)
                    at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:3151)
                    at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:11068)
                    at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1321)
                    at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1329)
                    at android.view.Choreographer.doCallbacks(Choreographer.java:930)
                    at android.view.Choreographer.doFrame(Choreographer.java:859)
                    at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1303)
                    at android.os.Handler.handleCallback(Handler.java:942)
                    at android.os.Handler.dispatchMessage(Handler.java:99)
                    at android.os.Looper.loopOnce(Looper.java:226)

Дайте знать если будут какие то идеи


Ответы (2 шт):

Автор решения: Vladimir Shal'kov

Из-за чего проблема?
Проблема возникает из-за анимации, пока идёт анимация, элемент еще считается частью RecyclerView.
Когда анимация заканчивается, внутри класса RecyclerView, вызывается метод, который кастит LayoutParams перетаскиваемой вью до RecyclerView.LayoutParams, если будет не совпадение мы получим креш. каст

Решение 1. Быстрое с костылём:
Просто убрать анимацию у элементов списка: recyclerView.itemAnimator = null, тогда элемент будет быстро удаляться из RecyclerView (до строчки установки RelativeLayout.LayoutParams)

Решение 2. Нормальное:
Работать копией, пример рабочего кода:

class MainActivity : AppCompatActivity() {
    private lateinit var parentLayout: RelativeLayout
    private lateinit var recyclerView: RecyclerView
    private lateinit var adapter: DraggableAdapter
    private var removalTriggered = false

    private var parentLeft = 0
    private var parentTop = 0
    private var recyclerLeft = 0
    private var recyclerTop = 0

    @SuppressLint("ClickableViewAccessibility")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        parentLayout = findViewById(R.id.parent_layout)
        recyclerView = findViewById(R.id.recyclerView)
        recyclerView.layoutManager =
            LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)

        adapter = DraggableAdapter(removed = {
            recyclerView.requestLayout()
            removalTriggered = false
        })

        recyclerView.adapter = adapter

        // Get parent's location on screen.
        val parentLoc = IntArray(2)
        parentLayout.getLocationOnScreen(parentLoc)
        parentLeft = parentLoc[0]
        parentTop = parentLoc[1]

        // Get recyclerView's location on screen.
        val recLoc = IntArray(2)
        recyclerView.getLocationOnScreen(recLoc)
        recyclerLeft = recLoc[0]
        recyclerTop = recLoc[1]

        parentLayout.setOnDragListener { view, event ->
            when (event.action) {
                DragEvent.ACTION_DRAG_STARTED -> true

                DragEvent.ACTION_DRAG_ENTERED -> {
                    view.invalidate()
                    true
                }

                DragEvent.ACTION_DRAG_LOCATION -> true

                DragEvent.ACTION_DROP -> {
                    // Retrieve the original view from localState.
                    val draggedView = event.localState as? TextView

                    draggedView?.let { view ->
                        // Remove the view from its old parent.

                        val clone = cloneTextView(view)

                        // Retrieve the stored initial touch offset from the view's tag.
                        @Suppress("UNCHECKED_CAST")
                        val initialTouch = clone.getTag(R.id.initial_touch_point) as? Pair<Int, Int>
                        // Use the stored touch offsets, defaulting to the center if not available.
                        val touchOffsetX = initialTouch?.first ?: (clone.width / 2)
                        val touchOffsetY = initialTouch?.second ?: (clone.height / 2)

                        // Calculate new position based on the drop coordinates and the initial touch offsets.
                        // event.x and event.y are assumed to be in the coordinate space of the parent layout.
                        var newX = event.x - touchOffsetX
                        var newY = event.y - touchOffsetY

                        // Optionally, clamp the new position so the view stays within the parent's bounds.
                        newX = newX.coerceIn(0f, (parentLayout.width - clone.width).toFloat())
                        newY = newY.coerceIn(0f, (parentLayout.height - clone.height).toFloat())

                        clone.x = newX
                        clone.y = newY

                        // Update layout parameters if needed.
                        clone.layoutParams = RelativeLayout.LayoutParams(view.width, view.height)
                        parentLayout.addView(clone)
                        clone.bringToFront()

                        (view.parent as? ViewGroup)?.removeView(view)
                    }

                    true
                }

                DragEvent.ACTION_DRAG_ENDED -> {
                    view.invalidate()
                    true
                }

                else -> false
            }
        }

        var itemTouchHelper: ItemTouchHelper? = null

        val swipeCallback: ItemTouchHelper.SimpleCallback =
            object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.UP) {
                override fun onMove(
                    recyclerView: RecyclerView,
                    viewHolder: RecyclerView.ViewHolder,
                    target: RecyclerView.ViewHolder
                ): Boolean = false

                override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
                    return 1f
                }

                override fun onChildDraw(
                    c: Canvas,
                    recyclerView: RecyclerView,
                    viewHolder: RecyclerView.ViewHolder,
                    dX: Float,
                    dY: Float,
                    actionState: Int,
                    isCurrentlyActive: Boolean
                ) {
                    // Let the default implementation update the translation.
                    super.onChildDraw(
                        c,
                        recyclerView,
                        viewHolder,
                        dX,
                        dY,
                        actionState,
                        isCurrentlyActive
                    )

                    // Define a removal threshold: 80% of the item's height.
                    val removalThreshold = viewHolder.itemView.height * 0.8f

                    // Get the current vertical translation.
                    val currentTranslationY = viewHolder.itemView.translationY

                    // If the view's vertical translation meets/exceeds the threshold, trigger removal.
                    if (abs(currentTranslationY) >= removalThreshold && !removalTriggered && isCurrentlyActive) {
                        removalTriggered = true

                        recyclerView.post {
                            onSwiped(viewHolder = viewHolder, direction = ItemTouchHelper.UP)
                        }
                    }
                }

                override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                    val view: View = viewHolder.itemView
                    val position: Int = viewHolder.adapterPosition

                    if (position != RecyclerView.NO_POSITION) {
                        adapter.removeItem(position)
                        view.visibility = View.INVISIBLE
                    }

                    val shadowBuilder = MyDragShadowBuilder(view = view)
                    view.startDragAndDrop(null, shadowBuilder, view, 0)
                }
            }

        itemTouchHelper = ItemTouchHelper(swipeCallback)
        itemTouchHelper.attachToRecyclerView(recyclerView)
    }

    private fun cloneTextView(original: TextView): TextView {
        return TextView(original.context).apply {
            layoutParams = RelativeLayout.LayoutParams(original.width, original.height)
            text = original.text
            textSize = original.textSize / resources.displayMetrics.scaledDensity
            typeface = original.typeface
            setPadding(
                original.paddingLeft,
                original.paddingTop,
                original.paddingRight,
                original.paddingBottom
            )
            setBackgroundColor((original.background as? ColorDrawable)?.color ?: Color.BLUE)
            setTextColor(original.currentTextColor)
            gravity = original.gravity
        }
    }
}

private class MyDragShadowBuilder(view: View) : View.DragShadowBuilder(view) {
    override fun onProvideShadowMetrics(shadowSize: Point, shadowTouchPoint: Point) {
        val width = view.width
        val height = view.height
        shadowSize.set(width, height)
        val clampedX = width / 2
        val clampedY = height / 2
        shadowTouchPoint.set(clampedX, clampedY)
    }

    override fun onDrawShadow(canvas: Canvas) {
        view.draw(canvas)
    }
}

class DraggableAdapter(
    val removed: () -> Unit
) : RecyclerView.Adapter<DraggableAdapter.DraggableViewHolder>() {

    // Use a mutable list so we can remove items.
    private val items: MutableList<String> = mutableListOf()

    init {
        for (i in 0 until 10) {
            items.add("Drag me $i")
        }
    }

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

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: DraggableViewHolder, position: Int) {
        holder.textView.text = items[position]
    }

    fun removeItem(position: Int) {
        if (position in 0 until items.size) {
            items.removeAt(position)
            notifyItemRemoved(position)
        }

        removed()
    }

    class DraggableViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView)
}

→ Ссылка
Автор решения: Airat Galiullin

Проблема возникает из-за того, что RecyclerView продолжает держать ссылку на элемент, который уже удалён из него и перемещён в другой контейнер. Когда RecyclerView пытается измерить или обработать этот элемент (а его параметры уже стали типом RelativeLayout.LayoutParams), возникает краш из-за неправильного приведения типов.

Решение: не следует переиспользовать или перемещать элемент напрямую из RecyclerView в другой контейнер. Вместо этого нужно:

  • Создавать копию элемента для переноса (например, новую View).
  • Скрывать исходный элемент и удалять его из RecyclerView, а потом создавать новый элемент во втором контейнере.
  • Никогда не менять LayoutParams элемента, пока RecyclerView полностью не закончил обновления.

Исправь метод ACTION_DROP следующим образом:

DragEvent.ACTION_DROP -> {
    val draggedView = event.localState as? View

    draggedView?.let { view ->
        val snapshot = createViewSnapshot(view)

        // Координаты drop'а в родительском контейнере
        val initialTouch = view.getTag(R.id.initial_touch_point) as? Pair<Int, Int>
        val offsetX = initialTouch?.first ?: (view.width / 2)
        val offsetY = initialTouch?.second ?: (view.height / 2)

        var newX = event.x - offsetX
        var newY = event.y - offsetY

        newX = newX.coerceIn(0f, (parentLayout.width - snapshot.width).toFloat())
        newY = newY.coerceIn(0f, (parentLayout.height - snapshot.height).toFloat())

        snapshot.x = newX
        snapshot.y = newY

        parentLayout.addView(snapshot)
    }
    true
}

И создай метод для копирования View:

private fun createViewSnapshot(view: View): View {
    val snapshot = LayoutInflater.from(view.context)
        .inflate(R.layout.item_draggable, parentLayout, false) as TextView
    snapshot.layoutParams = RelativeLayout.LayoutParams(view.width, view.height)
    snapshot.text = (view as? TextView)?.text ?: ""
    return snapshot
}
→ Ссылка