Skip to content

Commit 73664f5

Browse files
Saadnajmifacebook-github-bot
authored andcommitted
feat(iOS): Implement cursor style prop (#43078)
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
1 parent 923d4ab commit 73664f5

File tree

18 files changed

+262
-1
lines changed

18 files changed

+262
-1
lines changed

packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
144144
borderTopLeftRadius: true,
145145
borderTopRightRadius: true,
146146
borderTopStartRadius: true,
147+
cursor: true,
147148
opacity: true,
148149
pointerEvents: true,
149150

packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export type DimensionValue =
2727
type AnimatableNumericValue = number | Animated.AnimatedNode;
2828
type AnimatableStringValue = string | Animated.AnimatedNode;
2929

30+
export type CursorValue = 'auto' | 'pointer';
31+
3032
/**
3133
* Flex Prop Types
3234
* @see https://reactnative.dev/docs/flexbox
@@ -274,6 +276,7 @@ export interface ViewStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle {
274276
* Controls whether the View can be the target of touch events.
275277
*/
276278
pointerEvents?: 'box-none' | 'none' | 'box-only' | 'auto' | undefined;
279+
cursor?: CursorValue | undefined;
277280
}
278281

279282
export type FontVariant =
@@ -403,4 +406,5 @@ export interface ImageStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle {
403406
tintColor?: ColorValue | undefined;
404407
opacity?: AnimatableNumericValue | undefined;
405408
objectFit?: 'cover' | 'contain' | 'fill' | 'scale-down' | undefined;
409+
cursor?: CursorValue | undefined;
406410
}

packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export type EdgeInsetsValue = {
3737
export type DimensionValue = number | string | 'auto' | AnimatedNode | null;
3838
export type AnimatableNumericValue = number | AnimatedNode;
3939

40+
export type CursorValue = 'auto' | 'pointer';
41+
4042
/**
4143
* React Native's layout system is based on Flexbox and is powered both
4244
* on iOS and Android by an open source project called `Yoga`:
@@ -729,6 +731,7 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{
729731
opacity?: AnimatableNumericValue,
730732
elevation?: number,
731733
pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only',
734+
cursor?: CursorValue,
732735
}>;
733736

734737
export type ____ViewStyle_Internal = $ReadOnly<{

packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7428,6 +7428,7 @@ export type EdgeInsetsValue = {
74287428
};
74297429
export type DimensionValue = number | string | \\"auto\\" | AnimatedNode | null;
74307430
export type AnimatableNumericValue = number | AnimatedNode;
7431+
export type CursorValue = \\"auto\\" | \\"pointer\\";
74317432
type ____LayoutStyle_Internal = $ReadOnly<{
74327433
display?: \\"none\\" | \\"flex\\",
74337434
width?: DimensionValue,
@@ -7578,6 +7579,7 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{
75787579
opacity?: AnimatableNumericValue,
75797580
elevation?: number,
75807581
pointerEvents?: \\"auto\\" | \\"none\\" | \\"box-none\\" | \\"box-only\\",
7582+
cursor?: CursorValue,
75817583
}>;
75827584
export type ____ViewStyle_Internal = $ReadOnly<{
75837585
...____ViewStyle_InternalCore,

packages/react-native/React/Base/RCTConvert.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#import <React/RCTAnimationType.h>
1212
#import <React/RCTBorderCurve.h>
1313
#import <React/RCTBorderStyle.h>
14+
#import <React/RCTCursor.h>
1415
#import <React/RCTDefines.h>
1516
#import <React/RCTLog.h>
1617
#import <React/RCTPointerEvents.h>
@@ -89,6 +90,8 @@ typedef NSURL RCTFileURL;
8990
+ (UIBarStyle)UIBarStyle:(id)json __deprecated;
9091
#endif
9192

93+
+ (RCTCursor)RCTCursor:(id)json;
94+
9295
+ (CGFloat)CGFloat:(id)json;
9396
+ (CGPoint)CGPoint:(id)json;
9497
+ (CGSize)CGSize:(id)json;

packages/react-native/React/Base/RCTConvert.mm

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,15 @@ + (UIKeyboardType)UIKeyboardType:(id)json RCT_DYNAMIC
545545
UIBarStyleDefault,
546546
integerValue)
547547

548+
RCT_ENUM_CONVERTER(
549+
RCTCursor,
550+
(@{
551+
@"auto" : @(RCTCursorAuto),
552+
@"pointer" : @(RCTCursorPointer),
553+
}),
554+
RCTCursorAuto,
555+
integerValue)
556+
548557
static void convertCGStruct(const char *type, NSArray *fields, CGFloat *result, id json)
549558
{
550559
NSUInteger count = fields.count;

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
257257
self.layer.doubleSided = newViewProps.backfaceVisibility == BackfaceVisibility::Visible;
258258
}
259259

260+
// `cursor`
261+
if (oldViewProps.cursor != newViewProps.cursor) {
262+
needsInvalidateLayer = YES;
263+
}
264+
260265
// `shouldRasterize`
261266
if (oldViewProps.shouldRasterize != newViewProps.shouldRasterize) {
262267
self.layer.shouldRasterize = newViewProps.shouldRasterize;
@@ -592,6 +597,31 @@ - (void)invalidateLayer
592597
layer.shadowPath = nil;
593598
}
594599

600+
// Stage 1.5. Cursor / Hover Effects
601+
if (@available(iOS 17.0, *)) {
602+
UIHoverStyle *hoverStyle = nil;
603+
if (_props->cursor == Cursor::Pointer) {
604+
const RCTCornerInsets cornerInsets =
605+
RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero);
606+
#if TARGET_OS_IOS
607+
// Due to an Apple bug, it seems on iOS, UIShapes made with `[UIShape shapeWithBezierPath:]`
608+
// evaluate their shape on the superviews' coordinate space. This leads to the hover shape
609+
// rendering incorrectly on iOS, iOS apps in compatibility mode on visionOS, but not on visionOS.
610+
// To work around this, for iOS, we can calculate the border path based on `view.frame` (the
611+
// superview's coordinate space) instead of view.bounds.
612+
CGPathRef borderPath = RCTPathCreateWithRoundedRect(self.frame, cornerInsets, NULL);
613+
#else // TARGET_OS_VISION
614+
CGPathRef borderPath = RCTPathCreateWithRoundedRect(self.bounds, cornerInsets, NULL);
615+
#endif
616+
UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath];
617+
CGPathRelease(borderPath);
618+
UIShape *shape = [UIShape shapeWithBezierPath:bezierPath];
619+
620+
hoverStyle = [UIHoverStyle styleWithEffect:[UIHoverAutomaticEffect effect] shape:shape];
621+
}
622+
[self setHoverStyle:hoverStyle];
623+
}
624+
595625
// Stage 2. Border Rendering
596626
const bool useCoreAnimationBorderRendering =
597627
borderMetrics.borderColors.isUniform() && borderMetrics.borderWidths.isUniform() &&
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import <Foundation/Foundation.h>
9+
10+
typedef NS_ENUM(NSInteger, RCTCursor) {
11+
RCTCursorAuto,
12+
RCTCursorPointer,
13+
};

packages/react-native/React/Views/RCTView.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#import <React/RCTBorderCurve.h>
1111
#import <React/RCTBorderStyle.h>
1212
#import <React/RCTComponent.h>
13+
#import <React/RCTCursor.h>
1314
#import <React/RCTPointerEvents.h>
1415

1516
extern const UIAccessibilityTraits SwitchAccessibilityTrait;
@@ -120,6 +121,8 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait;
120121
*/
121122
@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets;
122123

124+
@property (nonatomic, assign) RCTCursor cursor;
125+
123126
/**
124127
* (Experimental and unused for Paper) Pointer event handlers.
125128
*/

packages/react-native/React/Views/RCTView.m

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ - (instancetype)initWithFrame:(CGRect)frame
136136
_borderCurve = RCTBorderCurveCircular;
137137
_borderStyle = RCTBorderStyleSolid;
138138
_hitTestEdgeInsets = UIEdgeInsetsZero;
139+
_cursor = RCTCursorAuto;
139140

140141
_backgroundColor = super.backgroundColor;
141142
}
@@ -796,6 +797,8 @@ - (void)displayLayer:(CALayer *)layer
796797

