Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 49 additions & 83 deletions android/src/main/java/com/reactnativepagerview/ComposePagerView.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -64,15 +67,14 @@ 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
private var nextScrollCommandId = 0
private var touchSlop = 0
private var initialTouchX = 0f
private var initialTouchY = 0f
private var didDelegateGestureToAncestor = false
private var isGestureResolved = false
private val scrollCommandState = mutableStateOf<ScrollCommand?>(null)
private var lastEmittedScrollState: String? = null
private var lastEmittedPageSelected: Int? = null
Expand Down Expand Up @@ -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-
Expand All @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 ->
Expand All @@ -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 ->
Expand Down
67 changes: 67 additions & 0 deletions e2e/tests/issues/issue-1156-nested-horizontal-scroll.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
);
5 changes: 5 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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',
},
],
},
];
Expand Down
Loading
Loading