From 49eb3c7e1432412ab212dcdf85f99018fa9c20ff Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 26 Oct 2026 13:52:12 +0200 Subject: [PATCH] fix(android): report settling until a swiped pager comes to rest Compose ends a drag's scroll before the snap fling starts its own, so the pager saw isScrollInProgress false on DragInteraction.Stop and reported idle as the finger lifted. Report settling on every drag stop, and idle only once nothing is scrolling a frame later. Fixes #1199 Co-Authored-By: Claude Opus 5.5 --- .../reactnativepagerview/ComposePagerView.kt | 23 ++++--- .../issues/issue-1199-scroll-state.e2e.ts | 45 +++++++++++++ example/src/App.tsx | 5 ++ .../gh-issues/Issue1199ScrollStateRepro.tsx | 64 +++++++++++++++++++ 4 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 e2e/tests/issues/issue-1199-scroll-state.e2e.ts create mode 100644 example/src/gh-issues/Issue1199ScrollStateRepro.tsx diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 068b4e1d..7c5ba2c9 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -548,8 +549,15 @@ class ComposePagerView(context: Context) : FrameLayout(context) { .drop(1) .collect { inProgress -> if (!inProgress) { - dispatchPageScroll(pagerState.settledPage, 0f) - dispatchScrollState("idle") + // A drag's scroll ends before the fling that follows it starts + // its own, in a coroutine launched after DragInteraction.Stop, + // so isScrollInProgress is briefly false between the two. Wait a + // frame so that gap isn't reported as the pager coming to rest. + withFrameNanos {} + if (!pagerState.isScrollInProgress) { + dispatchPageScroll(pagerState.settledPage, 0f) + dispatchScrollState("idle") + } } } } @@ -557,14 +565,11 @@ class ComposePagerView(context: Context) : FrameLayout(context) { pagerState.interactionSource.interactions.collect { interaction -> when (interaction) { is DragInteraction.Start -> dispatchScrollState("dragging") + // Every drag ends in a fling that snaps to a page, even when + // released at rest, and its end is reported as idle above. The + // fling hasn't started yet, so isScrollInProgress can't tell. is DragInteraction.Stop, - is DragInteraction.Cancel -> { - if (pagerState.isScrollInProgress) { - dispatchScrollState("settling") - } else { - dispatchScrollState("idle") - } - } + is DragInteraction.Cancel -> dispatchScrollState("settling") } } } diff --git a/e2e/tests/issues/issue-1199-scroll-state.e2e.ts b/e2e/tests/issues/issue-1199-scroll-state.e2e.ts new file mode 100644 index 00000000..d1d694fd --- /dev/null +++ b/e2e/tests/issues/issue-1199-scroll-state.e2e.ts @@ -0,0 +1,45 @@ +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: the Compose pager has to keep reporting settling until the +// snap after a drag has finished. +const options = { tags: ['regression', 'issue-1199'], platforms: ['android'] }; + +/** Opens the repro and waits for its status line. */ +async function openRepro(app: App, screen: Screen): Promise { + await openExample(app, screen, 'Issue #1199 Scroll State Repro'); + await expect(screen.getByTestId('issue-1199-status')).toHaveText( + 'No scroll yet' + ); +} + +test( + 'setPage reports settling, then idle once the page has stopped', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + await screen.getByTestId('issue-1199-next').tap(); + await expect(screen.getByTestId('issue-1199-status')).toHaveText( + 'settling > idle | idle at rest: yes' + ); + } +); + +test( + 'a swipe reports settling when the finger lifts, then idle once the page has stopped', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + // Before the fix this was "dragging > idle | idle at rest: no": idle + // arrived as the finger lifted, while the page was still moving. + await screen + .getByTestId('issue-1199-page-0') + .swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByTestId('issue-1199-status')).toHaveText( + 'dragging > settling > idle | idle at rest: yes' + ); + } +); diff --git a/example/src/App.tsx b/example/src/App.tsx index 92f5d899..a60dc42b 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 { Issue1199ScrollStateRepro } from './gh-issues/Issue1199ScrollStateRepro'; 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: Issue1199ScrollStateRepro, + name: 'Issue #1199 Scroll State Repro', + }, { component: Issue424SwipeCancelsPressRepro, name: 'Issue #424 Swipe Cancels Press Repro', diff --git a/example/src/gh-issues/Issue1199ScrollStateRepro.tsx b/example/src/gh-issues/Issue1199ScrollStateRepro.tsx new file mode 100644 index 00000000..c80def1c --- /dev/null +++ b/example/src/gh-issues/Issue1199ScrollStateRepro.tsx @@ -0,0 +1,64 @@ +/** + * Repro for #1199 on Android: a swipe must report `settling` when the finger + * lifts and `idle` only once the page has stopped. Before the fix the pager + * reported `idle` as the finger lifted, while the page was still moving, and + * never `settling`. The status lists the states since the previous `idle` and + * whether the last `onPageScroll` offset was 0 when `idle` arrived. + */ +import React, { useRef, useState } from 'react'; +import { Button, StyleSheet, Text, View } from 'react-native'; +import PagerView from 'react-native-pager-view'; + +const PAGES = ['Page 0', 'Page 1', 'Page 2']; + +export function Issue1199ScrollStateRepro() { + const pager = useRef(null); + const states = useRef([]); + const offset = useRef(0); + const [status, setStatus] = useState('No scroll yet'); + + return ( + + + {status} + +