Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.reactnativepagerview

import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.facebook.infer.annotation.Assertions
Expand Down Expand Up @@ -41,13 +43,49 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
mDelegate.receiveCommand(root, commandId, args)
}

/**
* Replaces ViewPager2's internal LayoutManager references with [safeLayoutManager] via reflection.
* ViewPager2 caches the LayoutManager in multiple internal objects at construction time.
* If we don't update all of them, components like ScrollEventAdapter will use the
* stale (detached) LayoutManager and fail to report page changes correctly.
* Each patch is isolated so a single failure doesn't prevent the others.
*/
private fun patchViewPager2LayoutManager(vp: ViewPager2, safeLayoutManager: SafeLinearLayoutManager) {
val vpFields = vp.javaClass.declaredFields

vpFields.firstOrNull { it.name == "mLayoutManager" }?.let { field ->
field.isAccessible = true
field.set(vp, safeLayoutManager)
}

for (name in arrayOf("mScrollEventAdapter", "mPageTransformerAdapter")) {
vpFields.firstOrNull { it.name == name }?.let { adapterField ->
adapterField.isAccessible = true
val adapter = adapterField.get(vp) ?: return@let
adapter.javaClass.declaredFields.firstOrNull { it.name == "mLayoutManager" }?.let { field ->
field.isAccessible = true
Comment on lines +46 to +66
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reflection relies on private ViewPager2 internal field names (e.g. mLayoutManager, mScrollEventAdapter). These names are not part of the AndroidX public API and can change between library versions or be renamed by R8 in minified builds, causing the patch to silently stop working (e.g. orientation/page change callbacks desync). Consider avoiding private-field reflection if possible, or ship/advise Proguard keep rules and add runtime checks/fallback behavior when the fields can’t be found.

Copilot uses AI. Check for mistakes.
field.set(adapter, safeLayoutManager)
Comment on lines +57 to +67
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patchViewPager2LayoutManager uses reflection writes (field.set(...)) without any exception handling. Despite the doc comment saying each patch is isolated, a single IllegalAccessException/SecurityException/IllegalArgumentException here would crash view creation and defeat the purpose of the workaround. Wrap each reflective get/set in its own try/catch (and consider validating field types) so failures degrade gracefully.

Suggested change
field.isAccessible = true
field.set(vp, safeLayoutManager)
}
for (name in arrayOf("mScrollEventAdapter", "mPageTransformerAdapter")) {
vpFields.firstOrNull { it.name == name }?.let { adapterField ->
adapterField.isAccessible = true
val adapter = adapterField.get(vp) ?: return@let
adapter.javaClass.declaredFields.firstOrNull { it.name == "mLayoutManager" }?.let { field ->
field.isAccessible = true
field.set(adapter, safeLayoutManager)
try {
if (LinearLayoutManager::class.java.isAssignableFrom(field.type)) {
field.isAccessible = true
field.set(vp, safeLayoutManager)
}
} catch (_: IllegalAccessException) {
// Ignore and allow ViewPager2 to continue using its original LayoutManager.
} catch (_: IllegalArgumentException) {
// Ignore type mismatches to avoid crashing if internal APIs change.
} catch (_: SecurityException) {
// Ignore security restrictions and skip this patch.
}
}
for (name in arrayOf("mScrollEventAdapter", "mPageTransformerAdapter")) {
vpFields.firstOrNull { it.name == name }?.let { adapterField ->
val adapter = try {
adapterField.isAccessible = true
adapterField.get(vp)
} catch (_: IllegalAccessException) {
null
} catch (_: IllegalArgumentException) {
null
} catch (_: SecurityException) {
null
} ?: return@let
adapter.javaClass.declaredFields.firstOrNull { it.name == "mLayoutManager" }?.let { field ->
try {
if (LinearLayoutManager::class.java.isAssignableFrom(field.type)) {
field.isAccessible = true
field.set(adapter, safeLayoutManager)
}
} catch (_: IllegalAccessException) {
// Ignore and let the adapter continue using its original LayoutManager.
} catch (_: IllegalArgumentException) {
// Ignore type mismatches to avoid crashing if internal APIs change.
} catch (_: SecurityException) {
// Ignore security restrictions and skip this patch.
}

Copilot uses AI. Check for mistakes.
}
}
}
}

public override fun createViewInstance(reactContext: ThemedReactContext): NestedScrollableHost {
val host = NestedScrollableHost(reactContext)
host.id = View.generateViewId()
host.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
host.isSaveEnabled = false
val vp = ViewPager2(reactContext)
vp.adapter = ViewPagerAdapter()
(vp.getChildAt(0) as? RecyclerView)?.let { rv ->
rv.itemAnimator = null
val safeLayoutManager = SafeLinearLayoutManager(reactContext, vp)
safeLayoutManager.orientation = rv.layoutManager?.let {
(it as? LinearLayoutManager)?.orientation
} ?: RecyclerView.HORIZONTAL
rv.layoutManager = safeLayoutManager
Comment on lines +82 to +86
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When creating SafeLinearLayoutManager, orientation is inferred from rv.layoutManager via cast to LinearLayoutManager. Since ViewPager2 already exposes vp.orientation and the constants match RecyclerView’s (HORIZONTAL/VERTICAL), using vp.orientation here avoids relying on internal implementation details/casts and is less brittle if ViewPager2’s internal LayoutManager changes.

Copilot uses AI. Check for mistakes.
patchViewPager2LayoutManager(vp, safeLayoutManager)
}
//https://github.com/callstack/react-native-viewpager/issues/183
vp.isSaveEnabled = false

Expand Down Expand Up @@ -88,6 +126,18 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
return host
}

private fun stopScrollIfNeeded(host: NestedScrollableHost) {
val recyclerView = (host.getChildAt(0) as? ViewPager2)?.getChildAt(0) as? RecyclerView
recyclerView?.stopScroll()
}

override fun onDropViewInstance(view: NestedScrollableHost) {
stopScrollIfNeeded(view)
val recyclerView = (view.getChildAt(0) as? ViewPager2)?.getChildAt(0) as? RecyclerView
recyclerView?.swapAdapter(null, false)
super.onDropViewInstance(view)
}

override fun addView(host: NestedScrollableHost, child: View, index: Int) {
PagerViewViewManagerImpl.addView(host, child, index)
}
Expand All @@ -99,14 +149,17 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
}

override fun removeView(parent: NestedScrollableHost, view: View) {
stopScrollIfNeeded(parent)
PagerViewViewManagerImpl.removeView(parent, view)
}

override fun removeAllViews(parent: NestedScrollableHost) {
stopScrollIfNeeded(parent)
PagerViewViewManagerImpl.removeAllViews(parent)
}

override fun removeViewAt(parent: NestedScrollableHost, index: Int) {
stopScrollIfNeeded(parent)
PagerViewViewManagerImpl.removeViewAt(parent, index)
}

Expand Down Expand Up @@ -206,4 +259,4 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
PageScrollStateChangedEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPageScrollStateChanged"),
PageSelectedEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPageSelected"))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,7 @@ object PagerViewViewManagerImpl {

fun removeViewAt(parent: NestedScrollableHost, index: Int) {
val pager = getViewPager(parent)
val adapter = pager.adapter as ViewPagerAdapter?

val child = adapter?.getChildAt(index)

if (child != null && child.parent != null) {
(child.parent as? ViewGroup)?.removeView(child)
}

adapter?.removeChildAt(index)

(pager.adapter as? ViewPagerAdapter)?.removeChildAt(index)
debouncedRefreshViewChildrenLayout(pager)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.reactnativepagerview

import android.content.Context
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2

/**
* A LinearLayoutManager that catches the "Scrapped or attached views may not be recycled"
* IllegalArgumentException thrown by RecyclerView during scroll-based recycling.
*
* This crash occurs when RecyclerView's internal removeAndRecycleViewAt calls removeViewAt
* to detach a view, but the view's mParent remains non-null (e.g. due to the view being
* held as a "disappearing view" by ViewGroup). The subsequent recycleViewHolderInternal
* check throws because holder.itemView.getParent() != null.
*
* Since this only happens during teardown (back navigation while mid-scroll), catching
* the exception and aborting the scroll is safe — the view is being destroyed anyway.
*/
class SafeLinearLayoutManager(
context: Context,
private val viewPager: ViewPager2
) : LinearLayoutManager(context) {

override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
val pageLimit = viewPager.offscreenPageLimit
if (pageLimit == ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT) {
super.calculateExtraLayoutSpace(state, extraLayoutSpace)
extraLayoutSpace[0] = extraLayoutSpace[0].coerceAtLeast(getPageSize())
extraLayoutSpace[1] = extraLayoutSpace[1].coerceAtLeast(getPageSize())
} else {
val offscreenSpace = getPageSize() * pageLimit
extraLayoutSpace[0] = offscreenSpace
extraLayoutSpace[1] = offscreenSpace
}
}

private fun getPageSize(): Int {
val rv = viewPager.getChildAt(0) as? RecyclerView ?: return 0
return if (orientation == HORIZONTAL) {
rv.width - rv.paddingLeft - rv.paddingRight
} else {
rv.height - rv.paddingTop - rv.paddingBottom
}
}

override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
return try {
super.scrollHorizontallyBy(dx, recycler, state)
} catch (_: IllegalArgumentException) {
0
}
}

override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
return try {
super.scrollVerticallyBy(dy, recycler, state)
} catch (_: IllegalArgumentException) {
0
}
}

override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
try {
super.onLayoutChildren(recycler, state)
} catch (_: IllegalArgumentException) {
// View is being torn down, layout will not be needed again
}
}
Comment on lines +50 to +69
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SafeLinearLayoutManager currently swallows any IllegalArgumentException from scroll/layout and returns 0/no-op. This can mask unrelated programming errors and leave the RecyclerView in a partially updated state with no signal. Narrow the catch to the specific RecyclerView crash you’re targeting (e.g. check exception message/cause) and rethrow otherwise (or at least only suppress during teardown).

Suggested change
} catch (_: IllegalArgumentException) {
0
}
}
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
return try {
super.scrollVerticallyBy(dy, recycler, state)
} catch (_: IllegalArgumentException) {
0
}
}
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
try {
super.onLayoutChildren(recycler, state)
} catch (_: IllegalArgumentException) {
// View is being torn down, layout will not be needed again
}
}
} catch (e: IllegalArgumentException) {
if (isKnownRecyclingException(e)) {
0
} else {
throw e
}
}
}
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
return try {
super.scrollVerticallyBy(dy, recycler, state)
} catch (e: IllegalArgumentException) {
if (isKnownRecyclingException(e)) {
0
} else {
throw e
}
}
}
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
try {
super.onLayoutChildren(recycler, state)
} catch (e: IllegalArgumentException) {
if (!isKnownRecyclingException(e)) {
throw e
}
// View is being torn down, layout will not be needed again
}
}
/**
* Returns true if the given IllegalArgumentException matches the known RecyclerView
* "Scrapped or attached views may not be recycled" crash that occurs during teardown.
*/
private fun isKnownRecyclingException(e: IllegalArgumentException): Boolean {
val message = e.message ?: return false
return message.contains("Scrapped or attached views may not be recycled")
}

