Странная проблема с 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>
Юзер может перетащить элемент из 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 шт):
Из-за чего проблема?
Проблема возникает из-за анимации, пока идёт анимация, элемент еще считается частью 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)
}
Проблема возникает из-за того, что 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
}
