[Android] v9: pager starts on the last page (initialPage ignored) when first laid out with zero width, e.g. inside `display: 'none'`
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 3/5
- 预计耗时
- 1-2 天
- 新手友好度
- 73/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 技术栈
- android, react-native, typescript
- 领域
- mobile
调研方向
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.
由索引模型根据 Issue 内容生成。
描述
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.
- 主要语言
- TypeScript
- 星标
- 3.4k
- 派生
- 485
- 平均合并
- 2 天 11 小时
- 30 天内合并 PR
- 26
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
callstack/react-native-pager-view 的其他 Issue
-
[iOS] Focused TextInput keeps focus and keyboard after switching pages (v9)可能已有人在做 @TaduJR 今天认领。 未关闭
难度 4/5 1-2 天 新手友好度 69/100
callstack/react-native-pager-view#1192 ·
维护者通常 1 天内回复
-
难度 3/5 1-2 天 新手友好度 58/100
callstack/react-native-pager-view#1189 ·
维护者通常 1 天内回复
-
iOS: PagerScrollDelegate proxies to itself and overflows the stack when the weak originalDelegate dies可能已有人在做 @lvlrSajjad 于 36 天前认领。 未关闭
难度 4/5 3-5 天 新手友好度 25/100
callstack/react-native-pager-view#1146 ·
维护者通常 1 天内回复
-
难度 5/5 一周以上 新手友好度 35/100
callstack/react-native-pager-view#1144 ·
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 45/100
callstack/react-native-pager-view#1141 · 1 个 reaction ·
维护者通常 1 天内回复
查看 callstack/react-native-pager-view 的全部 Issue
相似的 Issue
-
area:workflow bug ready-for-human
难度 1/5 1 小时以内 新手友好度 78/100
fil-donadoni/tolaria#6108 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
apache/rocketmq-dashboard#7234 ·
维护者通常 3 天内回复
-
难度 2/5 1-3 小时 新手友好度 78/100
vitest-dev/vitest#11656 ·
维护者通常 1 天内回复
-
e2e-failure ready-to-code
难度 2/5 1-3 小时 新手友好度 68/100
redhat-developer/rhdh-plugin-export-overlays#4460 · 1 条评论 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 62/100
ehmpathy/rhachet-roles-bhrain#655 ·
维护者通常 1 天内回复