diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index e870c99355d..e0c82a319f3 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 5a9121be117..f4a99256313 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 | undefined; } 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 | undefined; } diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index c39bcb97ea3..59a7afc81d8 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -37,6 +37,8 @@ export type EdgeInsetsValue = { export type DimensionValue = number | string | 'auto' | AnimatedNode | null; export type AnimatableNumericValue = number | AnimatedNode; +export type CursorValue = 'auto' | 'pointer'; + /** * React Native's layout system is based on Flexbox and is powered both * on iOS and Android by an open source project called `Yoga`: @@ -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/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 54740e29317..edf3153cfa1 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -7428,6 +7428,7 @@ export type EdgeInsetsValue = { }; export type DimensionValue = number | string | \\"auto\\" | AnimatedNode | null; export type AnimatableNumericValue = number | AnimatedNode; +export type CursorValue = \\"auto\\" | \\"pointer\\"; type ____LayoutStyle_Internal = $ReadOnly<{ display?: \\"none\\" | \\"flex\\", width?: DimensionValue, @@ -7578,6 +7579,7 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{ opacity?: AnimatableNumericValue, elevation?: number, pointerEvents?: \\"auto\\" | \\"none\\" | \\"box-none\\" | \\"box-only\\", + cursor?: CursorValue, }>; export type ____ViewStyle_Internal = $ReadOnly<{ ...____ViewStyle_InternalCore, diff --git a/packages/react-native/React/Base/RCTConvert.h b/packages/react-native/React/Base/RCTConvert.h index e8db7a07623..8b819fda89e 100644 --- a/packages/react-native/React/Base/RCTConvert.h +++ b/packages/react-native/React/Base/RCTConvert.h @@ -11,6 +11,7 @@ #import #import #import +#import #import #import #import @@ -89,6 +90,8 @@ typedef NSURL RCTFileURL; + (UIBarStyle)UIBarStyle:(id)json __deprecated; #endif ++ (RCTCursor)RCTCursor:(id)json; + + (CGFloat)CGFloat:(id)json; + (CGPoint)CGPoint:(id)json; + (CGSize)CGSize:(id)json; diff --git a/packages/react-native/React/Base/RCTConvert.mm b/packages/react-native/React/Base/RCTConvert.mm index 2d2775f50f3..fb5e43c6d2e 100644 --- a/packages/react-native/React/Base/RCTConvert.mm +++ b/packages/react-native/React/Base/RCTConvert.mm @@ -545,6 +545,15 @@ RCT_ENUM_CONVERTER( UIBarStyleDefault, integerValue) +RCT_ENUM_CONVERTER( + RCTCursor, + (@{ + @"auto" : @(RCTCursorAuto), + @"pointer" : @(RCTCursorPointer), + }), + RCTCursorAuto, + integerValue) + static void convertCGStruct(const char *type, NSArray *fields, CGFloat *result, id json) { NSUInteger count = fields.count; 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 a015c450ee2..6bdfb69e171 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -257,6 +257,11 @@ using namespace facebook::react; self.layer.doubleSided = newViewProps.backfaceVisibility == BackfaceVisibility::Visible; } + // `cursor` + if (oldViewProps.cursor != newViewProps.cursor) { + needsInvalidateLayer = YES; + } + // `shouldRasterize` if (oldViewProps.shouldRasterize != newViewProps.shouldRasterize) { self.layer.shouldRasterize = newViewProps.shouldRasterize; @@ -592,6 +597,31 @@ static RCTBorderStyle RCTBorderStyleFromBorderStyle(BorderStyle borderStyle) layer.shadowPath = nil; } + // Stage 1.5. Cursor / Hover Effects + if (@available(iOS 17.0, *)) { + UIHoverStyle *hoverStyle = nil; + if (_props->cursor == Cursor::Pointer) { + const RCTCornerInsets cornerInsets = + RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero); +#if TARGET_OS_IOS + // Due to an Apple bug, it seems on iOS, UIShapes made with `[UIShape shapeWithBezierPath:]` + // evaluate their shape on the superviews' coordinate space. This leads to the hover shape + // rendering incorrectly on iOS, iOS apps in compatibility mode on visionOS, but not on visionOS. + // To work around this, for iOS, we can calculate the border path based on `view.frame` (the + // superview's coordinate space) instead of view.bounds. + CGPathRef borderPath = RCTPathCreateWithRoundedRect(self.frame, cornerInsets, NULL); +#else // TARGET_OS_VISION + CGPathRef borderPath = RCTPathCreateWithRoundedRect(self.bounds, cornerInsets, NULL); +#endif + UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath]; + CGPathRelease(borderPath); + UIShape *shape = [UIShape shapeWithBezierPath:bezierPath]; + + hoverStyle = [UIHoverStyle styleWithEffect:[UIHoverAutomaticEffect effect] shape:shape]; + } + [self setHoverStyle:hoverStyle]; + } + // Stage 2. Border Rendering const bool useCoreAnimationBorderRendering = borderMetrics.borderColors.isUniform() && borderMetrics.borderWidths.isUniform() && diff --git a/packages/react-native/React/Views/RCTCursor.h b/packages/react-native/React/Views/RCTCursor.h new file mode 100644 index 00000000000..63fcb3e123e --- /dev/null +++ b/packages/react-native/React/Views/RCTCursor.h @@ -0,0 +1,13 @@ +/* + * 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, +}; diff --git a/packages/react-native/React/Views/RCTView.h b/packages/react-native/React/Views/RCTView.h index 200d8b451bf..8abda6e8538 100644 --- a/packages/react-native/React/Views/RCTView.h +++ b/packages/react-native/React/Views/RCTView.h @@ -10,6 +10,7 @@ #import #import #import +#import #import extern const UIAccessibilityTraits SwitchAccessibilityTrait; @@ -120,6 +121,8 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait; */ @property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets; +@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 6e6f9cd2761..292956e268a 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -136,6 +136,7 @@ static NSString *RCTRecursiveAccessibilityLabel(UIView *view) _borderCurve = RCTBorderCurveCircular; _borderStyle = RCTBorderStyleSolid; _hitTestEdgeInsets = UIEdgeInsetsZero; + _cursor = RCTCursorAuto; _backgroundColor = super.backgroundColor; } @@ -796,6 +797,8 @@ static CGFloat RCTDefaultIfNegativeTo(CGFloat defaultValue, CGFloat x) RCTUpdateShadowPathForView(self); + RCTUpdateHoverStyleForView(self); + const RCTCornerRadii cornerRadii = [self cornerRadii]; const UIEdgeInsets borderInsets = [self bordersAsInsets]; const RCTBorderColors borderColors = [self borderColorsWithTraitCollection:self.traitCollection]; @@ -891,6 +894,31 @@ static void RCTUpdateShadowPathForView(RCTView *view) } } +static void RCTUpdateHoverStyleForView(RCTView *view) +{ + if (@available(iOS 17.0, *)) { + UIHoverStyle *hoverStyle = nil; + if ([view cursor] == RCTCursorPointer) { + const RCTCornerRadii cornerRadii = [view cornerRadii]; + const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero); +#if TARGET_OS_IOS + // Due to an Apple bug, it seems on iOS, `[UIShape shapeWithBezierPath:]` needs to + // be calculated in the superviews' coordinate space (view.frame). This is not true + // on other platforms like visionOS. + CGPathRef borderPath = RCTPathCreateWithRoundedRect(view.frame, cornerInsets, NULL); +#else // TARGET_OS_VISION + CGPathRef borderPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL); +#endif + UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath]; + CGPathRelease(borderPath); + UIShape *shape = [UIShape shapeWithBezierPath:bezierPath]; + + hoverStyle = [UIHoverStyle styleWithEffect:[UIHoverHighlightEffect effect] shape:shape]; + } + [view setHoverStyle:hoverStyle]; + } +} + - (void)updateClippingForLayer:(CALayer *)layer { CALayer *mask = nil; diff --git a/packages/react-native/React/Views/RCTViewManager.m b/packages/react-native/React/Views/RCTViewManager.m index 66c449d7e81..0a0126545bd 100644 --- a/packages/react-native/React/Views/RCTViewManager.m +++ b/packages/react-native/React/Views/RCTViewManager.m @@ -13,6 +13,7 @@ #import "RCTBridge.h" #import "RCTConvert+Transform.h" #import "RCTConvert.h" +#import "RCTCursor.h" #import "RCTLog.h" #import "RCTShadowView.h" #import "RCTUIManager.h" @@ -195,6 +196,7 @@ RCT_REMAP_VIEW_PROPERTY(testID, reactAccessibilityElement.accessibilityIdentifie 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.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 3d55f25c38c..358fb3773eb 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -140,6 +140,14 @@ BaseViewProps::BaseViewProps( "shadowRadius", sourceProps.shadowRadius, {})), + cursor( + CoreFeatures::enablePropIteratorSetter ? sourceProps.cursor + : convertRawProp( + context, + rawProps, + "cursor", + sourceProps.cursor, + {})), transform( CoreFeatures::enablePropIteratorSetter ? sourceProps.transform : convertRawProp( @@ -281,6 +289,7 @@ void BaseViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(collapsable); RAW_SET_PROP_SWITCH_CASE_BASIC(removeClippedSubviews); RAW_SET_PROP_SWITCH_CASE_BASIC(experimental_layoutConformance); + RAW_SET_PROP_SWITCH_CASE_BASIC(cursor); // events field VIEW_EVENT_CASE(PointerEnter); VIEW_EVENT_CASE(PointerEnterCapture); 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 5bacae92232..73cf45387e8 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -52,6 +52,8 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { Float shadowOpacity{}; Float shadowRadius{3}; + Cursor cursor{}; + // Transform Transform transform{}; TransformOrigin transformOrigin{ diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp index fe11e92e1a5..9c92eac7ccc 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -60,7 +60,7 @@ void ViewShadowNode::initialize() noexcept { viewProps.accessibilityElementsHidden || viewProps.accessibilityViewIsModal || viewProps.importantForAccessibility != ImportantForAccessibility::Auto || - viewProps.removeClippedSubviews || + viewProps.removeClippedSubviews || viewProps.cursor != Cursor::Auto || HostPlatformViewTraitsInitializer::formsStackingContext(viewProps); bool formsView = formsStackingContext || diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h index aa6fb367daa..bbd9f46cfaf 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h @@ -705,6 +705,28 @@ inline void fromRawValue( react_native_expect(false); } +inline void fromRawValue( + const PropsParserContext& context, + const RawValue& value, + Cursor& result) { + result = Cursor::Auto; + react_native_expect(value.hasType()); + if (!value.hasType()) { + return; + } + auto stringValue = (std::string)value; + if (stringValue == "auto") { + result = Cursor::Auto; + return; + } + if (stringValue == "pointer") { + result = Cursor::Pointer; + return; + } + LOG(ERROR) << "Could not parse Cursor:" << stringValue; + react_native_expect(false); +} + inline void fromRawValue( const PropsParserContext& /*context*/, const RawValue& value, diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h b/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h index 162f2292cc6..09f98979517 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h @@ -91,6 +91,8 @@ enum class BorderCurve : uint8_t { Circular, Continuous }; enum class BorderStyle : uint8_t { Solid, Dotted, Dashed }; +enum class Cursor : uint8_t { Auto, Pointer }; + enum class LayoutConformance : uint8_t { Undefined, Classic, Strict }; template diff --git a/packages/rn-tester/js/examples/Cursor/CursorExample.js b/packages/rn-tester/js/examples/Cursor/CursorExample.js new file mode 100644 index 00000000000..77936112305 --- /dev/null +++ b/packages/rn-tester/js/examples/Cursor/CursorExample.js @@ -0,0 +1,124 @@ +/** + * 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. + * + * @format + */ + +'use strict'; + +const React = require('react'); +const {StyleSheet, Text, View} = require('react-native'); + +const styles = StyleSheet.create({ + invisibleBox: { + width: 100, + height: 100, + }, + box: { + width: 100, + height: 100, + borderWidth: 2, + }, + circle: { + width: 100, + height: 100, + borderWidth: 2, + borderRadius: 100, + }, + halfcircle: { + width: 100, + height: 100, + borderWidth: 2, + borderTopStartRadius: 100, + borderBottomStartRadius: 100, + }, + solid: { + backgroundColor: 'blue', + }, + pointer: { + cursor: 'pointer', + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + centerContent: { + justifyContent: 'center', + alignItems: 'center', + }, +}); + +function CursorExampleAuto() { + return ( + + + + + + + + + ); +} + +function CursorExamplePointer() { + return ( + + + + + + + + + ); +} + +function CursorExamplePointer() { + return ( + + + + + + + + + ); +} + +function CursorExampleViewFlattening() { + return ( + + + pointer + + + ); +} + +exports.title = 'Cursor'; +exports.category = 'UI'; +exports.description = + 'Demonstrates setting a cursor, which affects the appearance when a pointer is over the View.'; +exports.examples = [ + { + title: 'Default', + description: "Cursor: 'auto' or no cursor set ", + render: CursorExampleAuto, + }, + { + title: 'Pointer', + description: 'cursor: pointer', + render: CursorExamplePointer, + }, + { + title: 'View flattening', + description: 'Views with a cursor do not get flattened', + render: CursorExampleViewFlattening, + }, +]; diff --git a/packages/rn-tester/js/utils/RNTesterList.ios.js b/packages/rn-tester/js/utils/RNTesterList.ios.js index fa0587ccd14..4dbcabd8b3f 100644 --- a/packages/rn-tester/js/utils/RNTesterList.ios.js +++ b/packages/rn-tester/js/utils/RNTesterList.ios.js @@ -195,6 +195,10 @@ const APIs: Array = ([ key: 'CrashExample', module: require('../examples/Crash/CrashExample'), }, + { + key: 'CursorExample', + module: require('../examples/Cursor/CursorExample'), + }, { key: 'DevSettings', module: require('../examples/DevSettings/DevSettingsExample'),