Copilot uses AI. Check for mistakes.
}
32 changes: 20 additions & 12 deletions android/src/main/java/com/reactnativepagerview/ViewPagerAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import android.widget.FrameLayout
import androidx.recyclerview.widget.RecyclerView.Adapter
import java.util.*

private fun View.detachFromParent() {
(parent as? ViewGroup)?.removeView(this)
}

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

if (container.childCount > 0) {
container.removeAllViews()
}

if (child.parent != null) {
(child.parent as FrameLayout).removeView(child)
}
child.detachFromParent()

container.addView(child)
}

override fun onViewRecycled(holder: ViewPagerViewHolder) {
super.onViewRecycled(holder)
holder.container.removeAllViews()
}

override fun onFailedToRecycleView(holder: ViewPagerViewHolder): Boolean {
holder.container.removeAllViews()
return true
}

override fun getItemCount(): Int {
return childrenViews.size
}
Expand All @@ -45,26 +55,24 @@ class ViewPagerAdapter() : Adapter<ViewPagerViewHolder>() {

fun removeChild(child: View) {
val index = childrenViews.indexOf(child)
if(index > -1) {

if (index > -1) {
removeChildAt(index)
}
}

fun removeAll() {
for (index in 1..childrenViews.size) {
val child = childrenViews[index-1]
if (child.parent?.parent != null) {
(child.parent.parent as ViewGroup).removeView(child.parent as View)
}
for (child in childrenViews) {
child.detachFromParent()
}
val removedChildrenCount = childrenViews.size
childrenViews.clear()
notifyItemRangeRemoved(0, removedChildrenCount)
}

fun removeChildAt(index: Int) {
if (index >= 0 && index < childrenViews.size) {
if (index >= 0 && index < childrenViews.size) {
childrenViews[index].detachFromParent()
childrenViews.removeAt(index)
notifyItemRemoved(index)
}
Expand Down
Loading
Loading