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
63 changes: 63 additions & 0 deletions .argent/flows/native-intercept-cancellation-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Android only: a native view (nested PagerView) takes over the touch without
# the root seeing a disallow-intercept request. The tap under it must be
# cancelled by the framework's ACTION_CANCEL instead of firing on lift.
steps:
- echo: Launching Expo Example
- launch: com.example.ExpoExample
- await: { visible: { text: "Empty Example" }, timeout: 15000 }
- when: { platform: android }
steps:
- echo: "On the Home list, search for the native intercept cancellation example"
- type: { text: "intercept", into: { id: "search-examples" } }
- await: { visible: { text: "Native intercept cancellation" } }
- echo: "Tap the example to open it"
- tap: { text: "Native intercept cancellation" }
- await: { visible: { id: "tap-box" } }
- await: { idle: true }

- echo: "Clear console"
- await: { visible: { id: "open-console-button" } }
- tap: { id: "open-console-button" }
- await: { visible: { id: "clear-console-button" } }
- tap: { id: "clear-console-button" }
- tap: { id: "close-console-button" }
- await: { hidden: { id: "close-console-button" } }
- await: { idle: true }

- echo: "Swipe the inner pager starting on the tap box"
- assert: { visible: { text: "Tap count: 0" } }
- swipe: { from: { id: "tap-box" }, direction: left }
- await: { visible: { id: "inner-page-2" } }
- await: { idle: true }

- echo: "The pager moved and the tap did not fire"
- tap: { id: "open-console-button" }
- await: { idle: true }
- await: { visible: { id: "close-console-button" } }
- assert: { visible: { text: "1. onBegin" } }
- assert: { visible: { text: "2. onFinalize" } }
- assert: { hidden: { text: "onActivate" } }
- assert: { hidden: { text: "onDeactivate" } }

- echo: "Clear console"
- tap: { id: "clear-console-button" }
- tap: { id: "close-console-button" }
- await: { hidden: { id: "close-console-button" } }
- await: { idle: true }

- echo: "Swipe back to the first page"
- swipe: { from: { id: "inner-page-2" }, direction: right }
- await: { visible: { id: "tap-box" } }
- await: { idle: true }
- assert: { visible: { text: "Tap count: 0" } }

- echo: "A plain tap on the box still counts"
- tap: { id: "tap-box" }
- await: { visible: { text: "Tap count: 1" } }
- tap: { id: "open-console-button" }
- await: { idle: true }
- await: { visible: { id: "close-console-button" } }
- assert: { visible: { text: "3. onBegin" } }
- assert: { visible: { text: "4. onActivate" } }
- assert: { visible: { text: "5. onDeactivate" } }
- assert: { visible: { text: "6. onFinalize" } }
6 changes: 6 additions & 0 deletions apps/common-app/src/new_api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import PinchExample from './simple/pinch';
import RotationExample from './simple/rotation';
import TapExample from './simple/tap';
import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps';
import NativeInterceptCancellationExample from './tests/nativeInterceptCancellation';
import NestedPressablesExample from './tests/nestedPressables';
import NestedRootViewExample from './tests/nestedRootView';
import NestedTouchablesExample from './tests/nestedTouchables';
Expand Down Expand Up @@ -130,6 +131,11 @@ export const NEW_EXAMPLES: ExamplesSection[] = [
{ name: 'Modal with Nested Root View', component: NestedRootViewExample },
{ name: 'Nested pressables', component: NestedPressablesExample },
{ name: 'Nested touchables', component: NestedTouchablesExample },
{
name: 'Native intercept cancellation',
component: NativeInterceptCancellationExample,
unsupportedPlatforms: new Set(['ios', 'macos', 'web']),
},
{ name: 'Pressable', component: PressableExample },
{
name: 'RN responder cancellation',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import {
DrawerLayoutAndroid,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { GestureDetector, useTapGesture } from 'react-native-gesture-handler';
import PagerView from 'react-native-pager-view';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { COLORS, useIndexedLogger } from '../../../common';

// A native view takes over the touch without the root ever seeing a
// disallow-intercept request. The framework still sends ACTION_CANCEL to the
// tap's detector, which is what cancels the tap below.
// - Pager: swipe the inner pager starting on the box. The inner pager host
// asks its ancestors not to intercept on DOWN, so the pager's own request on
// MOVE stops at the already flagged page view and never reaches the root.
// - Drawer: swipe the drawer open from the right edge starting on the box.
// DrawerLayout intercepts on MOVE and never requests at all. With gesture
// navigation the system back swipe takes the edge, use 3-button navigation.
// In both cases the pager / drawer moves and the tap count stays the same.

type Mode = 'pager' | 'drawer';

type TapBoxProps = {
count: number;
onTap: () => void;
log: (message: string) => void;
};

function TapBox({ count, onTap, log }: TapBoxProps) {
const colorProgress = useSharedValue(0);

const animatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorProgress.value,
[0, 1],
[COLORS.NAVY, COLORS.KINDA_BLUE]
),
}));

