[Android] v9: pager starts on the last page (initialPage ignored) when first laid out with zero width, e.g. inside `display: 'none'`
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 3/5
- Tempo stimato
- 1-2 giorni
- Idoneità per principianti
- 73/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- android, react-native, typescript
- Ambito
- mobile
Direzione di ricerca
Start by reading ComposePagerView.onMeasure and measureComposeView() in the Android implementation; the issue describes how Fabric's zero-size measurement affects the Compose pager. Check the Android pager tests for coverage of initial pages and layout changes, then reproduce the zero-width cases from the supplied component. Done means initialPage remains selected when the pager is first measured at zero width, while the listed height-zero and hide/show cases continue to behave as reported.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Environment
| Package | Version |
|---|---|
| react-native-pager-view | 9.0.9 (same with 9.0.7; not seen with 8.0.2 in the same app) |
| react-native | 0.88.0 (New Architecture, Hermes) |
| react | 19.3.0 |
| expo | 58.0.14 |
- Platform: Android (iOS not tested)
- Device: Samsung SM-F976N, Android API 37, release build
Description
If a PagerView is mounted while its width is 0 (for example inside a display: 'none' container, or a container with width: 0), it starts on the last page instead of initialPage. It reports that with onPageSelected(lastIndex) right away, while it's still hidden. When the container later gets its real size, the pager stays on the last page.
We hit this with tab screens that are all mounted at startup while the inactive ones are hidden. A pager in an inactive tab came up on its last page. Our JS tracked the page from onPageScroll and still thought it was on page 0, so tapping the last tab's header did nothing and the visible page was blank.
These cases do not trigger it:
- zero height with full width (
height: 0) - hiding an already laid-out pager with
display: 'none'and showing it again - adding a page while it's hidden
It only happens when the pager is first laid out with a zero-width viewport.
Steps to reproduce
Render the component below (written in the style of example/src/gh-issues) and:
- A: tap "A show" (the pager was mounted inside a
display: 'none'container). - D: tap "D expand" (the pager was mounted inside a
width: 0container).
Expected: page 0 is shown and onPageSelected(0) is reported.
Actual: page 3 (the last page) is shown. onPageSelected(3) is reported at mount, before the container is shown.
Results on the device above. Each run started from a cold start, 3 runs per build:
| Case | 9.0.9 | 9.0.9 + change below |
|---|---|---|
A: mounted with display: 'none', then shown |
last page, onPageSelected(3) (3/3) |
page 0, onPageSelected(0) (3/3) |
D: mounted with width: 0, then expanded |
last page, onPageSelected(3) (3/3) |
page 0, onPageSelected(0) (3/3) |
B: laid out, setPage(1), hidden, shown |
stays on page 1 | stays on page 1 |
C: mounted with height: 0, then expanded |
page 0 | page 0 |
| E: hidden, page appended while hidden, shown | page 0 | page 0 |
Screen recordings of A and D (tap "A show" / "D expand"), before and after the change:
9.0.9:
https://github.com/user-attachments/assets/8265a998-5228-4fda-903d-504d898acab1
https://github.com/user-attachments/assets/2de148ea-a9bc-48c5-b8c0-9b4bf04b3449
9.0.9 + change below:
https://github.com/user-attachments/assets/58fb6a43-4a30-4907-a400-5618d2f74bf1
https://github.com/user-attachments/assets/9320320d-9ece-49b4-bb95-410d04fc1571
Last frame of each recording, side by side (left: 9.0.9, right: with the change):
Repro component
import { useCallback, useRef, useState } from 'react';
import { Button, ScrollView, StyleSheet, Text, View } from 'react-native';
import PagerView from 'react-native-pager-view';
const COLORS = ['#ffb3ba', '#bae1ff', '#baffc9', '#ffffba'];
const HOST_HEIGHT = 120;
function usePagerLog() {
const [selected, setSelected] = useState<number | null>(null);
const [events, setEvents] = useState<string[]>([]);
const push = useCallback(
(e: string) => setEvents((prev) => [...prev.slice(-5), e]),
[]
);
const onPageSelected = useCallback(
(e: { nativeEvent: { position: number } }) => {
setSelected(e.nativeEvent.position);
push(`selected:${e.nativeEvent.position}`);
},
[push]
);
const onPageScroll = useCallback(
(e: { nativeEvent: { position: number; offset: number } }) => {
const { position, offset } = e.nativeEvent;
if (offset === 0) push(`scroll:${position}`);
},
[push]
);
return { selected, events, onPageSelected, onPageScroll };
}
function Pages({ tag, count = 4 }: { tag: string; count?: number }) {
return Array.from({ length: count }, (_, i) => COLORS[i % COLORS.length]).map(
(color, i) => (
<View key={i} collapsable={false} style={[styles.page, { backgroundColor: color }]}>
<Text style={styles.pageLabel}>{`${tag} PAGE ${i}`}</Text>
</View>
)
);
}
function Log({ tag, log }: { tag: string; log: ReturnType<typeof usePagerLog> }) {
return (
<Text style={styles.log}>
{`${tag} selected=${log.selected ?? '-'} events=[${log.events.join(' ')}]`}
</Text>
);
}
/** A: mounted inside a `display: 'none'` container, then shown. */
function HiddenOnMountCase() {
const [shown, setShown] = useState(false);
const log = usePagerLog();
return (
<View style={styles.case}>
<Text style={styles.heading}>A: mounted hidden (display: none), then shown</Text>
<Button title="A show" onPress={() => setShown(true)} />
<View style={[styles.host, { display: shown ? 'flex' : 'none' }]}>
<PagerView
style={styles.pager}
initialPage={0}
onPageSelected={log.onPageSelected}
onPageScroll={log.onPageScroll}
>
{Pages({ tag: 'A' })}
</PagerView>
</View>
<Log tag="A" log={log} />
</View>
);
}
/** B: laid out, moved to page 1, hidden with `display: 'none'`, shown again. */
function HideAfterMountCase() {
const [shown, setShown] = useState(true);
const ref = useRef<PagerView>(null);
const log = usePagerLog();
return (
<View style={styles.case}>
<Text style={styles.heading}>B: go to page 1, hide, show</Text>
<View style={styles.row}>
<Button title="B page 1" onPress={() => ref.current?.setPage(1)} />
<Button title="B hide" onPress={() => setShown(false)} />
<Button title="B show" onPress={() => setShown(true)} />
</View>
<View style={[styles.host, { display: shown ? 'flex' : 'none' }]}>
<PagerView
ref={ref}
style={styles.pager}
initialPage={0}
onPageSelected={log.onPageSelected}
onPageScroll={log.onPageScroll}
>
{Pages({ tag: 'B' })}
</PagerView>
</View>
<Log tag="B" log={log} />
</View>
);
}
/** C: mounted inside a zero-height container, then expanded. */
function ZeroHeightOnMountCase() {
const [expanded, setExpanded] = useState(false);
const log = usePagerLog();
return (
<View style={styles.case}>
<Text style={styles.heading}>C: mounted at height 0, then expanded</Text>
<Button title="C expand" onPress={() => setExpanded(true)} />
<View style={[styles.host, { height: expanded ? HOST_HEIGHT : 0 }]}>
<PagerView
style={styles.pager}
initialPage={0}
onPageSelected={log.onPageSelected}
onPageScroll={log.onPageScroll}
>
{Pages({ tag: 'C' })}
</PagerView>
</View>
<Log tag="C" log={log} />
</View>
);
}
/** D: mounted inside a zero-width container, then expanded. */
function ZeroWidthOnMountCase() {
const [expanded, setExpanded] = useState(false);
const log = usePagerLog();
return (
<View style={styles.case}>
<Text style={styles.heading}>D: mounted at width 0, then expanded</Text>
<Button title="D expand" onPress={() => setExpanded(true)} />
<View style={[styles.host, expanded ? null : { width: 0 }]}>
<PagerView
style={styles.pager}
initialPage={0}
onPageSelected={log.onPageSelected}
onPageScroll={log.onPageScroll}
>
{Pages({ tag: 'D' })}
</PagerView>
</View>
<Log tag="D" log={log} />
</View>
);
}
/** E: laid out, hidden with `display: 'none'`, a page appended while hidden, shown again. */
function AddPageWhileHiddenCase() {
const [shown, setShown] = useState(true);
const [count, setCount] = useState(4);
const log = usePagerLog();
return (
<View style={styles.case}>
<Text style={styles.heading}>E: hide, add a page while hidden, show</Text>
<View style={styles.row}>
<Button title="E hide" onPress={() => setShown(false)} />
<Button title="E add" onPress={() => setCount((c) => c + 1)} />
<Button title="E show" onPress={() => setShown(true)} />
</View>
<View style={[styles.host, { display: shown ? 'flex' : 'none' }]}>
<PagerView
style={styles.pager}
initialPage={0}
onPageSelected={log.onPageSelected}
onPageScroll={log.onPageScroll}
>
{Pages({ tag: 'E', count })}
</PagerView>
</View>
<Log tag="E" log={log} />
</View>
);
}
export function ZeroWidthInitialPageRepro() {
return (
<ScrollView contentContainerStyle={styles.container}>
<HiddenOnMountCase />
<HideAfterMountCase />
<ZeroHeightOnMountCase />
<ZeroWidthOnMountCase />
<AddPageWhileHiddenCase />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 12, gap: 12, backgroundColor: '#fff' },
case: { gap: 6 },
heading: { fontWeight: '600', color: '#000' },
row: { flexDirection: 'row', gap: 8 },
host: { height: HOST_HEIGHT, overflow: 'hidden' },
pager: { flex: 1 },
page: { flex: 1, alignItems: 'center', justifyContent: 'center' },
pageLabel: { fontSize: 20, color: '#000' },
log: { fontSize: 12, color: '#000' },
});
Cause (as far as we can tell)
Fabric lays out views under display: 'none', or inside a zero-width container, at 0×0 and calls measure(EXACTLY 0, EXACTLY 0) on them (SurfaceMountingManager.updateLayout). ComposePagerView.onMeasure first calls super.onMeasure (FrameLayout), which measures the child ComposeView with that 0×0 spec. measureComposeView() already skips zero sizes, but by then the ComposeView has been measured.
After that first measurement, pagerState.currentPage is the last page. The first LaunchedEffect(pageCount) then reports it as the initial onPageSelected, which matches the selected:3 we see at mount. We didn't dig into why HorizontalPager settles on the last page with a zero-width viewport. We only observed that it does, and that it keeps that page once it gets a real size.
Possible fix
Don't measure the ComposeView when the incoming size is zero. The pager keeps its last layout, and the normal path runs once Fabric measures it with a real size:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val width = MeasureSpec.getSize(widthMeasureSpec)
+ val height = MeasureSpec.getSize(heightMeasureSpec)
+ if (width <= 0 || height <= 0) {
+ setMeasuredDimension(width, height)
+ return
+ }
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
measureComposeView()
}
With this change all five cases behave as expected (table above). In our app, the pager in the hidden tab starts on page 0. Swiping, setPage, and hiding/showing behave the same as before. We only tested this on the device above.
Happy to open a PR with this change if it looks right.
- Lingua principale
- TypeScript
- Stelle
- 3.4k
- Fork
- 488
- Merge medio
- 2g 11h
- PR unite (30g)
- 26
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di callstack/react-native-pager-view
-
[iOS] Focused TextInput keeps focus and keyboard after switching pages (v9)Forse già presa @TaduJR l’ha presa 2 giorni fa. Aperta
Difficoltà 4/5 1-2 giorni Idoneità per principianti 69/100
callstack/react-native-pager-view#1192 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 58/100
callstack/react-native-pager-view#1189 ·
I maintainer di solito rispondono entro 1 giorno
-
iOS: PagerScrollDelegate proxies to itself and overflows the stack when the weak originalDelegate diesForse già presa @lvlrSajjad l’ha presa 38 giorni fa. Aperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 25/100
callstack/react-native-pager-view#1146 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
callstack/react-native-pager-view#1144 · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 45/100
callstack/react-native-pager-view#1141 · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di callstack/react-native-pager-view
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 83/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 1-3 ore Idoneità per principianti 72/100
zts212653/clowder-ai#1678 ·
I maintainer di solito rispondono entro 1 giorno
-
good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 64/100
Progressive-Victory/the-website#680 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
I maintainer di solito rispondono entro 1 giorno
-
bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100