Skip to content
Draft
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
66 changes: 55 additions & 11 deletions android/src/main/java/com/reactnativepagerview/ComposePagerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<View>()
private val pages = mutableStateListOf<View>()
private val syncPagesRunnable = Runnable { syncPages() }
private val scrollEnabledState = mutableStateOf(true)
private val orientationState = mutableStateOf(Orientation.Horizontal)
private val layoutDirectionState = mutableStateOf(LayoutDirection.Ltr)
Expand Down Expand Up @@ -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()
Comment on lines +144 to +146
}
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()
}

Expand Down Expand Up @@ -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() {
Expand Down
13 changes: 13 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -135,6 +139,10 @@ const sections: { title: string; data: Example[] }[] = [
component: ReanimatedOnPageScrollExample,
name: 'Reanimated onPageScroll example',
},
{
component: ExitAnimationExample,
name: 'PagerView Exit Animation Example',
},
],
},
{
Expand Down Expand Up @@ -285,6 +293,11 @@ export function Navigation() {
animation: 'slide_from_bottom',
}}
/>
<NavigationStack.Screen
name="PagerView Exit Animation"
component={ExitAnimationPagerScreen}
options={{ animation: 'slide_from_bottom' }}
/>
<NavigationStack.Screen
name="Issue #1142 Plain List"
component={Issue1142PlainListScreen}
Expand Down
154 changes: 154 additions & 0 deletions example/src/ExitAnimationExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* PagerView content must stay visible for the whole native-stack exit
* animation. React Native drops the PagerView instance as soon as the screen
* is popped, while react-native-screens keeps the view on-screen until its
* slide-out transition finishes. On Android the Compose pager used to be
* disposed at drop time, blanking the pages mid-animation.
*/
import * as React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
import PagerView from 'react-native-pager-view';
import { colors, radius } from './theme';

export type ExitAnimationParamList = {
'PagerView Exit Animation Example': undefined;
'PagerView Exit Animation': undefined;
};

const PAGES = [
{ label: '01', color: '#a0b4f5' },
{ label: '02', color: '#f5c6a0' },
{ label: '03', color: '#a0f5c6' },
];

export function ExitAnimationExample() {
const navigation =
useNavigation<
NativeStackNavigationProp<
ExitAnimationParamList,
'PagerView Exit Animation Example'
>
>();

return (
<View style={styles.launcher}>
<Text style={styles.description}>
Open the pager screen, then close it. The pager pages should stay
visible until the screen has fully slid out.
</Text>
<Pressable
testID="exit-animation-open"
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={() => navigation.navigate('PagerView Exit Animation')}
>
<Text style={styles.buttonText}>Open pager screen</Text>
</Pressable>
</View>
);
}

export function ExitAnimationPagerScreen() {
const navigation =
useNavigation<
NativeStackNavigationProp<
ExitAnimationParamList,
'PagerView Exit Animation'
>
>();
const [page, setPage] = React.useState(0);

return (
<View style={styles.flex}>
<PagerView
style={styles.flex}
initialPage={0}
onPageSelected={(e) => setPage(e.nativeEvent.position)}
>
{PAGES.map(({ label, color }, i) => (
<View
key={label}
testID={`exit-animation-page-${i}`}
style={styles.page}
>
<View style={[styles.card, { backgroundColor: color }]}>
<Text style={styles.cardLabel}>{label}</Text>
</View>
<Text style={styles.title}>Page {i + 1}</Text>
<Text style={styles.description}>
This content should stay visible until the screen slides out.
</Text>
</View>
))}
</PagerView>
<View style={styles.footer}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${((page + 1) / PAGES.length) * 100}%` },
]}
/>
</View>
<Pressable
testID="exit-animation-close"
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={() => navigation.goBack()}
>
<Text style={styles.buttonText}>↓ Close page</Text>
</Pressable>
</View>
</View>
);
}

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' },
});
Loading