diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index e870c99355d1e0..e0c82a319f3756 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -144,6 +144,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { borderTopLeftRadius: true, borderTopRightRadius: true, borderTopStartRadius: true, + cursor: true, opacity: true, pointerEvents: true, diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index 5a9121be11765f..9541d4c249834d 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -27,6 +27,8 @@ export type DimensionValue = type AnimatableNumericValue = number | Animated.AnimatedNode; type AnimatableStringValue = string | Animated.AnimatedNode; +export type CursorValue = 'auto' | 'pointer'; + /** * Flex Prop Types * @see https://reactnative.dev/docs/flexbox @@ -274,6 +276,7 @@ export interface ViewStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle { * Controls whether the View can be the target of touch events. */ pointerEvents?: 'box-none' | 'none' | 'box-only' | 'auto' | undefined; + cursor?: CursorValue; } export type FontVariant = @@ -403,4 +406,5 @@ export interface ImageStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle { tintColor?: ColorValue | undefined; opacity?: AnimatableNumericValue | undefined; objectFit?: 'cover' | 'contain' | 'fill' | 'scale-down' | undefined; + cursor?: CursorValue; } diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index d4b5ab921132fc..4faf1dd92e5425 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -34,6 +34,8 @@ export type EdgeInsetsValue = { bottom: number, }; +export type CursorValue = ?('auto' | 'pointer'); + export type DimensionValue = number | string | 'auto' | AnimatedNode | null; export type AnimatableNumericValue = number | AnimatedNode; @@ -729,6 +731,7 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{ opacity?: AnimatableNumericValue, elevation?: number, pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only', + cursor?: CursorValue, }>; export type ____ViewStyle_Internal = $ReadOnly<{ diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index a015c450ee21b7..11f59798379330 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -32,6 +32,7 @@ @implementation RCTViewComponentView { BOOL _needsInvalidateLayer; BOOL _isJSResponder; BOOL _removeClippedSubviews; + NSString *_cursor; NSMutableArray *_reactSubviews; NSSet *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN; } @@ -256,6 +257,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & if (oldViewProps.backfaceVisibility != newViewProps.backfaceVisibility) { self.layer.doubleSided = newViewProps.backfaceVisibility == BackfaceVisibility::Visible; } + + // `cursor` + if (oldViewProps.cursor != newViewProps.cursor) { + _cursor = [NSString stringWithUTF8String:newViewProps.cursor.c_str()]; + needsInvalidateLayer = YES; + } // `shouldRasterize` if (oldViewProps.shouldRasterize != newViewProps.shouldRasterize) { @@ -573,14 +580,14 @@ - (void)invalidateLayer } const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics); + const RCTCornerInsets cornerInsets = + RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero); // Stage 1. Shadow Path BOOL const layerHasShadow = layer.shadowOpacity > 0 && CGColorGetAlpha(layer.shadowColor) > 0; if (layerHasShadow) { if (CGColorGetAlpha(_backgroundColor.CGColor) > 0.999) { // If view has a solid background color, calculate shadow path from border. - const RCTCornerInsets cornerInsets = - RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero); CGPathRef shadowPath = RCTPathCreateWithRoundedRect(self.bounds, cornerInsets, nil); layer.shadowPath = shadowPath; CGPathRelease(shadowPath); @@ -591,6 +598,23 @@ - (void)invalidateLayer } else { layer.shadowPath = nil; } + + // Stage 1.5. Cursor / Hover Effects + if (@available(iOS 17.0, *)) { + CGPathRef borderPath = RCTPathCreateWithRoundedRect(self.bounds, cornerInsets, nil); + UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath]; + UIShape *shape = [UIShape shapeWithBezierPath:bezierPath]; + NSObject *hoverEffect = nil; + + + if ([_cursor isEqual:@"pointer"]) { + hoverEffect = [UIHoverAutomaticEffect effect]; + [self setHoverStyle:[UIHoverStyle styleWithEffect:hoverEffect shape:shape]]; + } else { + [self setHoverStyle:nil]; + } + } + // Stage 2. Border Rendering const bool useCoreAnimationBorderRendering = diff --git a/packages/react-native/React/Views/RCTConvert+Cursor.h b/packages/react-native/React/Views/RCTConvert+Cursor.h new file mode 100644 index 00000000000000..60d97970f02016 --- /dev/null +++ b/packages/react-native/React/Views/RCTConvert+Cursor.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +typedef NS_ENUM(NSInteger, RCTCursor) { + RCTCursorAuto, + RCTCursorPointer, +}; + + +@interface RCTConvert (Transform) + ++ (RCTCursor)RCTCursor:(id)json; + +@end diff --git a/packages/react-native/React/Views/RCTConvert+Cursor.m b/packages/react-native/React/Views/RCTConvert+Cursor.m new file mode 100644 index 00000000000000..0088b4080613d3 --- /dev/null +++ b/packages/react-native/React/Views/RCTConvert+Cursor.m @@ -0,0 +1,22 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTConvert+Cursor.h" + + +@implementation RCTConvert (Cursor) + +RCT_ENUM_CONVERTER( + RCTCursor, + (@{ + @"auto" : @(RCTCursorAuto), + @"pointer" : @(RCTCursorPointer), + }), + RCTCursorAuto, + integerValue) + +@end diff --git a/packages/react-native/React/Views/RCTView.h b/packages/react-native/React/Views/RCTView.h index 200d8b451bf59e..53509568feb608 100644 --- a/packages/react-native/React/Views/RCTView.h +++ b/packages/react-native/React/Views/RCTView.h @@ -12,6 +12,8 @@ #import #import +#import "RCTConvert+Cursor.h" + extern const UIAccessibilityTraits SwitchAccessibilityTrait; @protocol RCTAutoInsetsProtocol; @@ -120,6 +122,11 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait; */ @property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets; +/** + * Used to control hover effects + */ +@property (nonatomic, assign) RCTCursor cursor; + /** * (Experimental and unused for Paper) Pointer event handlers. */ diff --git a/packages/react-native/React/Views/RCTView.m b/packages/react-native/React/Views/RCTView.m index 6e6f9cd2761035..30bf9f9be66921 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -136,6 +136,7 @@ - (instancetype)initWithFrame:(CGRect)frame _borderCurve = RCTBorderCurveCircular; _borderStyle = RCTBorderStyleSolid; _hitTestEdgeInsets = UIEdgeInsetsZero; + _cursor = RCTCursorAuto; _backgroundColor = super.backgroundColor; } @@ -861,6 +862,14 @@ - (void)displayLayer:(CALayer *)layer [self updateClippingForLayer:layer]; } +CGPathRef RCTBorderPathForView(RCTView *view) +{ + const RCTCornerRadii cornerRadii = [view cornerRadii]; + const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero); + CGPathRef borderPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL); + return borderPath; +} + static BOOL RCTLayerHasShadow(CALayer *layer) { return layer.shadowOpacity * CGColorGetAlpha(layer.shadowColor) > 0; @@ -873,7 +882,7 @@ static void RCTUpdateShadowPathForView(RCTView *view) // If view has a solid background color, calculate shadow path from border const RCTCornerRadii cornerRadii = [view cornerRadii]; const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero); - CGPathRef shadowPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL); + CGPathRef shadowPath = RCTBorderPathForView(view); view.layer.shadowPath = shadowPath; CGPathRelease(shadowPath); @@ -897,24 +906,32 @@ - (void)updateClippingForLayer:(CALayer *)layer CGFloat cornerRadius = 0; if (self.clipsToBounds) { - const RCTCornerRadii cornerRadii = [self cornerRadii]; - if (RCTCornerRadiiAreEqual(cornerRadii)) { - cornerRadius = cornerRadii.topLeft; - - } else { - CAShapeLayer *shapeLayer = [CAShapeLayer layer]; - CGPathRef path = - RCTPathCreateWithRoundedRect(self.bounds, RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero), NULL); - shapeLayer.path = path; - CGPathRelease(path); - mask = shapeLayer; - } + CAShapeLayer *shapeLayer = [CAShapeLayer layer]; + CGPathRef path = RCTBorderPathForView(self); + shapeLayer.path = path; + CGPathRelease(path); + mask = shapeLayer; } layer.cornerRadius = cornerRadius; layer.mask = mask; } +- (void)setCursor:(RCTCursor)cursor +{ + if (@available(iOS 17.0, *)) { + UIHoverStyle *hoverStyle = nil; + if (cursor == RCTCursorPointer) { + CGPathRef borderPath = RCTBorderPathForView(self); + UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath]; + UIShape *shape = [UIShape shapeWithBezierPath:bezierPath]; + + hoverStyle = [UIHoverStyle styleWithEffect:[UIHoverHighlightEffect effect] shape:[UIShape rectShape]]; + } + [self setHoverStyle:hoverStyle]; + } +} + #pragma mark Border Color #define setBorderColor(side) \ diff --git a/packages/react-native/React/Views/RCTViewManager.m b/packages/react-native/React/Views/RCTViewManager.m index 66c449d7e81a00..2b2709b301731d 100644 --- a/packages/react-native/React/Views/RCTViewManager.m +++ b/packages/react-native/React/Views/RCTViewManager.m @@ -11,6 +11,7 @@ #import "RCTBorderCurve.h" #import "RCTBorderStyle.h" #import "RCTBridge.h" +#import "RCTConvert+Cursor.h" #import "RCTConvert+Transform.h" #import "RCTConvert.h" #import "RCTLog.h" @@ -195,6 +196,7 @@ - (RCTShadowView *)shadowView RCT_EXPORT_VIEW_PROPERTY(backgroundColor, UIColor) RCT_REMAP_VIEW_PROPERTY(backfaceVisibility, layer.doubleSided, css_backface_visibility_t) +RCT_EXPORT_VIEW_PROPERTY(cursor, RCTCursor) RCT_REMAP_VIEW_PROPERTY(opacity, alpha, CGFloat) RCT_REMAP_VIEW_PROPERTY(shadowColor, layer.shadowColor, CGColor) RCT_REMAP_VIEW_PROPERTY(shadowOffset, layer.shadowOffset, CGSize) diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index b7bdd9afc7eb55..286307da14b388 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -46,6 +46,8 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { CascadedBorderCurves borderCurves{}; // iOS only? CascadedBorderStyles borderStyles{}; + std::string cursor; + // Shadow SharedColor shadowColor{}; Size shadowOffset{0, -3}; diff --git a/packages/rn-tester/js/examples/View/ViewExample.js b/packages/rn-tester/js/examples/View/ViewExample.js index d8989b90744179..31fb6da2ab7fde 100644 --- a/packages/rn-tester/js/examples/View/ViewExample.js +++ b/packages/rn-tester/js/examples/View/ViewExample.js @@ -572,6 +572,7 @@ export default ({ borderRadius: 25, borderWidth: 1, marginRight: 10, + cursor: 'pointer', }} />