797798
RCTUpdateShadowPathForView(self);
798799

800+
RCTUpdateHoverStyleForView(self);
801+
799802
const RCTCornerRadii cornerRadii = [self cornerRadii];
800803
const UIEdgeInsets borderInsets = [self bordersAsInsets];
801804
const RCTBorderColors borderColors = [self borderColorsWithTraitCollection:self.traitCollection];
@@ -891,6 +894,31 @@ static void RCTUpdateShadowPathForView(RCTView *view)
891894
}
892895
}
893896

897+
static void RCTUpdateHoverStyleForView(RCTView *view)
898+
{
899+
if (@available(iOS 17.0, *)) {
900+
UIHoverStyle *hoverStyle = nil;
901+
if ([view cursor] == RCTCursorPointer) {
902+
const RCTCornerRadii cornerRadii = [view cornerRadii];
903+
const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero);
904+
#if TARGET_OS_IOS
905+
// Due to an Apple bug, it seems on iOS, `[UIShape shapeWithBezierPath:]` needs to
906+
// be calculated in the superviews' coordinate space (view.frame). This is not true
907+
// on other platforms like visionOS.
908+
CGPathRef borderPath = RCTPathCreateWithRoundedRect(view.frame, cornerInsets, NULL);
909+
#else // TARGET_OS_VISION
910+
CGPathRef borderPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL);
911+
#endif
912+
UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath];
913+
CGPathRelease(borderPath);
914+
UIShape *shape = [UIShape shapeWithBezierPath:bezierPath];
915+
916+
hoverStyle = [UIHoverStyle styleWithEffect:[UIHoverHighlightEffect effect] shape:shape];
917+
}
918+
[view setHoverStyle:hoverStyle];
919+
}
920+
}
921+
894922
- (void)updateClippingForLayer:(CALayer *)layer
895923
{
896924
CALayer *mask = nil;

0 commit comments

Comments
 (0)