diff --git a/packages/docs-gesture-handler/docs/components/touchable.mdx b/packages/docs-gesture-handler/docs/components/touchable.mdx index 4761e1f3d1..defdcde9b6 100644 --- a/packages/docs-gesture-handler/docs/components/touchable.mdx +++ b/packages/docs-gesture-handler/docs/components/touchable.mdx @@ -237,9 +237,7 @@ defaultUnderlayOpacity?: number; Defines the initial opacity of underlay when the button is inactive. By default set to `0`. - ### hoverOpacity - ```ts hoverOpacity?: number; @@ -247,9 +245,7 @@ hoverOpacity?: number; Defines the opacity of the whole component when the button is hovered. By default falls back to [`defaultOpacity`](#defaultopacity). - ### hoverScale - ```ts hoverScale?: number; @@ -257,9 +253,7 @@ hoverScale?: number; Defines the scale of the whole component when the button is hovered. By default falls back to [`defaultScale`](#defaultscale). - ### hoverUnderlayOpacity - ```ts hoverUnderlayOpacity?: number; @@ -296,7 +290,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 and Android). +- `hover` — pointer hover. `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/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.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 8b9c0c7f45..75f565d3fe 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; /** @@ -54,6 +59,8 @@ #if TARGET_OS_TV - (void)handleAnimatePressIn; - (void)handleAnimatePressOut; +- (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 77e0cd1e8f..07c51c156c 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,10 @@ - (void)commonInit action:@selector(handleAnimatePressOut) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDragExit | UIControlEventTouchCancel]; + + UIHoverGestureRecognizer *hoverRecognizer = [[UIHoverGestureRecognizer alloc] initWithTarget:self + action:@selector(handleHover:)]; + [self addGestureRecognizer:hoverRecognizer]; #endif } @@ -126,6 +147,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 +161,8 @@ - (void)prepareForRecycle _isTouchInsideBounds = NO; _suppressSuperControlActionDispatch = NO; _pressInTimestamp = 0; + _isHovered = NO; + _isPressed = NO; } #if TARGET_OS_OSX @@ -147,10 +171,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 +186,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 +202,73 @@ - (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; +} + +// The hover visual is masked while disabled, so a hover only counts when the +// button is also enabled. +- (BOOL)shouldAnimateHover +{ + 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 [self shouldAnimateHover] ? self.hoverOpacity : _defaultOpacity; +} + +- (CGFloat)restingScale +{ + return [self shouldAnimateHover] ? self.hoverScale : _defaultScale; +} + +- (CGFloat)restingUnderlayOpacity +{ + return [self shouldAnimateHover] ? self.hoverUnderlayOpacity : _defaultUnderlayOpacity; +} + +- (void)setUserEnabled:(BOOL)userEnabled +{ + if (userEnabled == _userEnabled) { + return; + } + + _userEnabled = userEnabled; + + if (_isHovered && !_isPressed) { + [self animateHoverState]; + } +} + - (void)setUnderlayColor:(RNGHColor *)underlayColor { _underlayColor = underlayColor; @@ -266,18 +363,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 @@ -291,10 +388,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; } @@ -329,10 +423,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 +438,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 +456,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 +472,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); } } @@ -389,22 +483,38 @@ - (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) { dispatch_block_cancel(_pendingPressOutBlock); _pendingPressOutBlock = nil; } + // 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(); - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:_tapAnimationInDuration]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:_tapAnimationInDuration]; - } + [self animateToOpacity:_activeOpacity + scale:_activeScale + underlayOpacity:_activeUnderlayOpacity + duration:_tapAnimationInDuration]; } - (void)handleAnimatePressOut { + _isPressed = NO; if (_pendingPressOutBlock) { dispatch_block_cancel(_pendingPressOutBlock); } @@ -414,50 +524,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:_defaultOpacity scale:_defaultScale duration:longPressOut]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity 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:_defaultOpacity scale:_defaultScale duration:_tapAnimationOutDuration]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity 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:_defaultOpacity scale:_defaultScale duration:elapsed]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity 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 (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [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->_defaultOpacity - scale:strongSelf->_defaultScale - duration:strongSelf->_tapAnimationOutDuration]; - if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) { - [strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity - duration:strongSelf->_tapAnimationOutDuration]; - } + [strongSelf animateToOpacity:strongSelf.restingOpacity + scale:strongSelf.restingScale + underlayOpacity:strongSelf.restingUnderlayOpacity + duration:strongSelf->_tapAnimationOutDuration]; } }); NSTimeInterval scheduledDelay = [self shouldReduceMotion] ? 0 : remaining; @@ -468,6 +566,97 @@ - (void)handleAnimatePressOut } } +#if !TARGET_OS_OSX && !TARGET_OS_TV +- (void)handleHover:(UIHoverGestureRecognizer *)recognizer +{ + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + [self onHoverIn]; + break; + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: + case UIGestureRecognizerStateFailed: + [self onHoverOut]; + break; + default: + break; + } +} +#endif + +// 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) { + return; + } + + if ([self shouldAnimateHover]) { + [self animateToOpacity:self.hoverOpacity + scale:self.hoverScale + underlayOpacity:self.hoverUnderlayOpacity + duration:_hoverAnimationInDuration]; + } else { + [self animateToOpacity:_defaultOpacity + scale:_defaultScale + underlayOpacity:_defaultUnderlayOpacity + duration:_hoverAnimationOutDuration]; + } +} + +- (void)cancelPendingHoverOut +{ + if (!_pendingHoverOutBlock) { + return; + } + + dispatch_block_cancel(_pendingHoverOutBlock); + _pendingHoverOutBlock = nil; +} + +- (void)onHoverIn +{ + [self cancelPendingHoverOut]; + + if (_isHovered) { + return; + } + + _isHovered = YES; + [self animateHoverState]; +} + +- (void)onHoverOut +{ + 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]; + + // 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; + if (strongSelf) { + strongSelf->_pendingHoverOutBlock = nil; + strongSelf->_isHovered = NO; + [strongSelf animateHoverState]; + } + }); + 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 +872,36 @@ - (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom: } #if TARGET_OS_OSX +// 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) { + [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 onHoverIn]; + [super mouseEntered:event]; +} + +- (void)mouseExited:(NSEvent *)event +{ + [self onHoverOut]; + [super mouseExited:event]; +} + - (void)mouseDown:(NSEvent *)event { _isTouchInsideBounds = YES; @@ -692,6 +911,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 +925,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 +953,12 @@ - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event return [super beginTrackingWithTouch:touch withEvent:event]; } +// Whether a touch comes from a hovering input +- (BOOL)isHoveringTouch:(UITouch *)touch +{ + return touch.type == UITouchTypeIndirectPointer ? YES : 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 +1010,13 @@ - (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. + if ([self isHoveringTouch:touch]) { + _isHovered = currentlyInside; + } + if (currentlyInside) { if (!_isTouchInsideBounds) { [self rngh_sendActionsForControlEvents:UIControlEventTouchDragEnter withEvent:event]; @@ -817,7 +1054,13 @@ - (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); + + if ([self isHoveringTouch:touch]) { + _isHovered = inside; + } + + if (inside) { [self rngh_sendActionsForControlEvents:UIControlEventTouchUpInside withEvent:event]; } else { [self rngh_sendActionsForControlEvents:UIControlEventTouchUpOutside withEvent:event]; @@ -829,6 +1072,13 @@ - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event _isTouchInsideBounds = NO; } +- (void)cancelTrackingWithEvent:(UIEvent *)event +{ + _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..f62df4170d 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 onHoverIn]; + } else if (context.previouslyFocusedView == self) { + [_buttonView onHoverOut]; + } + [super didUpdateFocusInContext:context withAnimationCoordinator:coordinator]; +} #endif // TARGET_OS_TV - (void)prepareForRecycle @@ -281,21 +292,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) { @@ -371,6 +378,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 6e49839131..cea40b0893 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -102,39 +102,29 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { activeUnderlayOpacity?: number | undefined; /** - * Web and Android only. - * * Opacity applied to the button when it is hovered. Defaults to * `defaultOpacity` when not set. */ hoverOpacity?: number | undefined; /** - * Web and Android only. - * * Scale applied to the button when it is hovered. Defaults to * `defaultScale` when not set. */ hoverScale?: number | undefined; /** - * Web and Android only. - * * Opacity applied to the underlay when the button is hovered. Defaults * to `defaultUnderlayOpacity` when not set. */ hoverUnderlayOpacity?: number | undefined; /** - * Web and Android only. - * * Duration of the hover-in animation, in milliseconds. Defaults to 50ms. */ hoverAnimationInDuration?: number | undefined; /** - * Web and Android only. - * * Duration of the hover-out animation, in milliseconds. Defaults to 100ms. */ hoverAnimationOutDuration?: number | undefined;