diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 17e2766b..ef4f9be4 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -57,7 +57,17 @@ class ComposePagerView(context: Context) : FrameLayout(context) { // untouched. It only reaches DESTROYED for real in dispose() below. private val composeLifecycleOwner = ComposeViewLifecycleOwner() private var composeView: ComposeView? = null + private var isDisposePending = false + // `children` is what React Native sees (getChildCount/getChildAt) and is + // updated synchronously by the view manager. `pages` is what the Compose + // pager renders. Removals reach `pages` one frame later, so that when RN + // tears the pager down - removing every page and then dropping the pager + // in the same mount batch - while react-native-screens still keeps it + // on-screen for a pop transition, dispose() can freeze the rendered pages + // instead of blanking them before the animation finishes. + private val children = mutableListOf() private val pages = mutableStateListOf() + private val syncPagesRunnable = Runnable { syncPages() } private val scrollEnabledState = mutableStateOf(true) private val orientationState = mutableStateOf(Orientation.Horizontal) private val layoutDirectionState = mutableStateOf(LayoutDirection.Ltr) @@ -131,10 +141,24 @@ class ComposePagerView(context: Context) : FrameLayout(context) { // dispose the composition, since DisposeOnLifecycleDestroyed only acts // on ON_DESTROY. composeLifecycleOwner.detach() + if (isDisposePending) { + isDisposePending = false + composeLifecycleOwner.destroy() + } super.onDetachedFromWindow() } fun dispose() { + // If the view is still attached when RN drops it, react-native-screens is + // keeping it on-screen for an in-progress navigation transition (e.g. a + // pop). Destroying the Lifecycle now disposes the composition and blanks + // the pages before the animation finishes, so defer it until the view is + // detached from the window. + if (isAttachedToWindow) { + isDisposePending = true + removeCallbacks(syncPagesRunnable) + return + } composeLifecycleOwner.destroy() } @@ -223,35 +247,55 @@ class ComposePagerView(context: Context) : FrameLayout(context) { if (child == null) { return } - val insertionIndex = index.coerceIn(0, pages.size) - pages.add(insertionIndex, child) + val insertionIndex = index.coerceIn(0, children.size) + children.add(insertionIndex, child) + syncPages() } - fun getPageCount(): Int = pages.size + fun getPageCount(): Int = children.size - fun getPageAt(index: Int): View = pages[index] + fun getPageAt(index: Int): View = children[index] fun removePage(view: View) { - val index = pages.indexOf(view) + val index = children.indexOf(view) if (index >= 0) { removePageAt(index) } } fun removePageAt(index: Int) { - if (index !in pages.indices) { + if (index !in children.indices) { return } - val view = pages.removeAt(index) - (view.parent as? ViewGroup)?.removeView(view) + children.removeAt(index) + schedulePagesSync() } fun removeAllPages() { + children.clear() + resetPagerState() + schedulePagesSync() + } + + private fun schedulePagesSync() { + removeCallbacks(syncPagesRunnable) + post(syncPagesRunnable) + } + + private fun syncPages() { + removeCallbacks(syncPagesRunnable) + if (isDisposePending) { + return + } pages.forEach { view -> - (view.parent as? ViewGroup)?.removeView(view) + if (view !in children) { + (view.parent as? ViewGroup)?.removeView(view) + } + } + if (pages != children) { + pages.clear() + pages.addAll(children) } - pages.clear() - resetPagerState() } private fun resetPagerState() { diff --git a/example/src/App.tsx b/example/src/App.tsx index 5264c21b..efae5969 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -52,6 +52,10 @@ import { Issue1142SearchBarInsetRepro, } from './gh-issues/Issue1142SearchBarInsetRepro'; import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro'; +import { + ExitAnimationExample, + ExitAnimationPagerScreen, +} from './ExitAnimationExample'; import { colors, radius } from './theme'; function BasicPagerViewExampleScreen() { @@ -135,6 +139,10 @@ const sections: { title: string; data: Example[] }[] = [ component: ReanimatedOnPageScrollExample, name: 'Reanimated onPageScroll example', }, + { + component: ExitAnimationExample, + name: 'PagerView Exit Animation Example', + }, ], }, { @@ -285,6 +293,11 @@ export function Navigation() { animation: 'slide_from_bottom', }} /> + + >(); + + return ( + + + Open the pager screen, then close it. The pager pages should stay + visible until the screen has fully slid out. + + [styles.button, pressed && styles.pressed]} + onPress={() => navigation.navigate('PagerView Exit Animation')} + > + Open pager screen + + + ); +} + +export function ExitAnimationPagerScreen() { + const navigation = + useNavigation< + NativeStackNavigationProp< + ExitAnimationParamList, + 'PagerView Exit Animation' + > + >(); + const [page, setPage] = React.useState(0); + + return ( + + setPage(e.nativeEvent.position)} + > + {PAGES.map(({ label, color }, i) => ( + + + {label} + + Page {i + 1} + + This content should stay visible until the screen slides out. + + + ))} + + + + + + [styles.button, pressed && styles.pressed]} + onPress={() => navigation.goBack()} + > + ↓ Close page + + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1, backgroundColor: colors.background }, + launcher: { + flex: 1, + padding: 24, + gap: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background, + }, + page: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + gap: 24, + }, + card: { + width: 220, + height: 220, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + }, + cardLabel: { fontSize: 48, color: colors.foreground }, + title: { fontSize: 24, color: colors.foreground }, + description: { + fontSize: 15, + textAlign: 'center', + color: colors.foreground, + }, + footer: { padding: 24, gap: 32, alignItems: 'center' }, + progressTrack: { + alignSelf: 'stretch', + height: 4, + borderRadius: radius.full, + backgroundColor: colors.border, + overflow: 'hidden', + }, + progressFill: { height: '100%', backgroundColor: colors.primary }, + button: { + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: radius.full, + backgroundColor: colors.primary, + }, + pressed: { opacity: 0.7 }, + buttonText: { color: colors.primaryForeground, fontWeight: '600' }, +});