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
39 changes: 39 additions & 0 deletions android/src/main/java/com/reactnativepagerview/ComposePagerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.NativeGestureUtil
import com.reactnativepagerview.event.PageScrollEvent
import com.reactnativepagerview.event.PageScrollStateChangedEvent
import com.reactnativepagerview.event.PageSelectedEvent
Expand Down Expand Up @@ -75,6 +76,7 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
private var initialTouchX = 0f
private var initialTouchY = 0f
private var isGestureResolved = false
private var didStartNativeGesture = false
private val scrollCommandState = mutableStateOf<ScrollCommand?>(null)
private var lastEmittedScrollState: String? = null
private var lastEmittedPageSelected: Int? = null
Expand Down Expand Up @@ -147,10 +149,26 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
isGestureResolved = false
}
MotionEvent.ACTION_MOVE -> if (!isGestureResolved) resolveGesture(event)
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
if (didStartNativeGesture && event.actionMasked == MotionEvent.ACTION_UP) {
NativeGestureUtil.notifyNativeGestureEnded(this, event)
}
didStartNativeGesture = false
}
}
return super.dispatchTouchEvent(event)
}

// Not called once a child, like a nested ScrollView, has claimed the touch, so the pager
// only notifies when it is the one dragging.
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_MOVE) {
notifyNativeGestureStartedForMove(event)
}
return super.onInterceptTouchEvent(event)
}

override fun canScrollHorizontally(direction: Int): Boolean {
if (orientationState.value != Orientation.Horizontal) {
return false
Expand Down Expand Up @@ -368,6 +386,27 @@ class ComposePagerView(context: Context) : FrameLayout(context) {
return if (direction < 0) view.scrollX > 0 else view.scrollX < maxScrollX
}

// Compose starts dragging once the touch moves touchSlop along the pager axis. Tell
// React Native then, like ScrollView does, so the JS touch is cancelled instead of
// ending as a press.
private fun notifyNativeGestureStartedForMove(event: MotionEvent) {
// Compose never drags with a mouse.
val isMouse = event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
if (didStartNativeGesture || !scrollEnabledState.value || isMouse) {
return
}

val delta = if (orientationState.value == Orientation.Horizontal) {
event.rawX - initialTouchX
} else {
event.rawY - initialTouchY
}
if (delta.absoluteValue >= touchSlop) {
didStartNativeGesture = true
NativeGestureUtil.notifyNativeGestureStarted(this, event)
}
}

private fun canScrollInDirection(direction: Int): Boolean {
if (!scrollEnabledState.value || pages.size <= 1 || direction == 0) {
return false
Expand Down
59 changes: 59 additions & 0 deletions e2e/tests/issues/issue-424-swipe-cancels-press.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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 tell React Native when it takes the
// touch, as ScrollView does.
const options = { tags: ['regression', 'issue-424'], platforms: ['android'] };

/** Opens the repro and waits for the first page's button. */
async function openRepro(app: App, screen: Screen): Promise<void> {
await openExample(app, screen, 'Issue #424 Swipe Cancels Press Repro');
await expect(screen.getByTestId('issue-424-button-0')).toBeVisible();
}

test(
'a tap on a button in a page presses it',
options,
async ({ app, screen }) => {
await openRepro(app, screen);
await screen.getByTestId('issue-424-button-0').tap();
await expect(screen.getByTestId('issue-424-status')).toHaveText(
'Page: 0 | Presses: 1 | Touch: end'
);
}
);

test(
'a swipe that starts on a button in a page cancels its touch',
options,
async ({ app, screen }) => {
await openRepro(app, screen);
// Before the fix JS never got a cancel: the touch always ended, and it
// could press the button when the dragging event reached JS too late.
await screen
.getByTestId('issue-424-button-0')
.swipe({ direction: 'right', momentum: 'fast' });
await expect(screen.getByTestId('issue-424-status')).toHaveText(
'Page: 1 | Presses: 0 | Touch: cancel'
);
}
);

test(
'a swipe while the JS thread is busy does not press the button',
options,
async ({ app, screen }) => {
await openRepro(app, screen);
await screen.getByTestId('issue-424-block-js').tap();
await screen
.getByTestId('issue-424-button-0')
.swipe({ direction: 'right', momentum: 'fast' });
await expect(screen.getByTestId('issue-424-status')).toHaveText(
'Page: 1 | Presses: 0 | Touch: cancel',
{ timeout: 15_000 }
);
}
);
5 changes: 5 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from './gh-issues/Issue1142SearchBarInsetRepro';
import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro';
import { Issue1156NestedHorizontalScrollRepro } from './gh-issues/Issue1156NestedHorizontalScrollRepro';
import { Issue424SwipeCancelsPressRepro } from './gh-issues/Issue424SwipeCancelsPressRepro';
import { colors, radius } from './theme';

function BasicPagerViewExampleScreen() {
Expand Down Expand Up @@ -169,6 +170,10 @@ const sections: { title: string; data: Example[] }[] = [
component: Issue1156NestedHorizontalScrollRepro,
name: 'Issue #1156 Nested Horizontal Scroll Repro',
},
{
component: Issue424SwipeCancelsPressRepro,
name: 'Issue #424 Swipe Cancels Press Repro',
},
],
},
];
Expand Down
76 changes: 76 additions & 0 deletions example/src/gh-issues/Issue424SwipeCancelsPressRepro.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Repro for #424 on Android: a swipe that starts on a Pressable in a page
* must cancel the touch, so the Pressable does not fire onPress, while a tap
* still presses it. "Block JS" keeps the JS thread busy for 5 s; a swipe in
* that window could fire the press before the fix.
*/
import React, { useState } from 'react';
import { Button, Pressable, StyleSheet, Text, View } from 'react-native';
import PagerView from 'react-native-pager-view';

const PAGES = ['Page 0', 'Page 1'];
const BUSY_MS = 5000;

export function Issue424SwipeCancelsPressRepro() {
const [selectedPage, setSelectedPage] = useState(0);
const [presses, setPresses] = useState(0);
const [touch, setTouch] = useState('none');

return (
<View style={styles.screen}>
<Text testID="issue-424-status" style={styles.status}>
{`Page: ${selectedPage} | Presses: ${presses} | Touch: ${touch}`}
</Text>
<Button
testID="issue-424-block-js"
title="Block JS"
onPress={() => {
const end = Date.now() + BUSY_MS;
while (Date.now() < end) {
// Busy-wait, like a JS thread stuck in a long task.
}
}}
/>
<PagerView
style={styles.pager}
onPageSelected={(e) => setSelectedPage(e.nativeEvent.position)}
>
{PAGES.map((label, i) => (
<View
key={label}
style={styles.page}
onTouchStart={() => setTouch('start')}
onTouchEnd={() => setTouch('end')}
onTouchCancel={() => setTouch('cancel')}
>
<Pressable
testID={`issue-424-button-${i}`}
style={[styles.button, presses > 0 && styles.pressed]}
onPress={() => setPresses((count) => count + 1)}
>
<Text style={styles.label}>
{presses > 0 ? `Pressed ${presses}x` : label}
</Text>
</Pressable>
</View>
))}
</PagerView>
</View>
);
}

const styles = StyleSheet.create({
screen: { flex: 1 },
status: { padding: 12, fontSize: 16 },
pager: { flex: 1 },
page: { alignItems: 'center', justifyContent: 'center' },
button: {
width: '80%',
height: '60%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e2e8fd',
},
pressed: { backgroundColor: '#f87171' },
label: { fontSize: 24 },
});
Loading