[Android] v9: pager starts on the last page (initialPage ignored) when first laid out with zero width, e.g. inside `display: 'none'`
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 73/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- android, react-native, typescript
- Área
- mobile
Línea de trabajo
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.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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.
- Lenguaje dominante
- TypeScript
- Estrellas
- 3.4k
- Forks
- 485
- Merge medio
- 2 d 11 h
- PR fusionados (30 d)
- 26
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de callstack/react-native-pager-view
-
[iOS] Focused TextInput keeps focus and keyboard after switching pages (v9)Posiblemente ocupada @TaduJR la tomó hoy. Abierto
Dificultad 4/5 1-2 días Aptitud para principiantes 69/100
callstack/react-native-pager-view#1192 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 3/5 1-2 días Aptitud para principiantes 58/100
callstack/react-native-pager-view#1189 ·
Los mantenedores suelen responder en 1 día
-
iOS: PagerScrollDelegate proxies to itself and overflows the stack when the weak originalDelegate diesPosiblemente ocupada @lvlrSajjad la tomó hace 37 días. Abierto
Dificultad 4/5 3-5 días Aptitud para principiantes 25/100
callstack/react-native-pager-view#1146 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 5/5 Más de una semana Aptitud para principiantes 35/100
callstack/react-native-pager-view#1144 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 4/5 3-5 días Aptitud para principiantes 45/100
callstack/react-native-pager-view#1141 · 1 reacción ·
Los mantenedores suelen responder en 1 día
Todos los issues de callstack/react-native-pager-view
Issues similares
-
RED ladder stopped @ 4db4ed91Abiertoarea:workflow bug ready-for-human
Dificultad 1/5 Menos de una hora Aptitud para principiantes 78/100
fil-donadoni/tolaria#6108 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
apache/rocketmq-dashboard#7234 ·
Los mantenedores suelen responder en 3 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
vitest-dev/vitest#11656 ·
Los mantenedores suelen responder en 1 día
-
e2e-failure ready-to-code
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
redhat-developer/rhdh-plugin-export-overlays#4460 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
ehmpathy/rhachet-roles-bhrain#655 ·
Los mantenedores suelen responder en 1 día