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;