feat(iOS): Implement cursor style prop (#43078)

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
This commit is contained in:
Saad Najmi
2024-03-04 18:51:17 -08:00
committed by Facebook GitHub Bot
parent 923d4abd7b
commit 73664f576a
18 changed files with 262 additions and 1 deletions
@@ -144,6 +144,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
borderTopLeftRadius: true,
borderTopRightRadius: true,
borderTopStartRadius: true,
cursor: true,
opacity: true,
pointerEvents: true,
@@ -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;
}
@@ -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<{
@@ -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,
@@ -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>
@@ -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;
@@ -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;
@@ -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() &&
@@ -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,
};
@@ -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;
@@ -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.
*/
@@ -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;
@@ -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)
@@ -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);
@@ -52,6 +52,8 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps {
Float shadowOpacity{};
Float shadowRadius{3};
Cursor cursor{};
// Transform
Transform transform{};
TransformOrigin transformOrigin{
@@ -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 ||
@@ -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,
@@ -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>
@@ -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 (
<View style={styles.row}>
<View style={styles.box} />
<View style={styles.circle} />
<View style={styles.halfcircle} />
<View style={[styles.box, styles.solid]} />
<View style={[styles.circle, styles.solid]} />
<View style={[styles.halfcircle, styles.solid]} />
</View>
);
}
function CursorExamplePointer() {
return (
<View style={styles.row}>
<View style={[styles.box, styles.pointer]} />
<View style={[styles.circle, styles.pointer]} />
<View style={[styles.halfcircle, styles.pointer]} />
<View style={[styles.box, styles.solid, styles.pointer]} />
<View style={[styles.circle, styles.solid, styles.pointer]} />
<View style={[styles.halfcircle, styles.solid, styles.pointer]} />
</View>
);
}
function CursorExamplePointer() {
return (
<View style={styles.row}>
<View style={[styles.box, styles.pointer]} />
<View style={[styles.circle, styles.pointer]} />
<View style={[styles.halfcircle, styles.pointer]} />
<View style={[styles.box, styles.solid, styles.pointer]} />
<View style={[styles.circle, styles.solid, styles.pointer]} />
<View style={[styles.halfcircle, styles.solid, styles.pointer]} />
</View>
);
}
function CursorExampleViewFlattening() {
return (
<View style={styles.row}>
<View style={[styles.invisibleBox, styles.centerContent, styles.pointer]}>
<Text>pointer</Text>
</View>
</View>
);
}
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,
},
];
@@ -195,6 +195,10 @@ const APIs: Array<RNTesterModuleInfo> = ([
key: 'CrashExample',
module: require('../examples/Crash/CrashExample'),
},
{
key: 'CursorExample',
module: require('../examples/Cursor/CursorExample'),
},
{
key: 'DevSettings',
module: require('../examples/DevSettings/DevSettingsExample'),