From d2f2e6e2121bdbfe331114259963b1c172892985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Roz=CC=87niata?= <56474758+krozniata@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:59:35 +0200 Subject: [PATCH] fix(android): let nested horizontal ScrollView scroll inside pager --- .../reactnativepagerview/ComposePagerView.kt | 132 +++++++----------- ...issue-1156-nested-horizontal-scroll.e2e.ts | 67 +++++++++ example/src/App.tsx | 5 + .../Issue1156NestedHorizontalScrollRepro.tsx | 83 +++++++++++ 4 files changed, 204 insertions(+), 83 deletions(-) create mode 100644 e2e/tests/issues/issue-1156-nested-horizontal-scroll.e2e.ts create mode 100644 example/src/gh-issues/Issue1156NestedHorizontalScrollRepro.tsx diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 17e2766b..f6d1f6c0 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -1,12 +1,14 @@ package com.reactnativepagerview import android.content.Context +import android.graphics.Rect import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout +import android.widget.HorizontalScrollView import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.interaction.DragInteraction @@ -39,6 +41,7 @@ import com.reactnativepagerview.event.PageSelectedEvent import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.absoluteValue +import kotlin.math.max import kotlin.math.sign @OptIn(ExperimentalFoundationApi::class) @@ -64,7 +67,6 @@ class ComposePagerView(context: Context) : FrameLayout(context) { private val pageMarginState = mutableStateOf(0) private val offscreenPageLimitState = mutableStateOf(DEFAULT_OFFSCREEN_PAGE_LIMIT) private val overScrollModeState = mutableStateOf(OverScrollMode.Auto) - private val sameOrientationChildGestureState = mutableStateOf(false) private var initialPage = 0 private var didEmitInitialPage = false private var currentPage = 0 @@ -72,7 +74,7 @@ class ComposePagerView(context: Context) : FrameLayout(context) { private var touchSlop = 0 private var initialTouchX = 0f private var initialTouchY = 0f - private var didDelegateGestureToAncestor = false + private var isGestureResolved = false private val scrollCommandState = mutableStateOf(null) private var lastEmittedScrollState: String? = null private var lastEmittedPageSelected: Int? = null @@ -122,7 +124,6 @@ class ComposePagerView(context: Context) : FrameLayout(context) { } override fun onDetachedFromWindow() { - updateSameOrientationAncestorsGestureState(false) // composeView is intentionally left attached and alive here: its // Lifecycle is self-owned (see composeLifecycleOwner) and only reaches // DESTROYED in dispose(). We pause it to CREATED instead, so lifecycle- @@ -141,17 +142,11 @@ class ComposePagerView(context: Context) : FrameLayout(context) { override fun dispatchTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { - initialTouchX = event.x - initialTouchY = event.y - didDelegateGestureToAncestor = false - updateSameOrientationAncestorsGestureState(true) - } - MotionEvent.ACTION_MOVE -> updateSameOrientationAncestorsGestureStateForMove(event) - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - didDelegateGestureToAncestor = false - updateSameOrientationAncestorsGestureState(false) + initialTouchX = event.rawX + initialTouchY = event.rawY + isGestureResolved = false } + MotionEvent.ACTION_MOVE -> if (!isGestureResolved) resolveGesture(event) } return super.dispatchTouchEvent(event) } @@ -260,8 +255,6 @@ class ComposePagerView(context: Context) : FrameLayout(context) { scrollCommandState.value = null lastEmittedScrollState = null lastEmittedPageSelected = null - didDelegateGestureToAncestor = false - sameOrientationChildGestureState.value = false } fun setInitialPage(value: Int) { @@ -324,56 +317,55 @@ class ComposePagerView(context: Context) : FrameLayout(context) { } } - private fun setSameOrientationChildGestureActive(value: Boolean) { - sameOrientationChildGestureState.value = value - } - - private fun updateSameOrientationAncestorsGestureState(value: Boolean) { - val orientation = orientationState.value - var ancestor = parent - while (ancestor != null) { - if (ancestor is ComposePagerView && ancestor.orientationState.value == orientation) { - ancestor.setSameOrientationChildGestureActive(value) - } - ancestor = (ancestor as? View)?.parent - } - } - - private fun updateSameOrientationAncestorsGestureStateForMove(event: MotionEvent) { - val dx = event.x - initialTouchX - val dy = event.y - initialTouchY - val orientation = orientationState.value - val isHorizontal = orientation == Orientation.Horizontal - val scaledDx = dx.absoluteValue * if (isHorizontal) .5f else 1f - val scaledDy = dy.absoluteValue * if (isHorizontal) 1f else .5f - - if (scaledDx <= touchSlop && scaledDy <= touchSlop) { + private fun resolveGesture(event: MotionEvent) { + val dx = event.rawX - initialTouchX + val dy = event.rawY - initialTouchY + if (max(dx.absoluteValue, dy.absoluteValue) < touchSlop) { return } - val isPerpendicularGesture = isHorizontal == (scaledDy > scaledDx) - if (isPerpendicularGesture) { - updateSameOrientationAncestorsGestureState(false) + isGestureResolved = true + val isHorizontal = orientationState.value == Orientation.Horizontal + if (isHorizontal != (dx.absoluteValue > dy.absoluteValue)) { return } - val delta = if (isHorizontal) dx else dy - val direction = -delta.sign.toInt() - val canScroll = canScrollInDirection(direction) - updateSameOrientationAncestorsGestureState(canScroll) + val direction = -(if (isHorizontal) dx else dy).sign.toInt() + val x = initialTouchX.toInt() + val y = initialTouchY.toInt() + pages.firstNotNullOfOrNull { findScrollableViewAt(it, x, y, direction) } + ?.parent + ?.requestDisallowInterceptTouchEvent(true) + } - if (shouldHandleRtlHorizontalGesture(canScroll)) { - didDelegateGestureToAncestor = scrollInDirection(direction) - } else if (!canScroll && !didDelegateGestureToAncestor) { - didDelegateGestureToAncestor = scrollSameOrientationAncestorInDirection(direction) + private fun findScrollableViewAt(view: View, x: Int, y: Int, direction: Int): View? { + if (!view.isShown) { + return null + } + val rect = Rect() + if (!view.getGlobalVisibleRect(rect) || !rect.contains(x, y)) { + return null + } + val children = when (view) { + is ComposePagerView -> view.pages + is ViewGroup -> (view.childCount - 1 downTo 0).map(view::getChildAt) + else -> emptyList() } + return children.firstNotNullOfOrNull { findScrollableViewAt(it, x, y, direction) } + ?: view.takeIf { canViewScroll(it, direction) } } - private fun shouldHandleRtlHorizontalGesture(canScroll: Boolean): Boolean { - return canScroll && - !didDelegateGestureToAncestor && - orientationState.value == Orientation.Horizontal && - layoutDirectionState.value == LayoutDirection.Rtl + private fun canViewScroll(view: View, direction: Int): Boolean { + if (orientationState.value == Orientation.Vertical) { + return view.canScrollVertically(direction) + } + val canScroll = view.canScrollHorizontally(direction) + if (!canScroll || view !is HorizontalScrollView) { + return canScroll + } + val contentWidth = view.getChildAt(0)?.width ?: 0 + val maxScrollX = contentWidth - (view.width - view.paddingLeft - view.paddingRight) + return if (direction < 0) view.scrollX > 0 else view.scrollX < maxScrollX } private fun canScrollInDirection(direction: Int): Boolean { @@ -393,31 +385,6 @@ class ComposePagerView(context: Context) : FrameLayout(context) { } } - private fun scrollSameOrientationAncestorInDirection(direction: Int): Boolean { - val orientation = orientationState.value - var ancestor = parent - while (ancestor != null) { - if (ancestor is ComposePagerView && ancestor.orientationState.value == orientation) { - return ancestor.scrollInDirection(direction) - } - ancestor = (ancestor as? View)?.parent - } - return false - } - - private fun scrollInDirection(direction: Int): Boolean { - if (!canScrollInDirection(direction)) { - return false - } - - val isRtlHorizontal = - orientationState.value == Orientation.Horizontal && - layoutDirectionState.value == LayoutDirection.Rtl - val effectiveDirection = if (isRtlHorizontal) -direction else direction - setCurrentItem(currentPage + if (effectiveDirection < 0) -1 else 1, true) - return true - } - fun setCurrentItem(selectedPage: Int, animated: Boolean) { if (selectedPage < 0 || selectedPage >= pages.size) { return @@ -570,7 +537,6 @@ class ComposePagerView(context: Context) : FrameLayout(context) { val pageSpacing = pageMarginState.value.dp val beyondViewportPageCount = getBeyondViewportPageCount() - val userScrollEnabled = scrollEnabledState.value && !sameOrientationChildGestureState.value val overscrollConfiguration = if (overScrollModeState.value == OverScrollMode.Never) { null } else { @@ -583,7 +549,7 @@ class ComposePagerView(context: Context) : FrameLayout(context) { state = pagerState, modifier = Modifier.fillMaxSize(), pageSpacing = pageSpacing, - userScrollEnabled = userScrollEnabled, + userScrollEnabled = scrollEnabledState.value, reverseLayout = false, beyondViewportPageCount = beyondViewportPageCount ) { page -> @@ -594,7 +560,7 @@ class ComposePagerView(context: Context) : FrameLayout(context) { state = pagerState, modifier = Modifier.fillMaxSize(), pageSpacing = pageSpacing, - userScrollEnabled = userScrollEnabled, + userScrollEnabled = scrollEnabledState.value, reverseLayout = false, beyondViewportPageCount = beyondViewportPageCount ) { page -> diff --git a/e2e/tests/issues/issue-1156-nested-horizontal-scroll.e2e.ts b/e2e/tests/issues/issue-1156-nested-horizontal-scroll.e2e.ts new file mode 100644 index 00000000..39bf6282 --- /dev/null +++ b/e2e/tests/issues/issue-1156-nested-horizontal-scroll.e2e.ts @@ -0,0 +1,67 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import type { Screen } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +// Android-only: on iOS the inner ScrollView always got the gesture. +const options = { tags: ['regression', 'issue-1156'], platforms: ['android'] }; + +async function expectStatus( + screen: Screen, + page: number, + chipsScrolled: boolean +): Promise { + await expect(screen.getByTestId('issue-1156-status')).toHaveText( + `Selected: ${page} | Chips scrolled: ${chipsScrolled ? 'yes' : 'no'}` + ); +} + +test( + 'a horizontal ScrollView in a page scrolls instead of the pager', + options, + async ({ app, screen }) => { + await openExample( + app, + screen, + 'Issue #1156 Nested Horizontal Scroll Repro' + ); + await expectStatus(screen, 0, false); + + // Before the fix the pager took the swipe and the chips never moved. + await screen.getByTestId('issue-1156-chips').swipe({ direction: 'right' }); + await expectStatus(screen, 0, true); + } +); + +test( + 'the pager takes the swipe once the ScrollView is at its edge', + options, + async ({ app, screen }) => { + await openExample( + app, + screen, + 'Issue #1156 Nested Horizontal Scroll Repro' + ); + await screen.getByTestId('issue-1156-scroll-to-end').tap(); + await expectStatus(screen, 0, true); + + await screen.getByTestId('issue-1156-chips').swipe({ direction: 'right' }); + await expectStatus(screen, 1, true); + } +); + +test( + 'a swipe outside the ScrollView changes the page', + options, + async ({ app, screen }) => { + await openExample( + app, + screen, + 'Issue #1156 Nested Horizontal Scroll Repro' + ); + + await screen.getByTestId('issue-1156-page-0').swipe({ direction: 'right' }); + await expectStatus(screen, 1, false); + } +); diff --git a/example/src/App.tsx b/example/src/App.tsx index 5264c21b..c9113eb8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -52,6 +52,7 @@ import { Issue1142SearchBarInsetRepro, } from './gh-issues/Issue1142SearchBarInsetRepro'; import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro'; +import { Issue1156NestedHorizontalScrollRepro } from './gh-issues/Issue1156NestedHorizontalScrollRepro'; import { colors, radius } from './theme'; function BasicPagerViewExampleScreen() { @@ -164,6 +165,10 @@ const sections: { title: string; data: Example[] }[] = [ component: Issue1154PageChangeFocusRepro, name: 'Issue #1154 Page Change Focus Repro', }, + { + component: Issue1156NestedHorizontalScrollRepro, + name: 'Issue #1156 Nested Horizontal Scroll Repro', + }, ], }, ]; diff --git a/example/src/gh-issues/Issue1156NestedHorizontalScrollRepro.tsx b/example/src/gh-issues/Issue1156NestedHorizontalScrollRepro.tsx new file mode 100644 index 00000000..b0edbc82 --- /dev/null +++ b/example/src/gh-issues/Issue1156NestedHorizontalScrollRepro.tsx @@ -0,0 +1,83 @@ +/** + * Repro for #1156: on Android, a horizontal ScrollView inside a page must + * scroll when swiped, and the pager must only take over once the ScrollView + * has reached its edge. Swipe left on the chip row: the chips should scroll + * and the selected page should stay at 0. + */ +import React, { useRef, useState } from 'react'; +import { + Button, + ScrollView, + StyleSheet, + Text, + View, + type ScrollViewInstance, +} from 'react-native'; +import PagerView from 'react-native-pager-view'; + +const chips = Array.from({ length: 12 }, (_, index) => `Chip ${index + 1}`); + +export function Issue1156NestedHorizontalScrollRepro() { + const chipsRef = useRef(null); + const [selectedPage, setSelectedPage] = useState(0); + const [chipsScrolled, setChipsScrolled] = useState(false); + + return ( + + + {`Selected: ${selectedPage} | Chips scrolled: ${ + chipsScrolled ? 'yes' : 'no' + }`} + +