diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 068b4e1d..35e9dd93 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -200,6 +200,33 @@ class ComposePagerView(context: Context) : FrameLayout(context) { measureAndLayoutComposeView() } + // React Native's parent views drop layout requests, so the pager isn't laid out again when + // Compose asks for it, like when pages arrive in an empty pager, and stays blank until a + // touch (see #219). Lay it out again at its current size. + override fun requestLayout() { + super.requestLayout() + post { layoutAtCurrentSize() } + } + + // A configuration change handled in place, like dark mode, calls this on every view instead + // of requestLayout(), with the same result. + override fun forceLayout() { + super.forceLayout() + post { layoutAtCurrentSize() } + } + + private fun layoutAtCurrentSize() { + // dispose() requests a layout too, and measuring then would create a new composition. + if (composeView?.hasComposition != true || width <= 0 || height <= 0) { + return + } + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + ) + layout(left, top, right, bottom) + } + private fun measureAndLayoutComposeView() { val width = width.takeIf { it > 0 } ?: measuredWidth val height = height.takeIf { it > 0 } ?: measuredHeight diff --git a/e2e/tests/issues/issue-219-empty-pager.e2e.ts b/e2e/tests/issues/issue-219-empty-pager.e2e.ts new file mode 100644 index 00000000..bd585cf7 --- /dev/null +++ b/e2e/tests/issues/issue-219-empty-pager.e2e.ts @@ -0,0 +1,70 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import type { App, Screen } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +// Android-only: iOS already shows pages that arrive later, and it reports page 0 +// before it has any. +const options = { + tags: ['regression', 'issue-219'], + platforms: ['android'], +}; + +/** Opens the repro and waits for the empty pager. */ +async function openRepro(app: App, screen: Screen): Promise { + await openExample(app, screen, 'Issue #219 Empty Pager Repro'); + await expect(screen.getByTestId('issue-219-status')).toHaveText( + 'Pages: 0 | Selected: - | Events: 0' + ); +} + +/** Adds the pages and waits for the pager to report page A. */ +async function addPages(screen: Screen): Promise { + await screen.getByTestId('issue-219-add-pages').tap(); + await expect(screen.getByTestId('issue-219-status')).toHaveText( + 'Pages: 4 | Selected: 0 | Events: 1' + ); +} + +test( + 'a pager that mounts empty shows its first page when pages arrive', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + await addPages(screen); + // Before the fix the pager stayed blank until it was touched. + await expect(screen.getByText('Page A')).toBeVisible(); + } +); + +test( + 'a pager that loses all its pages shows them again when they come back', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + await addPages(screen); + await expect(screen.getByText('Page A')).toBeVisible(); + for (let cycle = 0; cycle < 2; cycle++) { + await screen.getByTestId('issue-219-remove-pages').tap(); + await expect(screen.getByText('Page A')).toBeHidden(); + await screen.getByTestId('issue-219-add-pages').tap(); + await expect(screen.getByText('Page A')).toBeVisible(); + } + } +); + +test( + 'setPage works as soon as pages arrive in a pager that mounted empty', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + await addPages(screen); + // Before the fix setPage did nothing until the pager was touched. + await screen.getByTestId('issue-219-select-c').tap(); + await expect(screen.getByTestId('issue-219-status')).toHaveText( + 'Pages: 4 | Selected: 2 | Events: 2' + ); + await expect(screen.getByText('Page C')).toBeVisible(); + } +); diff --git a/example/src/App.tsx b/example/src/App.tsx index 92f5d899..2569f285 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -60,6 +60,7 @@ import { } from './gh-issues/Issue1142SearchBarInsetRepro'; import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro'; import { Issue1156NestedHorizontalScrollRepro } from './gh-issues/Issue1156NestedHorizontalScrollRepro'; +import { Issue219EmptyPagerRepro } from './gh-issues/Issue219EmptyPagerRepro'; import { Issue424SwipeCancelsPressRepro } from './gh-issues/Issue424SwipeCancelsPressRepro'; import { Issue965PagesBeforeCurrentRepro } from './gh-issues/Issue965PagesBeforeCurrentRepro'; import { colors, radius } from './theme'; @@ -188,6 +189,10 @@ const sections: { title: string; data: Example[] }[] = [ component: Issue1156NestedHorizontalScrollRepro, name: 'Issue #1156 Nested Horizontal Scroll Repro', }, + { + component: Issue219EmptyPagerRepro, + name: 'Issue #219 Empty Pager Repro', + }, { component: Issue424SwipeCancelsPressRepro, name: 'Issue #424 Swipe Cancels Press Repro', diff --git a/example/src/gh-issues/Issue219EmptyPagerRepro.tsx b/example/src/gh-issues/Issue219EmptyPagerRepro.tsx new file mode 100644 index 00000000..e03f51fa --- /dev/null +++ b/example/src/gh-issues/Issue219EmptyPagerRepro.tsx @@ -0,0 +1,71 @@ +/** + * Repro for #219 on Android: a pager that mounts with no pages, or loses all + * of them, must show its first page as soon as pages arrive, without a touch. + */ +import React, { useRef, useState } from 'react'; +import { Button, StyleSheet, Text, View } from 'react-native'; +import PagerView from 'react-native-pager-view'; + +const PAGES = ['A', 'B', 'C', 'D']; +const COLORS: Record = { + A: '#fca5a5', + B: '#86efac', + C: '#93c5fd', + D: '#fde68a', +}; + +export function Issue219EmptyPagerRepro() { + const pagerRef = useRef(null); + const [count, setCount] = useState(0); + const [selected, setSelected] = useState(null); + const [events, setEvents] = useState(0); + + return ( + + + {`Pages: ${count} | Selected: ${selected ?? '-'} | Events: ${events}`} + +