Skip to content

Commit 6d07957

Browse files
committed
fix(android): resolve Scrapped or attached views may not be recycled crash
1 parent 5a4638a commit 6d07957

File tree

4 files changed

+145
-23
lines changed

4 files changed

+145
-23
lines changed

android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.reactnativepagerview
22

33
import android.view.View
44
import android.view.ViewGroup
5+
import androidx.recyclerview.widget.LinearLayoutManager
6+
import androidx.recyclerview.widget.RecyclerView
57
import androidx.viewpager2.widget.ViewPager2
68
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
79
import com.facebook.infer.annotation.Assertions
@@ -41,13 +43,49 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
4143
mDelegate.receiveCommand(root, commandId, args)
4244
}
4345

46+
/**
47+
* Replaces ViewPager2's internal LayoutManager references with [safeLayoutManager] via reflection.
48+
* ViewPager2 caches the LayoutManager in multiple internal objects at construction time.
49+
* If we don't update all of them, components like ScrollEventAdapter will use the
50+
* stale (detached) LayoutManager and fail to report page changes correctly.
51+
* Each patch is isolated so a single failure doesn't prevent the others.
52+
*/
53+
private fun patchViewPager2LayoutManager(vp: ViewPager2, safeLayoutManager: SafeLinearLayoutManager) {
54+
val vpFields = vp.javaClass.declaredFields
55+
56+
vpFields.firstOrNull { it.name == "mLayoutManager" }?.let { field ->
57+
field.isAccessible = true
58+
field.set(vp, safeLayoutManager)
59+
}
60+
61+
for (name in arrayOf("mScrollEventAdapter", "mPageTransformerAdapter")) {
62+
vpFields.firstOrNull { it.name == name }?.let { adapterField ->
63+
adapterField.isAccessible = true
64+
val adapter = adapterField.get(vp) ?: return@let
65+
adapter.javaClass.declaredFields.firstOrNull { it.name == "mLayoutManager" }?.let { field ->
66+
field.isAccessible = true
67+
field.set(adapter, safeLayoutManager)
68+
}
69+
}
70+
}
71+
}
72+
4473
public override fun createViewInstance(reactContext: ThemedReactContext): NestedScrollableHost {
4574
val host = NestedScrollableHost(reactContext)
4675
host.id = View.generateViewId()
4776
host.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
4877
host.isSaveEnabled = false
4978
val vp = ViewPager2(reactContext)
5079
vp.adapter = ViewPagerAdapter()
80+
(vp.getChildAt(0) as? RecyclerView)?.let { rv ->
81+
rv.itemAnimator = null
82+
val safeLayoutManager = SafeLinearLayoutManager(reactContext, vp)
83+
safeLayoutManager.orientation = rv.layoutManager?.let {
84+
(it as? LinearLayoutManager)?.orientation
85+
} ?: RecyclerView.HORIZONTAL
86+
rv.layoutManager = safeLayoutManager
87+
patchViewPager2LayoutManager(vp, safeLayoutManager)
88+
}
5189
//https://github.com/callstack/react-native-viewpager/issues/183
5290
vp.isSaveEnabled = false
5391

@@ -88,6 +126,18 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
88126
return host
89127
}
90128

129+
private fun stopScrollIfNeeded(host: NestedScrollableHost) {
130+
val recyclerView = (host.getChildAt(0) as? ViewPager2)?.getChildAt(0) as? RecyclerView
131+
recyclerView?.stopScroll()
132+
}
133+
134+
override fun onDropViewInstance(view: NestedScrollableHost) {
135+
stopScrollIfNeeded(view)
136+
val recyclerView = (view.getChildAt(0) as? ViewPager2)?.getChildAt(0) as? RecyclerView
137+
recyclerView?.swapAdapter(null, false)
138+
super.onDropViewInstance(view)
139+
}
140+
91141
override fun addView(host: NestedScrollableHost, child: View, index: Int) {
92142
PagerViewViewManagerImpl.addView(host, child, index)
93143
}
@@ -99,14 +149,17 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
99149
}
100150

101151
override fun removeView(parent: NestedScrollableHost, view: View) {
152+
stopScrollIfNeeded(parent)
102153
PagerViewViewManagerImpl.removeView(parent, view)
103154
}
104155

105156
override fun removeAllViews(parent: NestedScrollableHost) {
157+
stopScrollIfNeeded(parent)
106158
PagerViewViewManagerImpl.removeAllViews(parent)
107159
}
108160

109161
override fun removeViewAt(parent: NestedScrollableHost, index: Int) {
162+
stopScrollIfNeeded(parent)
110163
PagerViewViewManagerImpl.removeViewAt(parent, index)
111164
}
112165

@@ -206,4 +259,4 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
206259
PageScrollStateChangedEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPageScrollStateChanged"),
207260
PageSelectedEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPageSelected"))
208261
}
209-
}
262+
}

