Skip to content

Commit 5f0f1c9

Browse files
committed
Merge branch 'main' into @jpiasecki/web-gate-pointer-cancel
2 parents 5650e07 + 9c6f72c commit 5f0f1c9

9 files changed

Lines changed: 103 additions & 36 deletions

File tree

packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,6 @@ - (void)handleTouchDown:(UIView *)sender forEvent:(UIEvent *)event
253253
withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES
254254
withNumberOfTouches:event.allTouches.count
255255
withPointerType:_pointerType]];
256-
257-
[self sendActiveStateEventIfChangedForView:sender
258-
extraData:[RNGestureHandlerEventExtraData forPointerInside:YES
259-
withNumberOfTouches:event.allTouches.count
260-
withPointerType:_pointerType]];
261256
}
262257

263258
- (void)handleTouchUpOutside:(UIView *)sender forEvent:(UIEvent *)event
@@ -275,11 +270,12 @@ - (void)handleTouchUpOutside:(UIView *)sender forEvent:(UIEvent *)event
275270

276271
- (void)handleTouchUpInside:(UIView *)sender forEvent:(UIEvent *)event
277272
{
278-
[self sendEventsInState:RNGestureHandlerStateEnd
279-
forViewWithTag:sender.reactTag
280-
withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES
281-
withNumberOfTouches:event.allTouches.count
282-
withPointerType:_pointerType]];
273+
RNGestureHandlerEventExtraData *extraData = [RNGestureHandlerEventExtraData forPointerInside:YES
274+
withNumberOfTouches:event.allTouches.count
275+
withPointerType:_pointerType];
276+
277+
[self sendActiveStateEventIfChangedForView:sender extraData:extraData];
278+
[self sendEventsInState:RNGestureHandlerStateEnd forViewWithTag:sender.reactTag withExtraData:extraData];
283279
}
284280

285281
- (void)handleDragExit:(UIView *)sender forEvent:(UIEvent *)event

packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@
4949
*/
5050
- (void)applyStartAnimationState;
5151

52+
/**
53+
* Resets all transient press/animation state so the button can be safely reused
54+
* by Fabric view recycling. Cancels pending press-out blocks, removes in-flight
55+
* animations, and unconditionally restores the animation target's transform/alpha
56+
* and the underlay opacity to neutral values.
57+
*/
58+
- (void)prepareForRecycle;
59+
5260
/**
5361
* Updates the underlay layer's corner radii with separate horizontal/vertical
5462
* components per corner, supporting elliptical inner corners when border widths

packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ - (void)cancelPendingPressOutAnimation
117117
[_underlayLayer removeAllAnimations];
118118
}
119119

120+
- (void)prepareForRecycle
121+
{
122+
// Fabric reuses the same wrapper + button instance across mounts. Without
123+
// this reset, residual press-in transform/alpha/underlay-opacity from a
124+
// prior use leaks into the recycled view, and `updateProps:` won't undo it
125+
// when defaults are unchanged between mounts.
126+
[self cancelPendingPressOutAnimation];
127+
128+
RNGHUIView *target = self.animationTarget ?: self;
129+
target.layer.transform = CATransform3DIdentity;
130+
#if !TARGET_OS_OSX
131+
target.alpha = 1.0;
132+
#else
133+
target.alphaValue = 1.0;
134+
#endif
135+
_underlayLayer.opacity = 0;
136+
137+
_isTouchInsideBounds = NO;
138+
_suppressSuperControlActionDispatch = NO;
139+
_pressInTimestamp = 0;
140+
}
141+
120142
#if TARGET_OS_OSX
121143
- (void)viewWillMoveToWindow:(RNGHWindow *)newWindow
122144
{

packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ @interface RNGestureHandlerButtonComponentView () <RCTRNGestureHandlerButtonView
3434

3535
@implementation RNGestureHandlerButtonComponentView {
3636
RNGestureHandlerButton *_buttonView;
37+
BOOL _needsAnimationStateReset;
3738
}
3839

3940
#if TARGET_OS_OSX
@@ -67,6 +68,20 @@ - (instancetype)initWithFrame:(CGRect)frame
6768
return self;
6869
}
6970

71+
- (void)prepareForRecycle
72+
{
73+
[self.layer removeAnimationForKey:@"transform"];
74+
self.layer.transform = CATransform3DIdentity;
75+
76+
[_buttonView prepareForRecycle];
77+
78+
// Force the next updateProps: to re-run applyStartAnimationState even if
79+
// the new mount's defaults match the previous mount's.
80+
_needsAnimationStateReset = YES;
81+
82+
[super prepareForRecycle];
83+
}
84+
7085
- (void)mountChildComponentView:(RNGHUIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
7186
{
7287
[_buttonView mountChildComponentView:childComponentView index:index];
@@ -143,8 +158,33 @@ - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetric
143158

144159
- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
145160
{
161+
// super's invalidateLayer (called via finalizeUpdates) unconditionally sets
162+
// self.layer.opacity to the React style.opacity, overwriting our
163+
// applyStartAnimationState alpha and interrupting in-flight press
164+
// animations. Save/restore around super, but only touch what super actually
165+
// disturbed — re-adding an unchanged animation resets its progress.
166+
float savedOpacity = self.layer.opacity;
167+
CAAnimation *savedOpacityAnimation = [self.layer animationForKey:@"opacity"];
168+
146169
[super finalizeUpdates:updateMask];
147170

171+
BOOL opacityChanged = savedOpacity != self.layer.opacity;
172+
BOOL animationChanged = savedOpacityAnimation != [self.layer animationForKey:@"opacity"];
173+
if (opacityChanged || animationChanged) {
174+
[CATransaction begin];
175+
[CATransaction setDisableActions:YES];
176+
if (animationChanged) {
177+
[self.layer removeAnimationForKey:@"opacity"];
178+
if (savedOpacityAnimation) {
179+
[self.layer addAnimation:savedOpacityAnimation forKey:@"opacity"];
180+
}
181+
}
182+
if (opacityChanged) {
183+
self.layer.opacity = savedOpacity;
184+
}
185+
[CATransaction commit];
186+
}
187+
148188
// Resolve per-corner border radii from props and forward to the button
149189
// so its underlay CALayer gets the matching shape.
150190
const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics);
@@ -267,11 +307,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
267307
{
268308
const auto &newProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(props);
269309

270-
// Re-apply the idle visual state only on first mount or when one of the default
271-
// values actually changed. Doing it on every commit interrupts in-flight press
272-
// animations whenever React re-renders the children mid-press.
273-
BOOL shouldApplyStartAnimationState = !oldProps;
274-
if (oldProps) {
310+
// After recycling, treat diffing branches as a fresh mount so values that
311+
// survived recycling on _buttonView (e.g. pointerEvents) get re-applied.
312+
BOOL treatAsFirstMount = !oldProps || _needsAnimationStateReset;
313+
_needsAnimationStateReset = NO;
314+
315+
// Avoid re-running applyStartAnimationState on every commit — it would
316+
// interrupt in-flight press animations during mid-press re-renders.
317+
BOOL shouldApplyStartAnimationState = treatAsFirstMount;
318+
if (!treatAsFirstMount) {
275319
const auto &oldButtonProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(oldProps);
276320
shouldApplyStartAnimationState = oldButtonProps.defaultOpacity != newProps.defaultOpacity ||
277321
oldButtonProps.defaultScale != newProps.defaultScale ||
@@ -303,7 +347,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
303347
// This is necessary because pointerEvents is redefined in the spec,
304348
// which shadows the base property with a different, incompatible type.
305349
const auto &newViewProps = static_cast<const ViewProps &>(newProps);
306-
if (!oldProps) {
350+
if (treatAsFirstMount) {
307351
_buttonView.pointerEvents = RCTPointerEventsToEnum(newViewProps.pointerEvents);
308352
} else {
309353
const auto &oldButtonProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(oldProps);

packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
toView:(nonnull RNGHUIView *)view
1717
withActionType:(RNGestureHandlerActionType)actionType
1818
withHostDetector:(nullable RNGHUIView *)hostDetector;
19-
- (void)detachHandlerWithTag:(nonnull NSNumber *)handlerTag;
2019
- (void)detachHandlerWithTag:(nonnull NSNumber *)handlerTag fromHostDetector:(nonnull RNGHUIView *)hostDetectorView;
2120
- (void)dropHandlerWithTag:(nonnull NSNumber *)handlerTag;
2221
- (void)dropAllHandlers;

packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('[API v3] Components', () => {
134134
>;
135135
const { jsEventHandler } = gesture.detectorCallbacks;
136136

137-
// Fire BEGAN
137+
// Fire BEGAN — long press timer starts here
138138
act(() => {
139139
jsEventHandler?.({
140140
oldState: State.UNDETERMINED,
@@ -145,7 +145,7 @@ describe('[API v3] Components', () => {
145145
});
146146
});
147147

148-
// Fire ACTIVE — long press timer starts here (on iOS / non-Android)
148+
// Fire ACTIVE
149149
act(() => {
150150
jsEventHandler?.({
151151
oldState: State.BEGAN,

packages/react-native-gesture-handler/src/components/GestureButtons.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,37 +36,38 @@ class InnerBaseButton extends React.Component<BaseButtonWithRefProps> {
3636
delayLongPress: 600,
3737
};
3838

39-
private lastActive: boolean;
39+
private lastIsPressed: boolean;
4040
private longPressTimeout: ReturnType<typeof setTimeout> | undefined;
4141
private longPressDetected: boolean;
4242

4343
constructor(props: BaseButtonWithRefProps) {
4444
super(props);
45-
this.lastActive = false;
45+
this.lastIsPressed = false;
4646
this.longPressDetected = false;
4747
}
4848

4949
private handleEvent = ({
5050
nativeEvent,
5151
}: HandlerStateChangeEvent<NativeViewGestureHandlerPayload>) => {
5252
const { state, oldState, pointerInside } = nativeEvent;
53-
const active = pointerInside && state === State.ACTIVE;
53+
const isPressed =
54+
pointerInside && (state === State.BEGAN || state === State.ACTIVE);
5455

55-
if (active !== this.lastActive && this.props.onActiveStateChange) {
56-
this.props.onActiveStateChange(active);
56+
if (isPressed !== this.lastIsPressed && this.props.onActiveStateChange) {
57+
this.props.onActiveStateChange(isPressed);
5758
}
5859

5960
if (
6061
!this.longPressDetected &&
6162
oldState === State.ACTIVE &&
6263
state !== State.CANCELLED &&
63-
this.lastActive &&
64+
this.lastIsPressed &&
6465
this.props.onPress
6566
) {
6667
this.props.onPress(pointerInside);
6768
}
6869

69-
if (!this.lastActive && state === State.BEGAN && pointerInside) {
70+
if (!this.lastIsPressed && state === State.BEGAN && pointerInside) {
7071
this.longPressDetected = false;
7172
if (this.props.onLongPress) {
7273
this.longPressTimeout = setTimeout(
@@ -93,7 +94,7 @@ class InnerBaseButton extends React.Component<BaseButtonWithRefProps> {
9394
this.longPressTimeout = undefined;
9495
}
9596

96-
this.lastActive = active;
97+
this.lastIsPressed = isPressed;
9798
};
9899

99100
private onLongPress = () => {

packages/react-native-gesture-handler/src/components/touchables/GenericTouchable.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { Component } from 'react';
3-
import { Animated, Platform } from 'react-native';
3+
import { Animated } from 'react-native';
44

55
import type {
66
GestureEvent,
@@ -174,10 +174,7 @@ export default class GenericTouchable extends Component<
174174
// Need to handle case with external cancellation (e.g. by ScrollView)
175175
this.moveToState(TOUCHABLE_STATE.UNDETERMINED);
176176
} else if (
177-
// This platform check is an implication of slightly different behavior of handlers on different platform.
178-
// And Android "Active" state is achieving on first move of a finger, not on press in.
179-
// On iOS event on "Began" is not delivered.
180-
state === (Platform.OS !== 'android' ? State.ACTIVE : State.BEGAN) &&
177+
state === State.BEGAN &&
181178
this.STATE === TOUCHABLE_STATE.UNDETERMINED
182179
) {
183180
// Moving inside requires

packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,16 @@ export const BaseButton = (props: BaseButtonProps) => {
6969
};
7070

7171
const onDeactivate = (e: EndCallbackEventType) => {
72+
props.onDeactivate?.(e);
73+
};
74+
75+
const onFinalize = (e: EndCallbackEventType) => {
7276
onActiveStateChange?.(false);
7377

7478
if (!e.canceled && !longPressDetected.current) {
7579
onPress?.(e.pointerInside);
7680
}
7781

78-
props.onDeactivate?.(e);
79-
};
80-
81-
const onFinalize = (e: EndCallbackEventType) => {
8282
if (longPressTimeout.current !== undefined) {
8383
clearTimeout(longPressTimeout.current);
8484
longPressTimeout.current = undefined;

0 commit comments

Comments
 (0)