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
field.set(adapter, safeLayoutManager)
}
}
}
}

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
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
}
}
}
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