[Android] v9: pager starts on the last page (initialPage ignored) when first laid out with zero width, e.g. inside `display: 'none'`
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 73/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- android, react-native, typescript
- Lĩnh vực
- mobile
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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.
- Ngôn ngữ chính
- TypeScript
- Star
- 3.4k
- Fork
- 488
- Merge trung bình
- 2 ngày 11 giờ
- Pull request đã merge (30 ngày)
- 26
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của callstack/react-native-pager-view
-
[iOS] Focused TextInput keeps focus and keyboard after switching pages (v9)Có thể đã có người làm @TaduJR đã nhận 1 ngày trước. Đang mở
Độ khó 4/5 1-2 ngày Mức phù hợp với người mới 69/100
callstack/react-native-pager-view#1192 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 58/100
callstack/react-native-pager-view#1189 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
iOS: PagerScrollDelegate proxies to itself and overflows the stack when the weak originalDelegate diesCó thể đã có người làm @lvlrSajjad đã nhận 38 ngày trước. Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 25/100
callstack/react-native-pager-view#1146 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 35/100
callstack/react-native-pager-view#1144 · 1 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 45/100
callstack/react-native-pager-view#1141 · 1 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của callstack/react-native-pager-view
Issue tương tự
-
Edit: Tele Posadas HDĐang mởcheck:passed streams:edit
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
Maintainer thường phản hồi trong vòng 1 ngày
-
area:mcp bug has repro platform:vscode regression
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
anthropics/claude-code#103970 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
skillopt: success-reflect prompt omits the edit schema, so every success-only edit is droppedĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Workspace secrets/constants containing "$" are corrupted when resolved in data source optionsĐang mởbug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
Maintainer thường phản hồi trong vòng 1 ngày
-
sdk-typescript: unquoted COPY globs in shell form are silently dropped from the image build contextĐang mởbug javascript
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày