From 4d634b78dcac8c9873cb4feee597f68cecdd8349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 24 Jun 2026 14:38:45 +0200 Subject: [PATCH 01/11] Hover on apple --- .../docs/components/touchable.mdx | 8 +- .../apple/RNGestureHandlerButton.h | 5 + .../apple/RNGestureHandlerButton.mm | 344 ++++++++++++++++-- .../RNGestureHandlerButtonComponentView.mm | 10 +- .../src/components/GestureHandlerButton.tsx | 10 +- .../RNGestureHandlerButtonNativeComponent.ts | 8 + 6 files changed, 347 insertions(+), 38 deletions(-) diff --git a/packages/docs-gesture-handler/docs/components/touchable.mdx b/packages/docs-gesture-handler/docs/components/touchable.mdx index 787d680512..525146d293 100644 --- a/packages/docs-gesture-handler/docs/components/touchable.mdx +++ b/packages/docs-gesture-handler/docs/components/touchable.mdx @@ -237,7 +237,7 @@ defaultUnderlayOpacity?: number; Defines the initial opacity of underlay when the button is inactive. By default set to `0`. - + ### hoverOpacity @@ -247,7 +247,7 @@ hoverOpacity?: number; Defines the opacity of the whole component when the button is hovered. By default falls back to [`defaultOpacity`](#defaultopacity). - + ### hoverScale @@ -257,7 +257,7 @@ hoverScale?: number; Defines the scale of the whole component when the button is hovered. By default falls back to [`defaultScale`](#defaultscale). - + ### hoverUnderlayOpacity @@ -296,7 +296,7 @@ Press and hover animation timing, in milliseconds. Defaults to 50ms for the in p Each animation has two phases — `in` (running while the pointer engages the component) and `out` (running after the pointer releases) — across two categories: - `tap` — applies to presses. -- `hover` — pointer hover (web only). +- `hover` — pointer hover (web and iOS only). `longPress` is an optional override for the press-out timing once the press has been held past [`delayLongPress`](#delaylongpress). It only has an `out` field (the press-in is always the `tap` in duration). If omitted, the long-press release uses the resolved `tap` out duration. diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 8b9c0c7f45..d5c3a93876 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -37,6 +37,11 @@ @property (nonatomic, assign) CGFloat defaultScale; @property (nonatomic, assign) CGFloat defaultUnderlayOpacity; @property (nonatomic, assign) CGFloat activeUnderlayOpacity; +@property (nonatomic, assign) NSInteger hoverAnimationInDuration; +@property (nonatomic, assign) NSInteger hoverAnimationOutDuration; +@property (nonatomic, assign) CGFloat hoverOpacity; +@property (nonatomic, assign) CGFloat hoverScale; +@property (nonatomic, assign) CGFloat hoverUnderlayOpacity; @property (nonatomic, strong, nullable) RNGHColor *underlayColor; /** diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 77e0cd1e8f..18a7c35fa2 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -47,9 +47,18 @@ @implementation RNGestureHandlerButton { UIEdgeInsets _underlayBorderInsets; // border widths for padding-box inset NSTimeInterval _pressInTimestamp; dispatch_block_t _pendingPressOutBlock; + BOOL _isHovered; + BOOL _isPressed; + dispatch_block_t _pendingHoverOutBlock; +#if TARGET_OS_OSX + NSTrackingArea *_hoverTrackingArea; +#endif } @synthesize longPressAnimationOutDuration = _longPressAnimationOutDuration; +@synthesize hoverOpacity = _hoverOpacity; +@synthesize hoverScale = _hoverScale; +@synthesize hoverUnderlayOpacity = _hoverUnderlayOpacity; - (void)commonInit { @@ -70,6 +79,14 @@ - (void)commonInit _underlayColor = nil; _pressInTimestamp = 0; _pendingPressOutBlock = nil; + _hoverAnimationInDuration = 50; + _hoverAnimationOutDuration = 100; + _hoverOpacity = -1.0; + _hoverScale = -1.0; + _hoverUnderlayOpacity = -1.0; + _isHovered = NO; + _isPressed = NO; + _pendingHoverOutBlock = nil; #if TARGET_OS_OSX self.wantsLayer = YES; // Crucial for macOS layer-backing #endif @@ -89,6 +106,12 @@ - (void)commonInit action:@selector(handleAnimatePressOut) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDragExit | UIControlEventTouchCancel]; + + if (@available(iOS 13.4, *)) { + UIHoverGestureRecognizer *hoverRecognizer = + [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(handleHover:)]; + [self addGestureRecognizer:hoverRecognizer]; + } #endif } @@ -126,6 +149,7 @@ - (void)prepareForRecycle // prior use leaks into the recycled view, and `updateProps:` won't undo it // when defaults are unchanged between mounts. [self cancelPendingPressOutAnimation]; + [self cancelPendingHoverOut]; RNGHUIView *target = self.animationTarget ?: self; target.layer.transform = CATransform3DIdentity; @@ -139,6 +163,8 @@ - (void)prepareForRecycle _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; _pressInTimestamp = 0; + _isHovered = NO; + _isPressed = NO; } #if TARGET_OS_OSX @@ -147,10 +173,13 @@ - (void)viewWillMoveToWindow:(RNGHWindow *)newWindow [super viewWillMoveToWindow:newWindow]; if (newWindow == nil) { [self cancelPendingPressOutAnimation]; + [self cancelPendingHoverOut]; [self applyStartAnimationState]; _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; _pressInTimestamp = 0; + _isHovered = NO; + _isPressed = NO; } } #else @@ -159,10 +188,13 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow [super willMoveToWindow:newWindow]; if (newWindow == nil) { [self cancelPendingPressOutAnimation]; + [self cancelPendingHoverOut]; [self applyStartAnimationState]; _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; _pressInTimestamp = 0; + _isHovered = NO; + _isPressed = NO; } } #endif @@ -172,6 +204,72 @@ - (NSInteger)longPressAnimationOutDuration return _longPressAnimationOutDuration < 0 ? _tapAnimationOutDuration : _longPressAnimationOutDuration; } +- (CGFloat)hoverOpacity +{ + return _hoverOpacity < 0 ? _defaultOpacity : _hoverOpacity; +} + +- (CGFloat)hoverScale +{ + return _hoverScale < 0 ? _defaultScale : _hoverScale; +} + +- (CGFloat)hoverUnderlayOpacity +{ + return _hoverUnderlayOpacity < 0 ? _defaultUnderlayOpacity : _hoverUnderlayOpacity; +} + +- (BOOL)hasOpacityAnimation +{ + return _defaultOpacity != 1.0 || self.hoverOpacity != 1.0 || _activeOpacity != 1.0; +} + +- (BOOL)hasScaleAnimation +{ + return _defaultScale != 1.0 || self.hoverScale != 1.0 || _activeScale != 1.0; +} + +- (BOOL)hasUnderlayAnimation +{ + return _activeUnderlayOpacity != _defaultUnderlayOpacity || self.hoverUnderlayOpacity != _defaultUnderlayOpacity; +} + +// Resting (non-pressed) animation targets. While the pointer hovers, press-out +// settles on the hover values instead of the defaults, mirroring the web +// priority order (pressed > hovered > default). +- (CGFloat)restingOpacity +{ + return _isHovered ? self.hoverOpacity : _defaultOpacity; +} + +- (CGFloat)restingScale +{ + return _isHovered ? self.hoverScale : _defaultScale; +} + +- (CGFloat)restingUnderlayOpacity +{ + return _isHovered ? self.hoverUnderlayOpacity : _defaultUnderlayOpacity; +} + +- (void)setUserEnabled:(BOOL)userEnabled +{ + if (userEnabled == _userEnabled) { + _userEnabled = userEnabled; + return; + } + + _userEnabled = userEnabled; + + // Enabled is an input to the effective hover visual: web masks hover while + // disabled (`hovered && enabled`) and resumes it on re-enable with the + // pointer still inside. `_isHovered` keeps tracking across the disabled + // period, so re-evaluate the visual when enabled changes. + if (_isHovered && !_isPressed) { + [self applyHoverState]; + } +} + - (void)setUnderlayColor:(RNGHColor *)underlayColor { _underlayColor = underlayColor; @@ -266,18 +364,18 @@ - (void)applyStartAnimationState _underlayLayer.opacity = _defaultUnderlayOpacity; #if !TARGET_OS_OSX - if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + if ([self hasOpacityAnimation]) { target.alpha = _defaultOpacity; } - if (_activeScale != 1.0 || _defaultScale != 1.0) { + if ([self hasScaleAnimation]) { target.layer.transform = CATransform3DMakeScale(_defaultScale, _defaultScale, 1.0); } #else target.wantsLayer = YES; - if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + if ([self hasOpacityAnimation]) { target.alphaValue = _defaultOpacity; } - if (_activeScale != 1.0 || _defaultScale != 1.0) { + if ([self hasScaleAnimation]) { target.layer.transform = RNGHCenterScaleTransform(target.bounds, _defaultScale); } #endif @@ -329,10 +427,10 @@ - (void)animateTarget:(RNGHUIView *)target if (durationMs < snapThresholdMs) { [CATransaction begin]; [CATransaction setDisableActions:YES]; - if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + if ([self hasOpacityAnimation]) { target.alpha = opacity; } - if (_activeScale != 1.0 || _defaultScale != 1.0) { + if ([self hasScaleAnimation]) { layer.transform = CATransform3DMakeScale(scale, scale, 1.0); } [CATransaction commit]; @@ -344,10 +442,10 @@ - (void)animateTarget:(RNGHUIView *)target delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{ - if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + if ([self hasOpacityAnimation]) { target.alpha = opacity; } - if (_activeScale != 1.0 || _defaultScale != 1.0) { + if ([self hasScaleAnimation]) { target.layer.transform = CATransform3DMakeScale(scale, scale, 1.0); } } @@ -362,10 +460,10 @@ - (void)animateTarget:(RNGHUIView *)target if (durationMs < snapThresholdMs) { [CATransaction begin]; [CATransaction setDisableActions:YES]; - if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + if ([self hasOpacityAnimation]) { target.alphaValue = opacity; } - if (_activeScale != 1.0 || _defaultScale != 1.0) { + if ([self hasScaleAnimation]) { layer.transform = RNGHCenterScaleTransform(target.bounds, scale); } [CATransaction commit]; @@ -378,10 +476,10 @@ - (void)animateTarget:(RNGHUIView *)target context.allowsImplicitAnimation = YES; context.duration = duration; context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + if ([self hasOpacityAnimation]) { target.animator.alphaValue = opacity; } - if (_activeScale != 1.0 || _defaultScale != 1.0) { + if ([self hasScaleAnimation]) { target.layer.transform = RNGHCenterScaleTransform(target.bounds, scale); } } @@ -395,16 +493,22 @@ - (void)handleAnimatePressIn dispatch_block_cancel(_pendingPressOutBlock); _pendingPressOutBlock = nil; } + // A press is starting; cancel a pending (bracketing) hover-out so the hover + // state carries into the press and the hover -> press transition doesn't + // flicker through the default state. + [self cancelPendingHoverOut]; + _isPressed = YES; _pressInTimestamp = CACurrentMediaTime(); RNGHUIView *target = self.animationTarget ?: self; [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:_tapAnimationInDuration]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + if ([self hasUnderlayAnimation]) { [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:_tapAnimationInDuration]; } } - (void)handleAnimatePressOut { + _isPressed = NO; if (_pendingPressOutBlock) { dispatch_block_cancel(_pendingPressOutBlock); } @@ -415,24 +519,24 @@ - (void)handleAnimatePressOut // Long-press release - use the configured long-press out duration. NSInteger longPressOut = self.longPressAnimationOutDuration; RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:longPressOut]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:longPressOut]; + [self animateTarget:target toOpacity:self.restingOpacity scale:self.restingScale duration:longPressOut]; + if ([self hasUnderlayAnimation]) { + [self animateUnderlayToOpacity:self.restingUnderlayOpacity duration:longPressOut]; } } else if (elapsed >= _tapAnimationInDuration) { // Press-in animation fully finished - release with the configured out duration. RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:_tapAnimationOutDuration]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:_tapAnimationOutDuration]; + [self animateTarget:target toOpacity:self.restingOpacity scale:self.restingScale duration:_tapAnimationOutDuration]; + if ([self hasUnderlayAnimation]) { + [self animateUnderlayToOpacity:self.restingUnderlayOpacity duration:_tapAnimationOutDuration]; } // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play } else if (elapsed * 2 >= _tapAnimationOutDuration) { // Past minimum but press-in animation still playing, animate out in elapsed time RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:elapsed]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:elapsed]; + [self animateTarget:target toOpacity:self.restingOpacity scale:self.restingScale duration:elapsed]; + if ([self hasUnderlayAnimation]) { + [self animateUnderlayToOpacity:self.restingUnderlayOpacity duration:elapsed]; } } else { // Before minimum duration, finish press-in in remaining time then animate out in tapAnimationOutDuration. @@ -440,7 +544,7 @@ - (void)handleAnimatePressOut RNGHUIView *target = self.animationTarget ?: self; [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:remaining]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + if ([self hasUnderlayAnimation]) { [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:remaining]; } @@ -451,11 +555,11 @@ - (void)handleAnimatePressOut strongSelf->_pendingPressOutBlock = nil; RNGHUIView *target = strongSelf.animationTarget ?: strongSelf; [strongSelf animateTarget:target - toOpacity:strongSelf->_defaultOpacity - scale:strongSelf->_defaultScale + toOpacity:strongSelf.restingOpacity + scale:strongSelf.restingScale duration:strongSelf->_tapAnimationOutDuration]; - if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) { - [strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity + if ([strongSelf hasUnderlayAnimation]) { + [strongSelf animateUnderlayToOpacity:strongSelf.restingUnderlayOpacity duration:strongSelf->_tapAnimationOutDuration]; } } @@ -468,6 +572,105 @@ - (void)handleAnimatePressOut } } +#if !TARGET_OS_OSX +- (void)handleHover:(UIHoverGestureRecognizer *)recognizer API_AVAILABLE(ios(13.4)) +{ + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + [self animateHoverIn]; + break; + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: + [self animateHoverOut]; + break; + default: + break; + } +} +#endif + +// Animate to the effective hover visual, mirroring web's non-pressed render +// `(hovered && enabled) ? hover : default`. The pressed state is owned by the +// press animations, so this is a no-op while pressed (the hover state is still +// recorded by the callers, and press-out reads it via resting*). Picks the +// in/out duration from the direction it settles. +- (void)applyHoverState +{ + if (_isPressed) { + return; + } + + RNGHUIView *target = self.animationTarget ?: self; + + if (_isHovered && _userEnabled) { + [self animateTarget:target toOpacity:self.hoverOpacity scale:self.hoverScale duration:_hoverAnimationInDuration]; + + if ([self hasUnderlayAnimation]) { + [self animateUnderlayToOpacity:self.hoverUnderlayOpacity duration:_hoverAnimationInDuration]; + } + } else { + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:_hoverAnimationOutDuration]; + + if ([self hasUnderlayAnimation]) { + [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:_hoverAnimationOutDuration]; + } + } +} + +- (void)cancelPendingHoverOut +{ + if (!_pendingHoverOutBlock) { + return; + } + + dispatch_block_cancel(_pendingHoverOutBlock); + _pendingHoverOutBlock = nil; +} + +- (void)animateHoverIn +{ + [self cancelPendingHoverOut]; + + if (_isHovered) { + return; + } + + _isHovered = YES; + [self applyHoverState]; +} + +- (void)animateHoverOut +{ + if (_isPressed) { + // A genuine exit while pressed — drop hover so the release settles on the + // default state rather than animating back to the hover values. + _isHovered = NO; + return; + } + + [self cancelPendingHoverOut]; + + // A pointer press is bracketed by a hover-out just before touch-down (e.g. + // Apple Pencil). Defer the hover-out so an immediately following press + // (which cancels it in handleAnimatePressIn) wins, keeping the hover state + // for a flicker-free hover -> press -> hover transition. A real pointer + // leave has no press following, so the block runs and settles to default. + __weak auto weakSelf = self; + _pendingHoverOutBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ + __strong auto strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_pendingHoverOutBlock = nil; + strongSelf->_isHovered = NO; + [strongSelf applyHoverState]; + } + }); + NSTimeInterval delay = [self shouldReduceMotion] ? 0 : [self minFrameDurationMs]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_MSEC)), + dispatch_get_main_queue(), + _pendingHoverOutBlock); +} + - (void)applyUnderlayCornerRadii { CGRect rect = _underlayLayer.bounds; @@ -683,6 +886,39 @@ - (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom: } #if TARGET_OS_OSX +// macOS doesn't have UIHoverGestureRecognizer; instead we drive hover from an +// NSTrackingArea. NSTrackingInVisibleRect keeps it sized to the view +// automatically, and NSTrackingActiveAlways fires enter/exit even when the +// window isn't key (matching how a desktop cursor hover behaves regardless of +// focus). The area intentionally omits NSTrackingEnabledDuringMouseDrag, so +// during a press-drag the in/out tracking is handled by mouseDragged: / +// mouseUp: instead (the macOS analog of the iOS touch-bounds tracking). +- (void)updateTrackingAreas +{ + if (_hoverTrackingArea) { + [self removeTrackingArea:_hoverTrackingArea]; + } + _hoverTrackingArea = [[NSTrackingArea alloc] + initWithRect:NSZeroRect + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways | NSTrackingInVisibleRect + owner:self + userInfo:nil]; + [self addTrackingArea:_hoverTrackingArea]; + [super updateTrackingAreas]; +} + +- (void)mouseEntered:(NSEvent *)event +{ + [self animateHoverIn]; + [super mouseEntered:event]; +} + +- (void)mouseExited:(NSEvent *)event +{ + [self animateHoverOut]; + [super mouseExited:event]; +} + - (void)mouseDown:(NSEvent *)event { _isTouchInsideBounds = YES; @@ -692,6 +928,9 @@ - (void)mouseDown:(NSEvent *)event - (void)mouseUp:(NSEvent *)event { + NSPoint locationInView = [self convertPoint:[event locationInWindow] fromView:nil]; + _isHovered = NSPointInRect(locationInView, self.bounds); + [self handleAnimatePressOut]; _isTouchInsideBounds = NO; [super mouseUp:event]; @@ -703,6 +942,8 @@ - (void)mouseDragged:(NSEvent *)event NSPoint locationInView = [self convertPoint:locationInWindow fromView:nil]; BOOL currentlyInside = NSPointInRect(locationInView, self.bounds); + _isHovered = currentlyInside; + if (currentlyInside && !_isTouchInsideBounds) { _isTouchInsideBounds = YES; [self handleAnimatePressIn]; @@ -729,6 +970,19 @@ - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event return [super beginTrackingWithTouch:touch withEvent:event]; } +// Whether a touch comes from a hovering input — an indirect pointer +// (trackpad / mouse) or an Apple Pencil — as opposed to a finger, which never +// hovers. +- (BOOL)isHoveringTouch:(UITouch *)touch +{ + if (@available(iOS 13.4, *)) { + if (touch.type == UITouchTypeIndirectPointer) { + return YES; + } + } + return touch.type == UITouchTypePencil; +} + // Mirrors `sendActionsForControlEvents:` but preserves the real `UIEvent` // so target-actions with a `forEvent:` parameter receive the touches. // The public `sendActionsForControlEvents:` passes a nil event, which would @@ -780,6 +1034,16 @@ - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event CGRect hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets); BOOL currentlyInside = CGRectContainsPoint(hitFrame, location); + // Keep `_isHovered` in sync with the drag position for a hovering pointer. + // An Apple Pencil suppresses hover events while in contact, so the hover + // recognizer can't track in/out transitions during a drag — this is the iOS + // analog of the Android touch-bounds tracking, letting press-out settle on + // the correct resting (hover vs default) state. A finger never hovers, so + // `_isHovered` is left untouched (stays NO) for finger touches. + if ([self isHoveringTouch:touch]) { + _isHovered = currentlyInside; + } + if (currentlyInside) { if (!_isTouchInsideBounds) { [self rngh_sendActionsForControlEvents:UIControlEventTouchDragEnter withEvent:event]; @@ -817,7 +1081,18 @@ - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event if (touch != nil) { CGPoint location = [touch locationInView:self]; CGRect hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets); - if (CGRectContainsPoint(hitFrame, location)) { + BOOL inside = CGRectContainsPoint(hitFrame, location); + + // A hovering pointer (trackpad / Apple Pencil) is still hovering after the + // touch ends iff it lifted inside the bounds; settle press-out on the + // matching resting state. Set before dispatching Up* (which drives + // handleAnimatePressOut, reading restingOpacity). A finger never hovers, so + // `_isHovered` is left untouched for finger touches. + if ([self isHoveringTouch:touch]) { + _isHovered = inside; + } + + if (inside) { [self rngh_sendActionsForControlEvents:UIControlEventTouchUpInside withEvent:event]; } else { [self rngh_sendActionsForControlEvents:UIControlEventTouchUpOutside withEvent:event]; @@ -829,6 +1104,19 @@ - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event _isTouchInsideBounds = NO; } +- (void)cancelTrackingWithEvent:(UIEvent *)event +{ + // A cancelled touch (e.g. a scroll view stealing the gesture) aborts the + // press entirely; drop the hover state so the TouchCancel-driven press-out + // settles on the default rather than the hover values. Cleared before super + // dispatches the cancel action so restingOpacity reads the new value. Cancel + // coordinates/tool are unreliable, so this is unconditional (matching the + // Android ACTION_CANCEL handling). + _isHovered = NO; + _isTouchInsideBounds = NO; + [super cancelTrackingWithEvent:event]; +} + - (BOOL)shouldHandleTouch:(RNGHUIView *)view atPoint:(CGPoint)point { if ([view isKindOfClass:[RNGestureHandlerButton class]]) { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 468576db7c..2d7aa13409 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -357,7 +357,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & const auto &oldButtonProps = *std::static_pointer_cast(oldProps); shouldApplyStartAnimationState = oldButtonProps.defaultOpacity != newProps.defaultOpacity || oldButtonProps.defaultScale != newProps.defaultScale || - oldButtonProps.defaultUnderlayOpacity != newProps.defaultUnderlayOpacity; + oldButtonProps.defaultUnderlayOpacity != newProps.defaultUnderlayOpacity || + oldButtonProps.hoverOpacity != newProps.hoverOpacity || oldButtonProps.hoverScale != newProps.hoverScale || + oldButtonProps.hoverUnderlayOpacity != newProps.hoverUnderlayOpacity; } _buttonView.userEnabled = newProps.enabled; @@ -371,6 +373,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _buttonView.defaultScale = newProps.defaultScale; _buttonView.defaultUnderlayOpacity = newProps.defaultUnderlayOpacity; _buttonView.activeUnderlayOpacity = newProps.activeUnderlayOpacity; + _buttonView.hoverOpacity = newProps.hoverOpacity; + _buttonView.hoverScale = newProps.hoverScale; + _buttonView.hoverUnderlayOpacity = newProps.hoverUnderlayOpacity; + _buttonView.hoverAnimationInDuration = newProps.hoverAnimationInDuration > 0 ? newProps.hoverAnimationInDuration : 0; + _buttonView.hoverAnimationOutDuration = + newProps.hoverAnimationOutDuration > 0 ? newProps.hoverAnimationOutDuration : 0; if (newProps.underlayColor) { _buttonView.underlayColor = RCTUIColorFromSharedColor(newProps.underlayColor); } else { diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 8e09e6ed97..00b4f668fc 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -102,7 +102,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { activeUnderlayOpacity?: number | undefined; /** - * Web only. + * Web and iOS only. * * Opacity applied to the button when it is hovered. Defaults to * `defaultOpacity` when not set. @@ -110,7 +110,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { hoverOpacity?: number | undefined; /** - * Web only. + * Web and iOS only. * * Scale applied to the button when it is hovered. Defaults to * `defaultScale` when not set. @@ -118,7 +118,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { hoverScale?: number | undefined; /** - * Web only. + * Web and iOS only. * * Opacity applied to the underlay when the button is hovered. Defaults * to `defaultUnderlayOpacity` when not set. @@ -126,14 +126,14 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { hoverUnderlayOpacity?: number | undefined; /** - * Web only. + * Web and iOS only. * * Duration of the hover-in animation, in milliseconds. Defaults to 50ms. */ hoverAnimationInDuration?: number | undefined; /** - * Web only. + * Web and iOS only. * * Duration of the hover-out animation, in milliseconds. Defaults to 100ms. */ diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index b166134873..1842518015 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -28,6 +28,14 @@ interface NativeProps extends ViewProps { activeOpacity?: WithDefault; activeScale?: WithDefault; activeUnderlayOpacity?: WithDefault; + // Hover values default to -1 as an "unset" sentinel; the native side + // resolves them to the corresponding default* value (matching web, where + // an omitted hover value falls back to its default counterpart). + hoverOpacity?: WithDefault; + hoverScale?: WithDefault; + hoverUnderlayOpacity?: WithDefault; + hoverAnimationInDuration?: WithDefault; + hoverAnimationOutDuration?: WithDefault; defaultOpacity?: WithDefault; defaultScale?: WithDefault; defaultUnderlayOpacity?: WithDefault; From d4e9eb698be44a720d0b8eabc942723a388ac2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 24 Jun 2026 15:04:11 +0200 Subject: [PATCH 02/11] Copilot review --- .../apple/RNGestureHandlerButton.mm | 34 ++++++++----------- .../RNGestureHandlerButtonComponentView.mm | 10 ++++-- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 18a7c35fa2..7a97520ffd 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -234,22 +234,28 @@ - (BOOL)hasUnderlayAnimation return _activeUnderlayOpacity != _defaultUnderlayOpacity || self.hoverUnderlayOpacity != _defaultUnderlayOpacity; } -// Resting (non-pressed) animation targets. While the pointer hovers, press-out -// settles on the hover values instead of the defaults, mirroring the web -// priority order (pressed > hovered > default). +// The hover visual is masked while disabled, so a hover only counts when the +// button is also enabled. +- (BOOL)isEffectivelyHovered +{ + return _isHovered && _userEnabled; +} + +// Resting (non-pressed) animation targets. While the pointer effectively hovers, +// press-out settles on the hover values instead of the defaults. - (CGFloat)restingOpacity { - return _isHovered ? self.hoverOpacity : _defaultOpacity; + return [self isEffectivelyHovered] ? self.hoverOpacity : _defaultOpacity; } - (CGFloat)restingScale { - return _isHovered ? self.hoverScale : _defaultScale; + return [self isEffectivelyHovered] ? self.hoverScale : _defaultScale; } - (CGFloat)restingUnderlayOpacity { - return _isHovered ? self.hoverUnderlayOpacity : _defaultUnderlayOpacity; + return [self isEffectivelyHovered] ? self.hoverUnderlayOpacity : _defaultUnderlayOpacity; } - (void)setUserEnabled:(BOOL)userEnabled @@ -602,7 +608,7 @@ - (void)applyHoverState RNGHUIView *target = self.animationTarget ?: self; - if (_isHovered && _userEnabled) { + if ([self isEffectivelyHovered]) { [self animateTarget:target toOpacity:self.hoverOpacity scale:self.hoverScale duration:_hoverAnimationInDuration]; if ([self hasUnderlayAnimation]) { @@ -1036,10 +1042,7 @@ - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event // Keep `_isHovered` in sync with the drag position for a hovering pointer. // An Apple Pencil suppresses hover events while in contact, so the hover - // recognizer can't track in/out transitions during a drag — this is the iOS - // analog of the Android touch-bounds tracking, letting press-out settle on - // the correct resting (hover vs default) state. A finger never hovers, so - // `_isHovered` is left untouched (stays NO) for finger touches. + // recognizer can't track in/out transitions during a drag. if ([self isHoveringTouch:touch]) { _isHovered = currentlyInside; } @@ -1086,8 +1089,7 @@ - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event // A hovering pointer (trackpad / Apple Pencil) is still hovering after the // touch ends iff it lifted inside the bounds; settle press-out on the // matching resting state. Set before dispatching Up* (which drives - // handleAnimatePressOut, reading restingOpacity). A finger never hovers, so - // `_isHovered` is left untouched for finger touches. + // handleAnimatePressOut, reading restingOpacity). if ([self isHoveringTouch:touch]) { _isHovered = inside; } @@ -1106,12 +1108,6 @@ - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event - (void)cancelTrackingWithEvent:(UIEvent *)event { - // A cancelled touch (e.g. a scroll view stealing the gesture) aborts the - // press entirely; drop the hover state so the TouchCancel-driven press-out - // settles on the default rather than the hover values. Cleared before super - // dispatches the cancel action so restingOpacity reads the new value. Cancel - // coordinates/tool are unreliable, so this is unconditional (matching the - // Android ACTION_CANCEL handling). _isHovered = NO; _isTouchInsideBounds = NO; [super cancelTrackingWithEvent:event]; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 2d7aa13409..c0411038d4 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -355,11 +355,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & BOOL shouldApplyStartAnimationState = treatAsFirstMount; if (!treatAsFirstMount) { const auto &oldButtonProps = *std::static_pointer_cast(oldProps); + // Only default* changes warrant re-applying the start state, because that's + // the only visual applyStartAnimationState writes. hover*/active* are read + // live by the hover/press animations, so re-running here on a hover change + // would strand a currently-hovering button at the default visual (no + // enter/exit event follows to restore it) and could interrupt an in-flight + // press. shouldApplyStartAnimationState = oldButtonProps.defaultOpacity != newProps.defaultOpacity || oldButtonProps.defaultScale != newProps.defaultScale || - oldButtonProps.defaultUnderlayOpacity != newProps.defaultUnderlayOpacity || - oldButtonProps.hoverOpacity != newProps.hoverOpacity || oldButtonProps.hoverScale != newProps.hoverScale || - oldButtonProps.hoverUnderlayOpacity != newProps.hoverUnderlayOpacity; + oldButtonProps.defaultUnderlayOpacity != newProps.defaultUnderlayOpacity; } _buttonView.userEnabled = newProps.enabled; From dfe72272a61ccb219ed3eb2a521b87664d9078fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 24 Jun 2026 15:15:49 +0200 Subject: [PATCH 03/11] Remove useless assignment --- .../react-native-gesture-handler/apple/RNGestureHandlerButton.mm | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 7a97520ffd..4396ef3540 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -261,7 +261,6 @@ - (CGFloat)restingUnderlayOpacity - (void)setUserEnabled:(BOOL)userEnabled { if (userEnabled == _userEnabled) { - _userEnabled = userEnabled; return; } From f3ed3742db177b1d64b4ad7c0f647e9b96df391f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:40:14 +0200 Subject: [PATCH 04/11] Remove obsolete version checks (#4284) ## Description This PR removes obsolete version checks from our `iOS` codebase. Minimal targets for iOS and macOS were chosen based on supported versions. tvOS was changed to match iOS ## Test plan Checked that example apps (basic and macos) are built correctly, --- .../RNGestureHandler.podspec | 2 +- .../apple/Handlers/RNForceTouchHandler.m | 4 +- .../apple/Handlers/RNHoverHandler.h | 1 - .../apple/Handlers/RNHoverHandler.m | 52 +++++++------------ .../apple/Handlers/RNPanHandler.m | 17 ++---- .../apple/RNGestureHandlerButton.mm | 22 +++----- .../RNGestureHandlerButtonComponentView.mm | 18 +++---- 7 files changed, 38 insertions(+), 78 deletions(-) diff --git a/packages/react-native-gesture-handler/RNGestureHandler.podspec b/packages/react-native-gesture-handler/RNGestureHandler.podspec index 57b87c0be5..ff27bd0e60 100644 --- a/packages/react-native-gesture-handler/RNGestureHandler.podspec +++ b/packages/react-native-gesture-handler/RNGestureHandler.podspec @@ -20,7 +20,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/software-mansion/react-native-gesture-handler", :tag => "#{s.version}" } s.source_files = "apple/**/*.{h,m,mm}", "shared/**/*.{h,cpp}" s.requires_arc = true - s.platforms = { ios: '11.0', tvos: '11.0', osx: '10.15', visionos: '1.0' } + s.platforms = { ios: '15.1', tvos: '15.1', osx: '14.0', visionos: '1.0' } s.xcconfig = { "OTHER_CFLAGS" => "$(inherited) #{compilation_metadata_generation_flag} #{version_flag}" } diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m index 113dcddafd..3a0169df98 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNForceTouchHandler.m @@ -89,9 +89,7 @@ - (void)performFeedbackIfRequired { #if !TARGET_OS_TV && !TARGET_OS_VISION if (_feedbackOnActivation) { - if (@available(iOS 10.0, *)) { - [[[UIImpactFeedbackGenerator alloc] initWithStyle:(UIImpactFeedbackStyleMedium)] impactOccurred]; - } + [[[UIImpactFeedbackGenerator alloc] initWithStyle:(UIImpactFeedbackStyleMedium)] impactOccurred]; } #endif } diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.h b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.h index f0cf025cb6..334f8f1b03 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.h +++ b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.h @@ -7,6 +7,5 @@ #import "RNGestureHandler.h" -API_AVAILABLE(ios(13.4)) @interface RNHoverGestureHandler : RNGestureHandler @end diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m index c9f2532c80..1d2e662cc2 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m @@ -13,19 +13,14 @@ #import #import -#define CHECK_TARGET(__VERSION__) \ - defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && defined(__IPHONE_##__VERSION__) && \ - __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_##__VERSION__ && !TARGET_OS_TV - typedef NS_ENUM(NSInteger, RNGestureHandlerHoverEffect) { RNGestureHandlerHoverEffectNone = 0, RNGestureHandlerHoverEffectLift, RNGestureHandlerHoverEffectHightlight, }; -#if CHECK_TARGET(13_4) +#if !TARGET_OS_TV -API_AVAILABLE(ios(13.4)) @interface RNBetterHoverGestureRecognizer : UIHoverGestureRecognizer - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler; @@ -85,7 +80,7 @@ - (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction style #endif @implementation RNHoverGestureHandler { -#if CHECK_TARGET(13_4) +#if !TARGET_OS_TV UIPointerInteraction *_pointerInteraction; #endif } @@ -97,12 +92,9 @@ - (instancetype)initWithTag:(NSNumber *)tag #endif if ((self = [super initWithTag:tag])) { -#if CHECK_TARGET(13_4) - if (@available(iOS 13.4, *)) { - _recognizer = [[RNBetterHoverGestureRecognizer alloc] initWithGestureHandler:self]; - _pointerInteraction = - [[UIPointerInteraction alloc] initWithDelegate:(id)_recognizer]; - } +#if !TARGET_OS_TV + _recognizer = [[RNBetterHoverGestureRecognizer alloc] initWithGestureHandler:self]; + _pointerInteraction = [[UIPointerInteraction alloc] initWithDelegate:(id)_recognizer]; #endif } return self; @@ -110,21 +102,17 @@ - (instancetype)initWithTag:(NSNumber *)tag - (void)bindToView:(UIView *)view { -#if CHECK_TARGET(13_4) - if (@available(iOS 13.4, *)) { - [super bindToView:view]; - [view addInteraction:_pointerInteraction]; - } +#if !TARGET_OS_TV + [super bindToView:view]; + [view addInteraction:_pointerInteraction]; #endif } - (void)unbindFromView { -#if CHECK_TARGET(13_4) - if (@available(iOS 13.4, *)) { - [super unbindFromView]; - [self.recognizer.view removeInteraction:_pointerInteraction]; - } +#if !TARGET_OS_TV + [super unbindFromView]; + [self.recognizer.view removeInteraction:_pointerInteraction]; #endif } @@ -132,11 +120,9 @@ - (void)resetConfig { [super resetConfig]; -#if CHECK_TARGET(13_4) - if (@available(iOS 13.4, *)) { - RNBetterHoverGestureRecognizer *recognizer = (RNBetterHoverGestureRecognizer *)_recognizer; - recognizer.hoverEffect = RNGestureHandlerHoverEffectNone; - } +#if !TARGET_OS_TV + RNBetterHoverGestureRecognizer *recognizer = (RNBetterHoverGestureRecognizer *)_recognizer; + recognizer.hoverEffect = RNGestureHandlerHoverEffectNone; #endif } @@ -144,11 +130,9 @@ - (void)updateConfig:(NSDictionary *)config { [super updateConfig:config]; -#if CHECK_TARGET(13_4) - if (@available(iOS 13.4, *)) { - RNBetterHoverGestureRecognizer *recognizer = (RNBetterHoverGestureRecognizer *)_recognizer; - APPLY_INT_PROP(hoverEffect); - } +#if !TARGET_OS_TV + RNBetterHoverGestureRecognizer *recognizer = (RNBetterHoverGestureRecognizer *)_recognizer; + APPLY_INT_PROP(hoverEffect); #endif } @@ -156,7 +140,7 @@ - (void)setCurrentPointerType:(RNGestureHandlerPointerType)pointerType { _pointerType = pointerType; -#if CHECK_TARGET(16_1) +#if !TARGET_OS_TV if (@available(iOS 16.1, *)) { if (((UIHoverGestureRecognizer *)self.recognizer).zOffset > 0.0) { _pointerType = RNGestureHandlerStylus; diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m index 13ca9d963c..921ac4b186 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m @@ -380,14 +380,9 @@ - (void)resetConfig recognizer.activeOffsetYStart = NAN; recognizer.activeOffsetYEnd = NAN; recognizer.failOffsetYStart = NAN; - recognizer.failOffsetYStart = NAN; recognizer.failOffsetYEnd = NAN; -#if !TARGET_OS_OSX && !TARGET_OS_TV && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130400 - if (@available(iOS 13.4, *)) { - recognizer.allowedScrollTypesMask = 0; - } -#endif #if !TARGET_OS_OSX && !TARGET_OS_TV + recognizer.allowedScrollTypesMask = 0; recognizer.minimumNumberOfTouches = 1; recognizer.maximumNumberOfTouches = NSUIntegerMax; #endif @@ -412,12 +407,10 @@ - (void)updateConfig:(NSDictionary *)config APPLY_FLOAT_PROP(failOffsetYStart); APPLY_FLOAT_PROP(failOffsetYEnd); -#if !TARGET_OS_OSX && !TARGET_OS_TV && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130400 - if (@available(iOS 13.4, *)) { - bool enableTrackpadTwoFingerGesture = [RCTConvert BOOL:config[@"enableTrackpadTwoFingerGesture"]]; - if (enableTrackpadTwoFingerGesture) { - recognizer.allowedScrollTypesMask = UIScrollTypeMaskAll; - } +#if !TARGET_OS_OSX && !TARGET_OS_TV + bool enableTrackpadTwoFingerGesture = [RCTConvert BOOL:config[@"enableTrackpadTwoFingerGesture"]]; + if (enableTrackpadTwoFingerGesture) { + recognizer.allowedScrollTypesMask = UIScrollTypeMaskAll; } APPLY_NAMED_INT_PROP(minimumNumberOfTouches, @"minPointers"); diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 4396ef3540..ee9129bb0c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -107,11 +107,9 @@ - (void)commonInit forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDragExit | UIControlEventTouchCancel]; - if (@available(iOS 13.4, *)) { - UIHoverGestureRecognizer *hoverRecognizer = - [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(handleHover:)]; - [self addGestureRecognizer:hoverRecognizer]; - } + UIHoverGestureRecognizer *hoverRecognizer = [[UIHoverGestureRecognizer alloc] initWithTarget:self + action:@selector(handleHover:)]; + [self addGestureRecognizer:hoverRecognizer]; #endif } @@ -394,10 +392,7 @@ - (NSTimeInterval)minFrameDurationMs NSInteger maxFps = screen.maximumFramesPerSecond; #else NSScreen *screen = self.window.screen ?: NSScreen.mainScreen; - NSInteger maxFps = 60; - if (@available(macOS 12.0, *)) { - maxFps = screen.maximumFramesPerSecond; - } + NSInteger maxFps = screen.maximumFramesPerSecond; #endif return maxFps > 0 ? 1000.0 / (NSTimeInterval)maxFps : 1000.0 / 60.0; } @@ -578,7 +573,7 @@ - (void)handleAnimatePressOut } #if !TARGET_OS_OSX -- (void)handleHover:(UIHoverGestureRecognizer *)recognizer API_AVAILABLE(ios(13.4)) +- (void)handleHover:(UIHoverGestureRecognizer *)recognizer { switch (recognizer.state) { case UIGestureRecognizerStateBegan: @@ -980,12 +975,7 @@ - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event // hovers. - (BOOL)isHoveringTouch:(UITouch *)touch { - if (@available(iOS 13.4, *)) { - if (touch.type == UITouchTypeIndirectPointer) { - return YES; - } - } - return touch.type == UITouchTypePencil; + return touch.type == UITouchTypeIndirectPointer ? YES : touch.type == UITouchTypePencil; } // Mirrors `sendActionsForControlEvents:` but preserves the real `UIEvent` diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index c0411038d4..ca7cf2b49c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -281,21 +281,17 @@ - (void)setAccessibilityProps:(const Props::Shared &)props oldProps:(const Props if (!oldProps || oldButtonProps.accessibilityShowsLargeContentViewer != newButtonProps.accessibilityShowsLargeContentViewer) { - if (@available(iOS 13.0, *)) { - if (newButtonProps.accessibilityShowsLargeContentViewer) { - _buttonView.showsLargeContentViewer = YES; - UILargeContentViewerInteraction *interaction = [[UILargeContentViewerInteraction alloc] init]; - [_buttonView addInteraction:interaction]; - } else { - _buttonView.showsLargeContentViewer = NO; - } + if (newButtonProps.accessibilityShowsLargeContentViewer) { + _buttonView.showsLargeContentViewer = YES; + UILargeContentViewerInteraction *interaction = [[UILargeContentViewerInteraction alloc] init]; + [_buttonView addInteraction:interaction]; + } else { + _buttonView.showsLargeContentViewer = NO; } } if (!oldProps || oldButtonProps.accessibilityLargeContentTitle != newButtonProps.accessibilityLargeContentTitle) { - if (@available(iOS 13.0, *)) { - _buttonView.largeContentTitle = RCTNSStringFromStringNilIfEmpty(newButtonProps.accessibilityLargeContentTitle); - } + _buttonView.largeContentTitle = RCTNSStringFromStringNilIfEmpty(newButtonProps.accessibilityLargeContentTitle); } if (!oldProps || oldButtonProps.accessibilityTraits != newButtonProps.accessibilityTraits) { From daa80eecaa7054a3bbc7f34bf302b4d1c19480b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 29 Jun 2026 12:08:52 +0200 Subject: [PATCH 05/11] Drop comment --- .../apple/RNGestureHandlerButtonComponentView.mm | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index c0411038d4..8458875270 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -355,12 +355,6 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & BOOL shouldApplyStartAnimationState = treatAsFirstMount; if (!treatAsFirstMount) { const auto &oldButtonProps = *std::static_pointer_cast(oldProps); - // Only default* changes warrant re-applying the start state, because that's - // the only visual applyStartAnimationState writes. hover*/active* are read - // live by the hover/press animations, so re-running here on a hover change - // would strand a currently-hovering button at the default visual (no - // enter/exit event follows to restore it) and could interrupt an in-flight - // press. shouldApplyStartAnimationState = oldButtonProps.defaultOpacity != newProps.defaultOpacity || oldButtonProps.defaultScale != newProps.defaultScale || oldButtonProps.defaultUnderlayOpacity != newProps.defaultUnderlayOpacity; From cf5f13e224a83425e4acdfb0bad7a3d291f92e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 29 Jun 2026 12:24:26 +0200 Subject: [PATCH 06/11] Handle Failed state --- .../react-native-gesture-handler/apple/RNGestureHandlerButton.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 4396ef3540..82bd50b779 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -586,6 +586,7 @@ - (void)handleHover:(UIHoverGestureRecognizer *)recognizer API_AVAILABLE(ios(13. break; case UIGestureRecognizerStateEnded: case UIGestureRecognizerStateCancelled: + case UIGestureRecognizerStateFailed: [self animateHoverOut]; break; default: From 165e15fbdc113d6d70f733e59c6e734618c1c0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 29 Jun 2026 12:54:17 +0200 Subject: [PATCH 07/11] Extract to helper --- .../apple/RNGestureHandlerButton.mm | 87 +++++++++---------- 1 file changed, 41 insertions(+), 46 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 82bd50b779..be6d143a06 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -492,6 +492,18 @@ - (void)animateTarget:(RNGHUIView *)target #endif } +- (void)animateToOpacity:(CGFloat)opacity + scale:(CGFloat)scale + underlayOpacity:(CGFloat)underlayOpacity + duration:(NSTimeInterval)durationMs +{ + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:opacity scale:scale duration:durationMs]; + if ([self hasUnderlayAnimation]) { + [self animateUnderlayToOpacity:underlayOpacity duration:durationMs]; + } +} + - (void)handleAnimatePressIn { if (_pendingPressOutBlock) { @@ -504,11 +516,10 @@ - (void)handleAnimatePressIn [self cancelPendingHoverOut]; _isPressed = YES; _pressInTimestamp = CACurrentMediaTime(); - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:_tapAnimationInDuration]; - if ([self hasUnderlayAnimation]) { - [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:_tapAnimationInDuration]; - } + [self animateToOpacity:_activeOpacity + scale:_activeScale + underlayOpacity:_activeUnderlayOpacity + duration:_tapAnimationInDuration]; } - (void)handleAnimatePressOut @@ -523,50 +534,38 @@ - (void)handleAnimatePressOut if (_longPressDuration >= 0 && elapsed >= _longPressDuration) { // Long-press release - use the configured long-press out duration. NSInteger longPressOut = self.longPressAnimationOutDuration; - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:self.restingOpacity scale:self.restingScale duration:longPressOut]; - if ([self hasUnderlayAnimation]) { - [self animateUnderlayToOpacity:self.restingUnderlayOpacity duration:longPressOut]; - } + [self animateToOpacity:self.restingOpacity + scale:self.restingScale + underlayOpacity:self.restingUnderlayOpacity + duration:longPressOut]; } else if (elapsed >= _tapAnimationInDuration) { // Press-in animation fully finished - release with the configured out duration. - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:self.restingOpacity scale:self.restingScale duration:_tapAnimationOutDuration]; - if ([self hasUnderlayAnimation]) { - [self animateUnderlayToOpacity:self.restingUnderlayOpacity duration:_tapAnimationOutDuration]; - } + [self animateToOpacity:self.restingOpacity + scale:self.restingScale + underlayOpacity:self.restingUnderlayOpacity + duration:_tapAnimationOutDuration]; // elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play } else if (elapsed * 2 >= _tapAnimationOutDuration) { // Past minimum but press-in animation still playing, animate out in elapsed time - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:self.restingOpacity scale:self.restingScale duration:elapsed]; - if ([self hasUnderlayAnimation]) { - [self animateUnderlayToOpacity:self.restingUnderlayOpacity duration:elapsed]; - } + [self animateToOpacity:self.restingOpacity + scale:self.restingScale + underlayOpacity:self.restingUnderlayOpacity + duration:elapsed]; } else { // Before minimum duration, finish press-in in remaining time then animate out in tapAnimationOutDuration. NSTimeInterval remaining = _tapAnimationInDuration - elapsed; - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:remaining]; - if ([self hasUnderlayAnimation]) { - [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:remaining]; - } + [self animateToOpacity:_activeOpacity scale:_activeScale underlayOpacity:_activeUnderlayOpacity duration:remaining]; __weak auto weakSelf = self; _pendingPressOutBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ __strong auto strongSelf = weakSelf; if (strongSelf) { strongSelf->_pendingPressOutBlock = nil; - RNGHUIView *target = strongSelf.animationTarget ?: strongSelf; - [strongSelf animateTarget:target - toOpacity:strongSelf.restingOpacity - scale:strongSelf.restingScale - duration:strongSelf->_tapAnimationOutDuration]; - if ([strongSelf hasUnderlayAnimation]) { - [strongSelf animateUnderlayToOpacity:strongSelf.restingUnderlayOpacity - duration:strongSelf->_tapAnimationOutDuration]; - } + [strongSelf animateToOpacity:strongSelf.restingOpacity + scale:strongSelf.restingScale + underlayOpacity:strongSelf.restingUnderlayOpacity + duration:strongSelf->_tapAnimationOutDuration]; } }); NSTimeInterval scheduledDelay = [self shouldReduceMotion] ? 0 : remaining; @@ -606,20 +605,16 @@ - (void)applyHoverState return; } - RNGHUIView *target = self.animationTarget ?: self; - if ([self isEffectivelyHovered]) { - [self animateTarget:target toOpacity:self.hoverOpacity scale:self.hoverScale duration:_hoverAnimationInDuration]; - - if ([self hasUnderlayAnimation]) { - [self animateUnderlayToOpacity:self.hoverUnderlayOpacity duration:_hoverAnimationInDuration]; - } + [self animateToOpacity:self.hoverOpacity + scale:self.hoverScale + underlayOpacity:self.hoverUnderlayOpacity + duration:_hoverAnimationInDuration]; } else { - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:_hoverAnimationOutDuration]; - - if ([self hasUnderlayAnimation]) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:_hoverAnimationOutDuration]; - } + [self animateToOpacity:_defaultOpacity + scale:_defaultScale + underlayOpacity:_defaultUnderlayOpacity + duration:_hoverAnimationOutDuration]; } } From 00b54168036d07ab3cccd58200be64a1512b16e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 29 Jun 2026 14:42:35 +0200 Subject: [PATCH 08/11] Fix tvOS build --- .../apple/RNGestureHandlerButton.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 17f62d2ddf..bd41c0bf35 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -571,7 +571,7 @@ - (void)handleAnimatePressOut } } -#if !TARGET_OS_OSX +#if !TARGET_OS_OSX && !TARGET_OS_TV - (void)handleHover:(UIHoverGestureRecognizer *)recognizer { switch (recognizer.state) { From b9c7207342c952f9a790a68daa25b530ff90daa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 29 Jun 2026 14:45:06 +0200 Subject: [PATCH 09/11] Add tvOS support --- .../apple/RNGestureHandlerButton.h | 2 ++ .../apple/RNGestureHandlerButtonComponentView.mm | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index d5c3a93876..6ee70a7e54 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -59,6 +59,8 @@ #if TARGET_OS_TV - (void)handleAnimatePressIn; - (void)handleAnimatePressOut; +- (void)animateHoverIn; +- (void)animateHoverOut; #endif // TARGET_OS_TV /** diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index f7f2fac6c8..ef4d9664a1 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -104,6 +104,17 @@ - (void)animatePressOut [_buttonView handleAnimatePressOut]; } + +- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context + withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator +{ + if (context.nextFocusedView == self) { + [_buttonView animateHoverIn]; + } else if (context.previouslyFocusedView == self) { + [_buttonView animateHoverOut]; + } + [super didUpdateFocusInContext:context withAnimationCoordinator:coordinator]; +} #endif // TARGET_OS_TV - (void)prepareForRecycle From 28771a134229580131621d829a742055fa111637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 1 Jul 2026 12:29:33 +0200 Subject: [PATCH 10/11] Renames --- .../apple/RNGestureHandlerButton.h | 4 +-- .../apple/RNGestureHandlerButton.mm | 30 +++++++++---------- .../RNGestureHandlerButtonComponentView.mm | 4 +-- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 6ee70a7e54..75f565d3fe 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -59,8 +59,8 @@ #if TARGET_OS_TV - (void)handleAnimatePressIn; - (void)handleAnimatePressOut; -- (void)animateHoverIn; -- (void)animateHoverOut; +- (void)onHoverIn; +- (void)onHoverOut; #endif // TARGET_OS_TV /** diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index bd41c0bf35..edfa790c2d 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -234,7 +234,7 @@ - (BOOL)hasUnderlayAnimation // The hover visual is masked while disabled, so a hover only counts when the // button is also enabled. -- (BOOL)isEffectivelyHovered +- (BOOL)shouldAnimateHover { return _isHovered && _userEnabled; } @@ -243,17 +243,17 @@ - (BOOL)isEffectivelyHovered // press-out settles on the hover values instead of the defaults. - (CGFloat)restingOpacity { - return [self isEffectivelyHovered] ? self.hoverOpacity : _defaultOpacity; + return [self shouldAnimateHover] ? self.hoverOpacity : _defaultOpacity; } - (CGFloat)restingScale { - return [self isEffectivelyHovered] ? self.hoverScale : _defaultScale; + return [self shouldAnimateHover] ? self.hoverScale : _defaultScale; } - (CGFloat)restingUnderlayOpacity { - return [self isEffectivelyHovered] ? self.hoverUnderlayOpacity : _defaultUnderlayOpacity; + return [self shouldAnimateHover] ? self.hoverUnderlayOpacity : _defaultUnderlayOpacity; } - (void)setUserEnabled:(BOOL)userEnabled @@ -269,7 +269,7 @@ - (void)setUserEnabled:(BOOL)userEnabled // pointer still inside. `_isHovered` keeps tracking across the disabled // period, so re-evaluate the visual when enabled changes. if (_isHovered && !_isPressed) { - [self applyHoverState]; + [self animateHoverState]; } } @@ -576,12 +576,12 @@ - (void)handleHover:(UIHoverGestureRecognizer *)recognizer { switch (recognizer.state) { case UIGestureRecognizerStateBegan: - [self animateHoverIn]; + [self onHoverIn]; break; case UIGestureRecognizerStateEnded: case UIGestureRecognizerStateCancelled: case UIGestureRecognizerStateFailed: - [self animateHoverOut]; + [self onHoverOut]; break; default: break; @@ -594,13 +594,13 @@ - (void)handleHover:(UIHoverGestureRecognizer *)recognizer // press animations, so this is a no-op while pressed (the hover state is still // recorded by the callers, and press-out reads it via resting*). Picks the // in/out duration from the direction it settles. -- (void)applyHoverState +- (void)animateHoverState { if (_isPressed) { return; } - if ([self isEffectivelyHovered]) { + if ([self shouldAnimateHover]) { [self animateToOpacity:self.hoverOpacity scale:self.hoverScale underlayOpacity:self.hoverUnderlayOpacity @@ -623,7 +623,7 @@ - (void)cancelPendingHoverOut _pendingHoverOutBlock = nil; } -- (void)animateHoverIn +- (void)onHoverIn { [self cancelPendingHoverOut]; @@ -632,10 +632,10 @@ - (void)animateHoverIn } _isHovered = YES; - [self applyHoverState]; + [self animateHoverState]; } -- (void)animateHoverOut +- (void)onHoverOut { if (_isPressed) { // A genuine exit while pressed — drop hover so the release settles on the @@ -657,7 +657,7 @@ - (void)animateHoverOut if (strongSelf) { strongSelf->_pendingHoverOutBlock = nil; strongSelf->_isHovered = NO; - [strongSelf applyHoverState]; + [strongSelf animateHoverState]; } }); NSTimeInterval delay = [self shouldReduceMotion] ? 0 : [self minFrameDurationMs]; @@ -905,13 +905,13 @@ - (void)updateTrackingAreas - (void)mouseEntered:(NSEvent *)event { - [self animateHoverIn]; + [self onHoverIn]; [super mouseEntered:event]; } - (void)mouseExited:(NSEvent *)event { - [self animateHoverOut]; + [self onHoverOut]; [super mouseExited:event]; } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index ef4d9664a1..f62df4170d 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -109,9 +109,9 @@ - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator { if (context.nextFocusedView == self) { - [_buttonView animateHoverIn]; + [_buttonView onHoverIn]; } else if (context.previouslyFocusedView == self) { - [_buttonView animateHoverOut]; + [_buttonView onHoverOut]; } [super didUpdateFocusInContext:context withAnimationCoordinator:coordinator]; } From 9e2da72c1be4faf3c659fa4e824c9afc041d7b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 1 Jul 2026 12:36:40 +0200 Subject: [PATCH 11/11] Trim comments --- .../apple/RNGestureHandlerButton.mm | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index edfa790c2d..07c51c156c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -264,10 +264,6 @@ - (void)setUserEnabled:(BOOL)userEnabled _userEnabled = userEnabled; - // Enabled is an input to the effective hover visual: web masks hover while - // disabled (`hovered && enabled`) and resumes it on re-enable with the - // pointer still inside. `_isHovered` keeps tracking across the disabled - // period, so re-evaluate the visual when enabled changes. if (_isHovered && !_isPressed) { [self animateHoverState]; } @@ -505,9 +501,8 @@ - (void)handleAnimatePressIn dispatch_block_cancel(_pendingPressOutBlock); _pendingPressOutBlock = nil; } - // A press is starting; cancel a pending (bracketing) hover-out so the hover - // state carries into the press and the hover -> press transition doesn't - // flicker through the default state. + // Cancel a pending hover-out so the hover state carries into the + // press without flickering through the default state. [self cancelPendingHoverOut]; _isPressed = YES; _pressInTimestamp = CACurrentMediaTime(); @@ -589,11 +584,8 @@ - (void)handleHover:(UIHoverGestureRecognizer *)recognizer } #endif -// Animate to the effective hover visual, mirroring web's non-pressed render -// `(hovered && enabled) ? hover : default`. The pressed state is owned by the -// press animations, so this is a no-op while pressed (the hover state is still -// recorded by the callers, and press-out reads it via resting*). Picks the -// in/out duration from the direction it settles. +// Animate to the effective hover visual. No-op while pressed — the press owns +// the visual and press-out settles on the recorded hover state via resting*. - (void)animateHoverState { if (_isPressed) { @@ -646,11 +638,9 @@ - (void)onHoverOut [self cancelPendingHoverOut]; - // A pointer press is bracketed by a hover-out just before touch-down (e.g. - // Apple Pencil). Defer the hover-out so an immediately following press - // (which cancels it in handleAnimatePressIn) wins, keeping the hover state - // for a flicker-free hover -> press -> hover transition. A real pointer - // leave has no press following, so the block runs and settles to default. + // An Apple Pencil press is bracketed by a hover-out just before touch-down, so + // defer a frame to let a following press-in cancel it and keep the hover state + // through the press. A real leave has no press, so it settles to default. __weak auto weakSelf = self; _pendingHoverOutBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ __strong auto strongSelf = weakSelf; @@ -882,13 +872,10 @@ - (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom: } #if TARGET_OS_OSX -// macOS doesn't have UIHoverGestureRecognizer; instead we drive hover from an -// NSTrackingArea. NSTrackingInVisibleRect keeps it sized to the view -// automatically, and NSTrackingActiveAlways fires enter/exit even when the -// window isn't key (matching how a desktop cursor hover behaves regardless of -// focus). The area intentionally omits NSTrackingEnabledDuringMouseDrag, so -// during a press-drag the in/out tracking is handled by mouseDragged: / -// mouseUp: instead (the macOS analog of the iOS touch-bounds tracking). +// macOS has no UIHoverGestureRecognizer, so drive hover from an NSTrackingArea. +// InVisibleRect keeps it view-sized; ActiveAlways fires enter/exit regardless of +// window focus. Omits EnabledDuringMouseDrag — mouseDragged:/mouseUp: track +// in/out during a press instead. - (void)updateTrackingAreas { if (_hoverTrackingArea) { @@ -966,9 +953,7 @@ - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event return [super beginTrackingWithTouch:touch withEvent:event]; } -// Whether a touch comes from a hovering input — an indirect pointer -// (trackpad / mouse) or an Apple Pencil — as opposed to a finger, which never -// hovers. +// Whether a touch comes from a hovering input - (BOOL)isHoveringTouch:(UITouch *)touch { return touch.type == UITouchTypeIndirectPointer ? YES : touch.type == UITouchTypePencil; @@ -1071,10 +1056,6 @@ - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event CGRect hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets); BOOL inside = CGRectContainsPoint(hitFrame, location); - // A hovering pointer (trackpad / Apple Pencil) is still hovering after the - // touch ends iff it lifted inside the bounds; settle press-out on the - // matching resting state. Set before dispatching Up* (which drives - // handleAnimatePressOut, reading restingOpacity). if ([self isHoveringTouch:touch]) { _isHovered = inside; }