const tap = useTapGesture({
onBegin: () => {
log('onBegin');
colorProgress.value = withTiming(1, { duration: 100 });
},
onActivate: () => {
log('onActivate');
scheduleOnRN(onTap);
},
onDeactivate: () => {
log('onDeactivate');
},
onFinalize: () => {
log('onFinalize');
colorProgress.value = withTiming(0, { duration: 100 });
},
});

return (
<GestureDetector gesture={tap}>
<Animated.View testID="tap-box" style={[styles.box, animatedStyle]}>
<Text style={styles.boxText}>Tap count: {count}</Text>
</Animated.View>
</GestureDetector>
);
}

function PagerMode(props: TapBoxProps) {
return (
<PagerView style={styles.flex} initialPage={0}>
<View key="outer-1" style={styles.flex}>
<PagerView style={styles.flex} initialPage={0}>
<View key="inner-1" style={styles.flex}>
<TapBox {...props} />
</View>
<View key="inner-2" style={[styles.flex, styles.center]}>
<Text testID="inner-page-2">Inner page 2</Text>
</View>
</PagerView>
</View>
<View key="outer-2" style={[styles.flex, styles.center]}>
<Text>Outer page 2</Text>
</View>
</PagerView>
);
}

function DrawerMode(props: TapBoxProps) {
return (
<DrawerLayoutAndroid
style={styles.flex}
drawerWidth={300}
drawerPosition="right"
renderNavigationView={() => (
<View testID="drawer-panel" style={[styles.flex, styles.center]}>
<Text>Drawer</Text>
</View>
)}>
<TapBox {...props} />
</DrawerLayoutAndroid>
);
}

export default function NativeInterceptCancellationExample() {
const [mode, setMode] = useState<Mode>('pager');
const [count, setCount] = useState(0);
const log = useIndexedLogger();

const boxProps = {
count,
log,
onTap: () => setCount((c) => c + 1),
};

return (
<View style={styles.flex}>
<View style={styles.header}>
<Text style={styles.hint}>
Swipe the pager or drag the drawer open from the right edge, starting
on the box. The count must not change.
</Text>
<View style={styles.buttons}>
<ModeButton mode="pager" current={mode} onSelect={setMode} />
<ModeButton mode="drawer" current={mode} onSelect={setMode} />
</View>
</View>
{mode === 'pager' ? (
<PagerMode {...boxProps} />
) : (
<DrawerMode {...boxProps} />
)}
</View>
);
}

type ModeButtonProps = {
mode: Mode;
current: Mode;
onSelect: (mode: Mode) => void;
};

function ModeButton({ mode, current, onSelect }: ModeButtonProps) {
return (
<Pressable
testID={`mode-${mode}`}
style={[styles.button, mode === current && styles.buttonActive]}
onPress={() => onSelect(mode)}>
<Text style={styles.buttonText}>
{mode === 'pager' ? 'Pager' : 'Drawer'}
</Text>
</Pressable>
);
}

