From a46a17b5e29198b3e75dd5100a5402a6ccb9e06c Mon Sep 17 00:00:00 2001 From: gcd Date: Thu, 27 Aug 2026 10:33:22 +0200 Subject: [PATCH 1/3] fix(android): give ComposeView its own Lifecycle to fix #1103 and #1104 react-native-screens fully removes and re-adds a covered screen's Fragment instead of just hiding it, destroying its view-tree Lifecycle. Compose's default disposal strategy keys off that ambient Lifecycle, so the pager's ComposeView was torn down and rebuilt on every cover/reveal cycle, causing a blank pager (#1103) and loss of page state such as FlatList scroll position (#1104). Giving the ComposeView its own self-owned Lifecycle lets the composition survive the cycle untouched. --- android/build.gradle | 3 + .../reactnativepagerview/ComposePagerView.kt | 112 +++++++++++++----- .../PagerViewViewManager.kt | 5 + 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 16e92aef..d6e48af5 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -246,6 +246,9 @@ dependencies { implementation "androidx.compose.foundation:foundation:$compose_version" implementation "androidx.compose.runtime:runtime:$compose_version" implementation "androidx.compose.ui:ui:$compose_version" + // Needed for ComposeViewLifecycleOwner (see ComposePagerView.kt), which + // gives the ComposeView a self-owned Lifecycle instead of the ambient one. + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 8695b93c..5820f27e 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -24,6 +24,10 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.setViewTreeLifecycleOwner import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper import com.reactnativepagerview.event.PageScrollEvent @@ -37,7 +41,19 @@ import kotlin.math.sign @OptIn(ExperimentalFoundationApi::class) class ComposePagerView(context: Context) : FrameLayout(context) { private val reactContext = context as ReactContext - private val composeView = ComposeView(context) + // react-native-screens fully removes and re-adds this screen's Fragment + // (rather than merely hiding it) while it's covered by another screen + // (see #1103), destroying that Fragment's view-tree Lifecycle. Compose's + // default composition-disposal strategies key off that ambient Lifecycle, + // so a ComposeView left to use them gets torn down on every cover/reveal + // cycle - and rebuilding the composition from scratch each time also + // discarded every page's native view host, resetting their state (e.g. a + // FlatList's scroll position, see #1104). Giving the ComposeView its own + // Lifecycle - one we control instead of the ambient, repeatedly-destroyed + // one - lets the composition (and each page's host) survive the cycle + // untouched. It only reaches DESTROYED for real in dispose() below. + private val composeLifecycleOwner = ComposeViewLifecycleOwner() + private var composeView: ComposeView? = null private val pages = mutableStateListOf() private val scrollEnabledState = mutableStateOf(true) private val orientationState = mutableStateOf(Orientation.Horizontal) @@ -57,45 +73,59 @@ class ComposePagerView(context: Context) : FrameLayout(context) { private val scrollCommandState = mutableStateOf(null) private var lastEmittedScrollState: String? = null private var lastEmittedPageSelected: Int? = null - private var didSetContent = false init { id = View.generateViewId() layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) isSaveEnabled = false - - composeView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - composeView.isSaveEnabled = false - composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) applyOverScrollMode() touchSlop = ViewConfiguration.get(context).scaledTouchSlop } + private fun createComposeView(): ComposeView { + return ComposeView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + isSaveEnabled = false + layoutDirection = androidLayoutDirection() + overScrollMode = androidOverScrollMode() + // Bind our own Lifecycle before the composition is created, so Compose + // resolves it instead of walking up to the ambient (and repeatedly + // destroyed) Fragment view-tree Lifecycle - see the field comment above. + setViewTreeLifecycleOwner(composeLifecycleOwner) + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnLifecycleDestroyed(composeLifecycleOwner) + ) + } + } + override fun onAttachedToWindow() { super.onAttachedToWindow() - if (composeView.parent == null) { - super.addView(composeView) - post { - measureAndLayoutComposeView() - } - } - if (!didSetContent) { - didSetContent = true - composeView.setContent { + if (composeView == null) { + val view = createComposeView() + composeView = view + super.addView(view) + view.setContent { PagerContent() } } + post { + measureAndLayoutComposeView() + } } override fun onDetachedFromWindow() { updateSameOrientationAncestorsGestureState(false) - if (composeView.parent === this) { - super.removeView(composeView) - didSetContent = false - } + // composeView is intentionally left attached and alive here: its + // Lifecycle is self-owned (see composeLifecycleOwner) and only reaches + // DESTROYED in dispose(), so there is nothing to tear down on a mere + // window detach. super.onDetachedFromWindow() } + fun dispose() { + composeLifecycleOwner.destroy() + } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { @@ -149,7 +179,7 @@ class ComposePagerView(context: Context) : FrameLayout(context) { val width = width.takeIf { it > 0 } ?: measuredWidth val height = height.takeIf { it > 0 } ?: measuredHeight if (measureComposeView(width, height)) { - composeView.layout(0, 0, width, height) + composeView?.layout(0, 0, width, height) } } @@ -157,11 +187,12 @@ class ComposePagerView(context: Context) : FrameLayout(context) { width: Int = measuredWidth, height: Int = measuredHeight ): Boolean { - if (composeView.parent !== this || width <= 0 || height <= 0) { + val view = composeView + if (view == null || view.parent !== this || width <= 0 || height <= 0) { return false } - composeView.measure( + view.measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) ) @@ -238,13 +269,16 @@ class ComposePagerView(context: Context) : FrameLayout(context) { fun setLayoutDirection(value: String) { layoutDirectionState.value = if (value == "rtl") LayoutDirection.Rtl else LayoutDirection.Ltr - val androidLayoutDirection = if (layoutDirectionState.value == LayoutDirection.Rtl) { + layoutDirection = androidLayoutDirection() + composeView?.layoutDirection = androidLayoutDirection() + } + + private fun androidLayoutDirection(): Int { + return if (layoutDirectionState.value == LayoutDirection.Rtl) { View.LAYOUT_DIRECTION_RTL } else { View.LAYOUT_DIRECTION_LTR } - layoutDirection = androidLayoutDirection - composeView.layoutDirection = androidLayoutDirection } fun setOffscreenPageLimit(value: Int) { @@ -265,13 +299,17 @@ class ComposePagerView(context: Context) : FrameLayout(context) { } private fun applyOverScrollMode() { - val androidOverScrollMode = when (overScrollModeState.value) { + val androidOverScrollMode = androidOverScrollMode() + overScrollMode = androidOverScrollMode + composeView?.overScrollMode = androidOverScrollMode + } + + private fun androidOverScrollMode(): Int { + return when (overScrollModeState.value) { OverScrollMode.Never -> View.OVER_SCROLL_NEVER OverScrollMode.Always -> View.OVER_SCROLL_ALWAYS OverScrollMode.Auto -> View.OVER_SCROLL_IF_CONTENT_SCROLLS } - overScrollMode = androidOverScrollMode - composeView.overScrollMode = androidOverScrollMode } private fun setSameOrientationChildGestureActive(value: Boolean) { @@ -606,3 +644,21 @@ class ComposePagerView(context: Context) : FrameLayout(context) { } } } + +// A Lifecycle that isn't derived from the ambient Fragment/Activity: it +// starts RESUMED and only moves to DESTROYED when destroy() is called +// explicitly, so it survives being covered/revealed by react-native-screens' +// Fragment remove+re-add (see the composeLifecycleOwner field comment on +// ComposePagerView above). +private class ComposeViewLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry(this).apply { + currentState = Lifecycle.State.RESUMED + } + + override val lifecycle: Lifecycle + get() = registry + + fun destroy() { + registry.currentState = Lifecycle.State.DESTROYED + } +} diff --git a/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt b/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt index 490e3128..d41052a7 100644 --- a/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt +++ b/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt @@ -69,6 +69,11 @@ class PagerViewViewManager : ViewGroupManager(), RNCViewPagerM return true } + override fun onDropViewInstance(view: ComposePagerView) { + view.dispose() + super.onDropViewInstance(view) + } + @ReactProp(name = "scrollEnabled", defaultBoolean = true) override fun setScrollEnabled(view: ComposePagerView?, value: Boolean) { if (view != null) { From a5be89a1168a855e26dec116066bcc836b533cfe Mon Sep 17 00:00:00 2001 From: gcd Date: Thu, 27 Aug 2026 17:23:08 +0200 Subject: [PATCH 2/3] make Lifecycle version configurable --- android/build.gradle | 3 ++- android/gradle.properties | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index d6e48af5..d4b053e4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -238,6 +238,7 @@ repositories { def kotlin_version = getExtOrDefault('kotlinVersion') def compose_version = getExtOrDefault('composeVersion') +def lifecycle_version = getExtOrDefault('lifecycleVersion') dependencies { //noinspection GradleDynamicVersion @@ -248,7 +249,7 @@ dependencies { implementation "androidx.compose.ui:ui:$compose_version" // Needed for ComposeViewLifecycleOwner (see ComposePagerView.kt), which // gives the ComposeView a self-owned Lifecycle instead of the ambient one. - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" } if (isNewArchitectureEnabled()) { diff --git a/android/gradle.properties b/android/gradle.properties index 3020b8b8..7b041548 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,6 @@ PagerView_kotlinVersion=2.0.21 PagerView_composeVersion=1.7.8 +PagerView_lifecycleVersion=2.6.1 PagerView_minSdkVersion=24 PagerView_targetSdkVersion=35 PagerView_compileSdkVersion=35 From 07e7c5e55cf5a312b5426e0bab8c118c0b4b3571 Mon Sep 17 00:00:00 2001 From: gcd Date: Thu, 27 Aug 2026 17:25:03 +0200 Subject: [PATCH 3/3] improve life cycle --- .../reactnativepagerview/ComposePagerView.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 5820f27e..e048c14d 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -100,6 +100,7 @@ class ComposePagerView(context: Context) : FrameLayout(context) { override fun onAttachedToWindow() { super.onAttachedToWindow() + composeLifecycleOwner.resume() if (composeView == null) { val view = createComposeView() composeView = view @@ -117,8 +118,12 @@ class ComposePagerView(context: Context) : FrameLayout(context) { updateSameOrientationAncestorsGestureState(false) // composeView is intentionally left attached and alive here: its // Lifecycle is self-owned (see composeLifecycleOwner) and only reaches - // DESTROYED in dispose(), so there is nothing to tear down on a mere - // window detach. + // DESTROYED in dispose(). We pause it to CREATED instead, so lifecycle- + // aware content within pages (video players, nested Compose effects, + // screen-view tracking) stops doing work while covered - this doesn't + // dispose the composition, since DisposeOnLifecycleDestroyed only acts + // on ON_DESTROY. + composeLifecycleOwner.pause() super.onDetachedFromWindow() } @@ -658,6 +663,18 @@ private class ComposeViewLifecycleOwner : LifecycleOwner { override val lifecycle: Lifecycle get() = registry + fun resume() { + if (registry.currentState != Lifecycle.State.DESTROYED) { + registry.currentState = Lifecycle.State.RESUMED + } + } + + fun pause() { + if (registry.currentState != Lifecycle.State.DESTROYED) { + registry.currentState = Lifecycle.State.CREATED + } + } + fun destroy() { registry.currentState = Lifecycle.State.DESTROYED }