android/src/main/java/com/reactnativepagerview/PagerViewViewManagerImpl.kt

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,7 @@ object PagerViewViewManagerImpl {
7474

7575
fun removeViewAt(parent: NestedScrollableHost, index: Int) {
7676
val pager = getViewPager(parent)
77-
val adapter = pager.adapter as ViewPagerAdapter?
78-
79-
val child = adapter?.getChildAt(index)
80-
81-
if (child != null && child.parent != null) {
82-
(child.parent as? ViewGroup)?.removeView(child)
83-
}
84-
85-
adapter?.removeChildAt(index)
86-
77+
(pager.adapter as? ViewPagerAdapter)?.removeChildAt(index)
8778
debouncedRefreshViewChildrenLayout(pager)
8879
}
8980

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.reactnativepagerview
2+
3+
import android.content.Context
4+
import androidx.recyclerview.widget.LinearLayoutManager
5+
import androidx.recyclerview.widget.RecyclerView
6+
import androidx.viewpager2.widget.ViewPager2
7+
8+
/**
9+
* A LinearLayoutManager that catches the "Scrapped or attached views may not be recycled"
10+
* IllegalArgumentException thrown by RecyclerView during scroll-based recycling.
11+
*
12+
* This crash occurs when RecyclerView's internal removeAndRecycleViewAt calls removeViewAt
13+
* to detach a view, but the view's mParent remains non-null (e.g. due to the view being
14+
* held as a "disappearing view" by ViewGroup). The subsequent recycleViewHolderInternal
15+
* check throws because holder.itemView.getParent() != null.
16+
*
17+
* Since this only happens during teardown (back navigation while mid-scroll), catching
18+
* the exception and aborting the scroll is safe — the view is being destroyed anyway.
19+
*/
20+
class SafeLinearLayoutManager(
21+
context: Context,
22+
private val viewPager: ViewPager2
23+
) : LinearLayoutManager(context) {
24+
25+
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
26+
val pageLimit = viewPager.offscreenPageLimit
27+
if (pageLimit == ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT) {
28+
super.calculateExtraLayoutSpace(state, extraLayoutSpace)
29+
extraLayoutSpace[0] = extraLayoutSpace[0].coerceAtLeast(getPageSize())
30+
extraLayoutSpace[1] = extraLayoutSpace[1].coerceAtLeast(getPageSize())
31+
} else {
32+
val offscreenSpace = getPageSize() * pageLimit
33+
extraLayoutSpace[0] = offscreenSpace
34+
extraLayoutSpace[1] = offscreenSpace
35+
}
36+
}
37+
38+
private fun getPageSize(): Int {
39+
val rv = viewPager.getChildAt(0) as? RecyclerView ?: return 0
40+
return if (orientation == HORIZONTAL) {
41+
rv.width - rv.paddingLeft - rv.paddingRight
42+
} else {
43+
rv.height - rv.paddingTop - rv.paddingBottom
44+
}
45+
}
46+
47+
override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
48+
return try {
49+
super.scrollHorizontallyBy(dx, recycler, state)
50+
} catch (_: IllegalArgumentException) {
51+
0
52+
}
53+
}
54+
55+
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
56+
return try {
57+
super.scrollVerticallyBy(dy, recycler, state)
58+
} catch (_: IllegalArgumentException) {
59+
0
60+
}
61+
}
62+
63+
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
64+
try {
65+
super.onLayoutChildren(recycler, state)
66+
} catch (_: IllegalArgumentException) {
67+
// View is being torn down, layout will not be needed again
68+
}
69+
}
70+
}

android/src/main/java/com/reactnativepagerview/ViewPagerAdapter.kt

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import android.widget.FrameLayout
66
import androidx.recyclerview.widget.RecyclerView.Adapter
77
import java.util.*
88

9+
private fun View.detachFromParent() {
10+
(parent as? ViewGroup)?.removeView(this)
11+
}
912

1013
class ViewPagerAdapter() : Adapter<ViewPagerViewHolder>() {
1114
private val childrenViews: ArrayList<View> = ArrayList()
@@ -17,19 +20,26 @@ class ViewPagerAdapter() : Adapter<ViewPagerViewHolder>() {
1720
override fun onBindViewHolder(holder: ViewPagerViewHolder, index: Int) {
1821
val container: FrameLayout = holder.container
1922
val child = getChildAt(index)
20-
holder.setIsRecyclable(false)
2123

2224
if (container.childCount > 0) {
2325
container.removeAllViews()
2426
}
2527

26-
if (child.parent != null) {
27-
(child.parent as FrameLayout).removeView(child)
28-
}
28+
child.detachFromParent()
2929

3030
container.addView(child)
3131
}
3232

33+
override fun onViewRecycled(holder: ViewPagerViewHolder) {
34+
super.onViewRecycled(holder)
35+
holder.container.removeAllViews()
36+
}
37+
38+
override fun onFailedToRecycleView(holder: ViewPagerViewHolder): Boolean {
39+
holder.container.removeAllViews()
40+
return true
41+
}
42+
3343
override fun getItemCount(): Int {
3444
return childrenViews.size
3545
}
@@ -45,26 +55,24 @@ class ViewPagerAdapter() : Adapter<ViewPagerViewHolder>() {
4555

4656
fun removeChild(child: View) {
4757
val index = childrenViews.indexOf(child)
48-
49-
if(index > -1) {
58+
59+
if (index > -1) {
5060
removeChildAt(index)
5161
}
5262
}
5363

5464
fun removeAll() {
55-
for (index in 1..childrenViews.size) {
56-
val child = childrenViews[index-1]
57-
if (child.parent?.parent != null) {
58-
(child.parent.parent as ViewGroup).removeView(child.parent as View)
59-
}
65+
for (child in childrenViews) {
66+
child.detachFromParent()
6067
}
6168
val removedChildrenCount = childrenViews.size
6269
childrenViews.clear()
6370
notifyItemRangeRemoved(0, removedChildrenCount)
6471
}
6572

6673
fun removeChildAt(index: Int) {
67-
if (index >= 0 && index < childrenViews.size) {
74+
if (index >= 0 && index < childrenViews.size) {
75+
childrenViews[index].detachFromParent()
6876
childrenViews.removeAt(index)
6977
notifyItemRemoved(index)
7078
}

0 commit comments

Comments
 (0)