Skip to content

Commit

Permalink
feat(iOS): Implement cursor style prop
Browse files Browse the repository at this point in the history
# Conflicts:
#	packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts
  • Loading branch information
Saadnajmi authored and okwasniewski committed Feb 23, 2024
1 parent cdf24d6 commit 5471136
Show file tree
Hide file tree
Showing 15 changed files with 222 additions and 0 deletions.
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
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 @@ -34,6 +34,8 @@ export type EdgeInsetsValue = {
bottom: number,
};

export type CursorValue = ?('auto' | 'pointer');

export type DimensionValue = number | string | 'auto' | AnimatedNode | null;
export type AnimatableNumericValue = number | AnimatedNode;

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
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 @@ -80,6 +81,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.m
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,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 @@ -32,6 +32,7 @@ @implementation RCTViewComponentView {
BOOL _needsInvalidateLayer;
BOOL _isJSResponder;
BOOL _removeClippedSubviews;
Cursor _cursor;
NSMutableArray<UIView *> *_reactSubviews;
NSSet<NSString *> *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN;
}
Expand Down Expand Up @@ -256,6 +257,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
if (oldViewProps.backfaceVisibility != newViewProps.backfaceVisibility) {
self.layer.doubleSided = newViewProps.backfaceVisibility == BackfaceVisibility::Visible;
}

// `cursor`
if (oldViewProps.cursor != newViewProps.cursor) {
_cursor = newViewProps.cursor;
needsInvalidateLayer = YES;
}

// `shouldRasterize`
if (oldViewProps.shouldRasterize != newViewProps.shouldRasterize) {
Expand Down Expand Up @@ -591,6 +598,31 @@ - (void)invalidateLayer
} else {
layer.shadowPath = nil;
}

// Stage 1.5. Cursor / Hover Effects
if (@available(iOS 17.0, *)) {
UIHoverStyle *hoverStyle = nil;
if (_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 =
Expand Down
14 changes: 14 additions & 0 deletions packages/react-native/React/Views/RCTCursor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* 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 @@ -9,6 +9,7 @@

#import <React/RCTBorderCurve.h>
#import <React/RCTBorderStyle.h>
#import <React/RCTCursor.h>
#import <React/RCTComponent.h>
#import <React/RCTPointerEvents.h>

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,15 @@ BaseViewProps::BaseViewProps(
"shadowRadius",
sourceProps.shadowRadius,
{})),
cursor(
CoreFeatures::enablePropIteratorSetter
? sourceProps.cursor
: convertRawProp(
context,
rawProps,
"cursor",
sourceProps.cursor,
{})),
transform(
CoreFeatures::enablePropIteratorSetter ? sourceProps.transform
: convertRawProp(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps {
Size shadowOffset{0, -3};
Float shadowOpacity{};
Float shadowRadius{3};

Cursor cursor{};

// Transform
Transform transform{};
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
88 changes: 88 additions & 0 deletions packages/rn-tester/js/examples/Cursor/CursorExample.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* 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 {Image, StyleSheet, View} = require('react-native');

const styles = StyleSheet.create({
box: {
width: 100,
height: 100,
borderWidth: 2,
},
circle: {
width: 100,
height: 100,
borderWidth: 2,
borderRadius: 50,
},
halfcircle: {
width: 100,
height: 100,
borderWidth: 2,
borderTopStartRadius: 50,
borderBottomStartRadius: 50,
},
solid: {
backgroundColor: 'blue',
},
pointer: {
cursor: 'pointer',
},
row: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
}
});

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>
);
}

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,
},
];
4 changes: 4 additions & 0 deletions packages/rn-tester/js/utils/RNTesterList.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,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'),
Expand Down

0 comments on commit 5471136

Please sign in to comment.