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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public RNGestureHandlerRootViewManagerDelegate(U viewManager) {
@Override
public void setProperty(T view, String propName, @Nullable Object value) {
switch (propName) {
case "pointerEvents":
mViewManager.setPointerEvents(view, (String) value);
break;
case "unstable_forceActive":
mViewManager.setUnstable_forceActive(view, value == null ? false : (boolean) value);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
package com.facebook.react.viewmanagers;

import android.view.View;
import androidx.annotation.Nullable;
import com.facebook.react.uimanager.ViewManagerWithGeneratedInterface;

public interface RNGestureHandlerRootViewManagerInterface<T extends View> extends ViewManagerWithGeneratedInterface {
void setPointerEvents(T view, @Nullable String value);
void setUnstable_forceActive(T view, boolean value);
}
Original file line number Diff line number Diff line change
Expand Up @@ -568,10 +568,14 @@ open class GestureHandler {
}

// if there are tracked pointers and the gesture is about to end, send event cancelling all pointers
if (trackedPointersCount > 0 &&
(newState == STATE_END || newState == STATE_CANCELLED || newState == STATE_FAILED)
) {
if (trackedPointersCount > 0 && isFinished(newState)) {
cancelPointers()

// `cancelPointers` dispatches the event synchronously and its callback may change the state
// (e.g. via `GestureStateManager.fail`), in which case the transition is already complete.
if (isFinished(state)) {
return
}
}

val oldState = state
Expand Down Expand Up @@ -951,6 +955,8 @@ open class GestureHandler {
}

companion object {
fun isFinished(state: Int) = state == STATE_CANCELLED || state == STATE_FAILED || state == STATE_END

private const val DEFAULT_NEEDS_POINTER_DATA = false
private const val DEFAULT_MANUAL_ACTIVATION = false
private const val DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class GestureHandlerOrchestrator(

private fun cleanupFinishedHandlers() {
for (handler in gestureHandlers.asReversed()) {
if (isFinished(handler.state) && !handler.isAwaiting) {
if (GestureHandler.isFinished(handler.state) && !handler.isAwaiting) {
handler.reset()
handler.apply {
isActive = false
Expand All @@ -107,13 +107,13 @@ class GestureHandlerOrchestrator(
}
}

gestureHandlers.removeAll { isFinished(it.state) && !it.isAwaiting }
gestureHandlers.removeAll { GestureHandler.isFinished(it.state) && !it.isAwaiting }

finishedHandlersCleanupScheduled = false
}

private fun hasOtherHandlerToWaitFor(handler: GestureHandler) =
gestureHandlers.any { !isFinished(it.state) && shouldHandlerWaitForOther(handler, it) }
gestureHandlers.any { !GestureHandler.isFinished(it.state) && shouldHandlerWaitForOther(handler, it) }

private fun shouldBeCancelledByFinishedHandler(handler: GestureHandler) =
gestureHandlers.any { shouldHandlerWaitForOther(handler, it) && it.state == GestureHandler.STATE_END }
Expand Down Expand Up @@ -163,7 +163,7 @@ class GestureHandlerOrchestrator(
handler.isAwaiting = false
}

if (isFinished(newState)) {
if (GestureHandler.isFinished(newState)) {
// We have to loop through copy in order to avoid modifying collection
// while iterating over its elements
val currentlyAwaitingHandlers = awaitingHandlers.toList()
Expand Down Expand Up @@ -678,6 +678,7 @@ class GestureHandlerOrchestrator(
continue
}
view.onTouchEvent(cancelEvent)
view.stopNestedScroll()
}

cancelEvent.recycle()
Expand Down Expand Up @@ -927,9 +928,5 @@ class GestureHandlerOrchestrator(
true
}
}

private fun isFinished(state: Int) = state == GestureHandler.STATE_CANCELLED ||
state == GestureHandler.STATE_FAILED ||
state == GestureHandler.STATE_END
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ class HoverGestureHandler : GestureHandler() {
override fun onReset() {
super.onReset()
stylusData = StylusData()
handler?.removeCallbacksAndMessages(null)
}

private fun finish() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView:
}

companion object {
private const val MIN_ALPHA_FOR_TOUCH = 0.1f
// Same threshold as UIKit / RCTViewComponentView hit testing on iOS.
private const val MIN_ALPHA_FOR_TOUCH = 0.01f
private fun findRootViewTag(viewGroup: ViewGroup): ViewGroup {
UiThreadUtil.assertOnUiThread()
var parent: ViewParent? = viewGroup
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.swmansion.gesturehandler.react

import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.PointerEvents.Companion.parsePointerEvents
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
Expand Down Expand Up @@ -33,6 +34,11 @@ class RNGestureHandlerRootViewManager :
view.tearDown()
}

@ReactProp(name = "pointerEvents")
override fun setPointerEvents(view: RNGestureHandlerRootView, pointerEventsStr: String?) {
view.pointerEvents = parsePointerEvents(pointerEventsStr)
}

@ReactProp(name = "unstable_forceActive")
override fun setUnstable_forceActive(view: RNGestureHandlerRootView, active: Boolean) {
view.setUnstableForceActive(active)
Expand Down
18 changes: 15 additions & 3 deletions packages/react-native-gesture-handler/apple/RNGestureHandler.mm
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ - (instancetype)initWithTag:(NSNumber *)tag
- (void)resetConfig
{
self.enabled = YES;
self.manualActivation = NO;
_manualActivation = NO;
_shouldCancelWhenOutside = NO;
_handlersToWaitFor = nil;
_simultaneousHandlers = nil;
Expand Down Expand Up @@ -176,6 +176,8 @@ - (void)configure:(NSDictionary *)config
RCTLogError(@"Cannot have all of top, bottom and height defined");
}
}

[self syncManualActivationRecognizer];
}

- (void)setEnabled:(BOOL)enabled
Expand Down Expand Up @@ -480,14 +482,24 @@ - (void)stopActivationBlocker
- (void)setManualActivation:(BOOL)manualActivation
{
_manualActivation = manualActivation;
[self syncManualActivationRecognizer];
}

- (void)syncManualActivationRecognizer
{
BOOL hasRecognizer = _manualActivationRecognizer != nil;

if (manualActivation) {
if (hasRecognizer == _manualActivation) {
return;
}

if (_manualActivation) {
_manualActivationRecognizer = [[RNManualActivationRecognizer alloc] initWithGestureHandler:self];

if (_recognizer.view != nil) {
[_recognizer.view addGestureRecognizer:_manualActivationRecognizer];
}
} else if (_manualActivationRecognizer != nil) {
} else {
[_manualActivationRecognizer.view removeGestureRecognizer:_manualActivationRecognizer];
_manualActivationRecognizer = nil;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
// This is necessary because RCTViewComponentView's hitTest might handle pointerEvents
// from ViewProps and prevent touches from reaching _buttonView (which is the contentView).
// Since _buttonView has its own pointerEvents handling, we always forward to it.
// Keep the visibility checks RCTViewComponentView applies, so a hidden or
// (nearly) transparent button is skipped like any other RN view.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (!self.userInteractionEnabled || self.hidden || self.alpha < 0.01) {
return nil;
}

if (![self pointInside:point withEvent:event]) {
return nil;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react-native';
import React from 'react';
import { findNodeHandle, View } from 'react-native';

import { Wrap } from '../handlers/gestures/GestureDetector/Wrap';
import * as reanimatedWrapper from '../handlers/gestures/reanimatedWrapper';
import { Gesture, GestureDetector, GestureHandlerRootView } from '../index';
import RNGestureHandlerModule from '../RNGestureHandlerModule';

Expand Down Expand Up @@ -132,6 +134,65 @@ describe('Legacy GestureDetector ref forwarding', () => {
expect(secondRef).toHaveBeenCalledWith(childInstance);
});

describe('animatable ref for Reanimated', () => {
let refreshSpy: jest.SpyInstance;

beforeEach(() => {
refreshSpy = jest.spyOn(
reanimatedWrapper,
'reanimatedRefreshesAnimatableRef'
);
});

afterEach(() => {
refreshSpy.mockRestore();
});

test('exposes the child host instance when Reanimated re-resolves it on update', () => {
refreshSpy.mockReturnValue(true);
const wrapRef = React.createRef<Wrap>();
const childRef = React.createRef<ChildWithHostInstance>();

render(
<Wrap ref={wrapRef}>
<ChildWithHostInstance ref={childRef} />
</Wrap>
);

expect(childRef.current).not.toBeNull();
expect(wrapRef.current?.getAnimatableRef()).toBe(childRef.current);
});

test('falls back to itself without a host instance', () => {
refreshSpy.mockReturnValue(true);
const wrapRef = React.createRef<Wrap>();

render(
<Wrap ref={wrapRef}>
<ChildIgnoringRef />
</Wrap>
);

expect(wrapRef.current).not.toBeNull();
expect(wrapRef.current?.getAnimatableRef()).toBe(wrapRef.current);
});

test('returns itself when Reanimated caches the animatable ref', () => {
refreshSpy.mockReturnValue(false);
const wrapRef = React.createRef<Wrap>();
const childRef = React.createRef<ChildWithHostInstance>();

render(
<Wrap ref={wrapRef}>
<ChildWithHostInstance ref={childRef} />
</Wrap>
);

expect(childRef.current).not.toBeNull();
expect(wrapRef.current?.getAnimatableRef()).toBe(wrapRef.current);
});
});

test('does not reattach gestures on re-render', () => {
const gesture = Gesture.Tap();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import {
assignRef,
isHostInstance,
preferHostInstance,
resolveHostInstance,
} from '../../../hostInstance';
import { Reanimated } from '../reanimatedWrapper';
import {
Reanimated,
reanimatedRefreshesAnimatableRef,
} from '../reanimatedWrapper';
import { tagMessage } from '../../../utils';

export class Wrap extends React.Component<{
Expand All @@ -24,6 +28,18 @@ export class Wrap extends React.Component<{
return this.hostInstance;
}

// Used by Reanimated's `createAnimatedComponent` (`AnimatedWrap`). Without it,
// Reanimated calls `findHostInstance_DEPRECATED` on this class instance, which
// warns under StrictMode. Reanimated caches the result, so the child's host
// instance is only safe to hand out when it re-resolves the ref on update;
// otherwise return the instance itself, which keeps the deprecated lookup.
// eslint-disable-next-line @eslint-react/no-unused-class-component-members
public getAnimatableRef() {
return reanimatedRefreshesAnimatableRef()
? resolveHostInstance(this)
: this;
}

private detachChildRef() {
if (this.childRefCleanup !== undefined) {
this.childRefCleanup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
extractGestureRelations,
checkGestureCallbacksForWorklets,
ALLOWED_PROPS,
hasExternalRelations,
} from './utils';
import { MountRegistry } from '../../../mountRegistry';

Expand Down Expand Up @@ -53,6 +54,9 @@ export function attachHandlers({
registerHandler(handler.handlerTag, handler, handler.config.testId);
}

// React to every mount until the microtask below classifies the new gesture set
preparedGesture.hasExternalRelations = true;

// Use queueMicrotask to extract handlerTags, because all refs should be initialized
// when it's ran
ghQueueMicrotask(() => {
Expand All @@ -70,9 +74,16 @@ export function attachHandlers({
);
}

preparedGesture.hasExternalRelations =
hasExternalRelations(gesturesToAttach);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

scheduleFlushOperations();
});

// Set before the mount events below fire, so this detector's own mount
// listener can tell its gestures apart from external ones
preparedGesture.attachedGestures = gesturesToAttach;

for (const gesture of gesturesToAttach) {
const actionType = gesture.shouldUseReanimated
? ActionType.REANIMATED_WORKLET
Expand All @@ -98,8 +109,6 @@ export function attachHandlers({
MountRegistry.gestureWillMount(gesture);
}

preparedGesture.attachedGestures = gesturesToAttach;

if (preparedGesture.animatedHandlers) {
const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ export const GestureDetector = (props: GestureDetectorProps) => {
animatedHandlers: null,
shouldUseReanimated: shouldUseReanimated,
isMounted: false,
// The real value is computed in the attach microtask, which runs after
// this commit's effects. Until then react to every mount, so a relation
// to a gesture mounting in the same commit is never skipped.
hasExternalRelations: true,
}).current;

const updateAttachedGestures = useDetectorUpdater(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface AttachedGestureState {
shouldUseReanimated: boolean;
// Whether the GestureDetector is mounted
isMounted: boolean;
// Whether any attached gesture has a relation to a gesture outside this
// detector (a ref or a gesture object), which may resolve on a later mount
hasExternalRelations: boolean;
}

export interface GestureDetectorState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
extractGestureRelations,
checkGestureCallbacksForWorklets,
ALLOWED_PROPS,
hasExternalRelations,
} from './utils';

export function updateHandlers(
Expand All @@ -33,6 +34,9 @@ export function updateHandlers(
// Store attached gestures to avoid crash when gestures changed after queueing micro task
const attachedGestures = preparedGesture.attachedGestures;

// React to every mount until the microtask below classifies the new gesture set
preparedGesture.hasExternalRelations = true;

// Use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated
// and handlerTags in BaseGesture references should be updated in the loop above (we need to wait
// in case of external relations)
Expand Down Expand Up @@ -77,6 +81,10 @@ export function updateHandlers(
registerHandler(handler.handlerTag, handler, handler.config.testId);
}

// Classify the new instances: their configs reference the new siblings,
// which the old attached objects would mistake for external gestures
preparedGesture.hasExternalRelations = hasExternalRelations(newGestures);

if (preparedGesture.animatedHandlers && shouldUpdateSharedValueIfUsed) {
const newHandlersValue = attachedGestures
.filter((g) => g.shouldUseReanimated) // Ignore gestures that shouldn't run on UI
Expand Down
Loading
Loading