From 73664f576aaa472d5c8fb2a02e0ddd017bbb2ea4 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 4 Mar 2024 18:51:17 -0800 Subject: [PATCH] feat(iOS): Implement cursor style prop (#43078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Implement the cursor style prop for iOS (and consequently, visionOS), as described in this RFC: https://github.com/react-native-community/discussions-and-proposals/pull/750 See related PR in React Native macOS, where we target macOS and visionOS (not running in iPad compatibility mode) with the same change: https://github.com/microsoft/react-native-macos/pull/2080 Docs update: https://github.com/facebook/react-native-website/pull/4033 ## Changelog: [IOS] [ADDED] - Implement cursor style prop Pull Request resolved: https://github.com/facebook/react-native/pull/43078 Test Plan: See the added example page, running on iOS with the new architecture enabled. This also runs the same on the old architecture. https://github.com/facebook/react-native/assets/6722175/2af60a0c-1c1f-45c4-8d66-a20f6d5815df See the example page running on all three apple platforms. The JS is slightly different because: 1. The "macOS Cursors" example is not part of this PR but the one in React Native macOS. 2. This PR (and exapmple) has went though a bunch of iterations and It got hard taking videos of every change 😅 https://github.com/facebook/react-native/assets/6722175/7775ba7c-8624-4873-a735-7665b94b7233 ## Notes - React Native macOS added the cursor prop to View with https://github.com/microsoft/react-native-macos/pull/760 and Text with https://github.com/microsoft/react-native-macos/pull/1469 . Much of the implementation comes from there. - Due to an Apple bug, as of iOS 17.4 Beta 4, the shape of the iOS cursor hover effect doesn't render in the correct bounds (but it does on visionOS). I've worked around it with an ifdef. The result is that the hover effect will work on iOS and visionOS, but not iPad apps running in compatibility mode on visionOS. Reviewed By: NickGerleman Differential Revision: D54512945 Pulled By: vincentriemer fbshipit-source-id: 699e3a01a901f55a466a2c1a19f667aede5aab80 --- .../View/ReactNativeStyleAttributes.js | 1 + .../Libraries/StyleSheet/StyleSheetTypes.d.ts | 4 + .../Libraries/StyleSheet/StyleSheetTypes.js | 3 + .../__snapshots__/public-api-test.js.snap | 2 + packages/react-native/React/Base/RCTConvert.h | 3 + .../react-native/React/Base/RCTConvert.mm | 9 ++ .../View/RCTViewComponentView.mm | 30 +++++ packages/react-native/React/Views/RCTCursor.h | 13 ++ packages/react-native/React/Views/RCTView.h | 3 + packages/react-native/React/Views/RCTView.m | 28 ++++ .../react-native/React/Views/RCTViewManager.m | 2 + .../components/view/BaseViewProps.cpp | 9 ++ .../renderer/components/view/BaseViewProps.h | 2 + .../components/view/ViewShadowNode.cpp | 2 +- .../renderer/components/view/conversions.h | 22 ++++ .../renderer/components/view/primitives.h | 2 + .../js/examples/Cursor/CursorExample.js | 124 ++++++++++++++++++ .../rn-tester/js/utils/RNTesterList.ios.js | 4 + 18 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/React/Views/RCTCursor.h create mode 100644 packages/rn-tester/js/examples/Cursor/CursorExample.js 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'),