Skip to content

Commit

Permalink
feat(iOS): Implement cursor style prop (#43078)
Browse files Browse the repository at this point in the history
Summary:
Implement the cursor style prop for iOS (and consequently, visionOS), as described in this RFC: react-native-community/discussions-and-proposals#750

See related PR in React Native macOS, where we target macOS and visionOS (not running in iPad compatibility mode) with the same change: microsoft#2080

Docs update: facebook/react-native-website#4033

## Changelog:

[IOS] [ADDED] - Implement cursor style prop

Pull Request resolved: #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 microsoft#760 and Text with microsoft#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
  • Loading branch information
Saadnajmi authored and facebook-github-bot committed Mar 5, 2024
1 parent 923d4ab commit 73664f5
Show file tree
Hide file tree
Showing 18 changed files with 262 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
borderTopLeftRadius: true,
borderTopRightRadius: true,
borderTopStartRadius: true,
cursor: true,
opacity: true,
pointerEvents: true,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native/React/Base/RCTConvert.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#import <React/RCTAnimationType.h>
#import <React/RCTBorderCurve.h>
#import <React/RCTBorderStyle.h>
#import <React/RCTCursor.h>
#import <React/RCTDefines.h>
#import <React/RCTLog.h>
#import <React/RCTPointerEvents.h>
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/react-native/React/Base/RCTConvert.mm
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,15 @@ + (UIKeyboardType)UIKeyboardType:(id)json RCT_DYNAMIC
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
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;
Expand Down Expand Up @@ -592,6 +597,31 @@ - (void)invalidateLayer
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() &&
Expand Down
13 changes: 13 additions & 0 deletions packages/react-native/React/Views/RCTCursor.h
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

typedef NS_ENUM(NSInteger, RCTCursor) {
RCTCursorAuto,
RCTCursorPointer,
};
3 changes: 3 additions & 0 deletions packages/react-native/React/Views/RCTView.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#import <React/RCTBorderCurve.h>
#import <React/RCTBorderStyle.h>
#import <React/RCTComponent.h>
#import <React/RCTCursor.h>
#import <React/RCTPointerEvents.h>

extern const UIAccessibilityTraits SwitchAccessibilityTrait;
Expand Down Expand Up @@ -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.
*/
Expand Down
28 changes: 28 additions & 0 deletions packages/react-native/React/Views/RCTView.m
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_borderCurve = RCTBorderCurveCircular;
_borderStyle = RCTBorderStyleSolid;
_hitTestEdgeInsets = UIEdgeInsetsZero;
_cursor = RCTCursorAuto;

_backgroundColor = super.backgroundColor;
}
Expand Down Expand Up @@ -796,6 +797,8 @@ - (void)displayLayer:(CALayer *)layer

RCTUpdateShadowPathForView(self);

RCTUpdateHoverStyleForView(self);

const RCTCornerRadii cornerRadii = [self cornerRadii];
const UIEdgeInsets borderInsets = [self bordersAsInsets];
const RCTBorderColors borderColors = [self borderColorsWithTraitCollection:self.traitCollection];
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-native/React/Views/RCTViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps {
Float shadowOpacity{};
Float shadowRadius{3};

Cursor cursor{};

// Transform
Transform transform{};
TransformOrigin transformOrigin{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>());
if (!value.hasType<std::string>()) {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <typename T>
Expand Down

0 comments on commit 73664f5

Please sign in to comment.