const styles = StyleSheet.create({
flex: { flex: 1 },
center: { alignItems: 'center', justifyContent: 'center' },
header: {
padding: 12,
gap: 8,
backgroundColor: COLORS.offWhite,
},
hint: {
textAlign: 'center',
opacity: 0.6,
fontSize: 14,
},
buttons: { flexDirection: 'row', justifyContent: 'center', gap: 8 },
button: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 6,
backgroundColor: COLORS.KINDA_YELLOW,
},
buttonActive: { backgroundColor: COLORS.YELLOW },
buttonText: { fontWeight: '600', color: COLORS.NAVY },
box: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
boxText: {
fontSize: 20,
fontWeight: '700',
color: COLORS.offWhite,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -366,19 +366,23 @@ class GestureHandlerOrchestrator(
}

private inline fun cancelHandlersMatching(predicate: (GestureHandler) -> Boolean) {
val handlersToProcess = obtainHandlerList()
handlersToProcess.addAll(gestureHandlers)
val handlersToCancel = obtainHandlerList()
gestureHandlers.filterTo(handlersToCancel, predicate)

try {
handlersToProcess.forEach {
if (predicate(it)) {
it.cancel()
// Cancelling a handler activates the handlers awaiting its failure. Drop the batch from the
// awaiting pool first, so cancelling one member cannot activate another before its turn.
for (handler in handlersToCancel) {
if (awaitingHandlersTags.remove(handler.tag)) {
awaitingHandlers.remove(handler)
}
}

handlersToCancel.forEach { it.cancel() }

scheduleFinishedHandlersCleanup()
} finally {
recycleHandlerList(handlersToProcess)
recycleHandlerList(handlersToCancel)
}
}

Expand All @@ -403,6 +407,16 @@ class GestureHandlerOrchestrator(
cancelledByHook || cancelledByDetector
}

/**
* Cancels pending detector handlers whose detector got ACTION_CANCEL from the native dispatch of
* the event with `eventTime`. Unlike a disallow-intercept request, the CANCEL names its target.
*/
fun cancelHandlersOnNativeTouchCancel(eventTime: Long) = cancelHandlersMatching {
!it.isActive &&
GestureHandler.usesNativeOrVirtualDetector(it.actionType) &&
it.hostDetectorView?.shouldCancelOnNativeTouchCancel(eventTime) == true
Comment thread
m-bert marked this conversation as resolved.
}

/**
* isViewAttachedUnderWrapper checks whether all of parents for view related to handler
* view are attached. Since there might be an issue rarely observed when view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
// bypasses `dispatchTouchEvent`, so a DOWN intercepted by an ancestor never updates it.
private var lastNativeDownTime = -1L

// Event time of the last CANCEL native dispatch delivered to this view. The cancel loop of an
// intercepting ancestor reuses the intercepted event, so it matches that event's time.
private var lastNativeCancelTime = -1L

data class VirtualChildren(val handlerTags: List<Int>, val viewTag: Int)

fun setHandlerTags(handlerTags: ReadableArray?) {
Expand All @@ -50,12 +54,18 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
}

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
lastNativeDownTime = event.downTime
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> lastNativeDownTime = event.downTime
MotionEvent.ACTION_CANCEL -> {
lastNativeCancelTime = event.eventTime
RNGestureHandlerRootView.findGestureHandlerRootView(this)?.onNativeTouchCancelled()
}
}
return super.dispatchTouchEvent(event)
}

fun shouldCancelOnNativeTouchCancel(eventTime: Long) = lastNativeCancelTime == eventTime

/**
* A native view takes over the touch stream with `requestDisallowInterceptTouchEvent`, which only
* bubbles up, so handlers of detectors below it are never notified. Mirror the rule buttons use:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:
private var passingTouch = false
private var passingNativeTouch = false
private var nativeTouchGrabRequested = false
private var nativeTouchCancelled = false

init {
val registry =
Expand Down Expand Up @@ -138,6 +139,17 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:
passingNativeTouch = true
}

/**
* A detector got ACTION_CANCEL from the native dispatch: an ancestor took over the touch and its
* disallow-intercept request never reached the root (stopped at a flagged ancestor, or never
* made, e.g. DrawerLayout). CANCELs outside a native pass are the root's or a native handler's own.
*/
fun onNativeTouchCancelled() {
if (passingNativeTouch) {
nativeTouchCancelled = true
}
}

/**
* Deferred handling of a disallow-intercept request recorded during this dispatch pass. The
* request alone doesn't say what the caller did with the event: a scrollable calls it when it
Expand All @@ -158,6 +170,11 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:

orchestrator?.cancelHandlersOnNativeTouchGrab(grabbedMidGesture, event.downTime)
}

if (nativeTouchCancelled) {
nativeTouchCancelled = false
orchestrator?.cancelHandlersOnNativeTouchCancel(event.eventTime)
}
}

fun dispatchTouchEvent(event: MotionEvent): Boolean {
Expand Down
Loading
Loading