Skip to content

Commit

Permalink
feat(iOS): Implement cursor style prop
Browse files Browse the repository at this point in the history
  • Loading branch information
Saadnajmi committed Feb 20, 2024
1 parent 8ff05b5 commit 941db0d
Show file tree
Hide file tree
Showing 11 changed files with 125 additions and 15 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
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;
}

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;
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ @implementation RCTViewComponentView {
BOOL _needsInvalidateLayer;
BOOL _isJSResponder;
BOOL _removeClippedSubviews;
NSString *_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 = [NSString stringWithUTF8String:newViewProps.cursor.c_str()];
needsInvalidateLayer = YES;
}

// `shouldRasterize`
if (oldViewProps.shouldRasterize != newViewProps.shouldRasterize) {
Expand Down Expand Up @@ -573,14 +580,14 @@ - (void)invalidateLayer
}

const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics);
const RCTCornerInsets cornerInsets =
RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero);

// Stage 1. Shadow Path
BOOL const layerHasShadow = layer.shadowOpacity > 0 && CGColorGetAlpha(layer.shadowColor) > 0;
if (layerHasShadow) {
if (CGColorGetAlpha(_backgroundColor.CGColor) > 0.999) {
// If view has a solid background color, calculate shadow path from border.
const RCTCornerInsets cornerInsets =
RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero);
CGPathRef shadowPath = RCTPathCreateWithRoundedRect(self.bounds, cornerInsets, nil);
layer.shadowPath = shadowPath;
CGPathRelease(shadowPath);
Expand All @@ -591,6 +598,23 @@ - (void)invalidateLayer
} else {
layer.shadowPath = nil;
}

// Stage 1.5. Cursor / Hover Effects
if (@available(iOS 17.0, *)) {
CGPathRef borderPath = RCTPathCreateWithRoundedRect(self.bounds, cornerInsets, nil);
UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath];
UIShape *shape = [UIShape shapeWithBezierPath:bezierPath];
NSObject<UIHoverEffect> *hoverEffect = nil;


if ([_cursor isEqual:@"pointer"]) {
hoverEffect = [UIHoverAutomaticEffect effect];
[self setHoverStyle:[UIHoverStyle styleWithEffect:hoverEffect shape:shape]];
} else {
[self setHoverStyle:nil];
}
}


// Stage 2. Border Rendering
const bool useCoreAnimationBorderRendering =
Expand Down
20 changes: 20 additions & 0 deletions packages/react-native/React/Views/RCTConvert+Cursor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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 <React/RCTConvert.h>

typedef NS_ENUM(NSInteger, RCTCursor) {
RCTCursorAuto,
RCTCursorPointer,
};


@interface RCTConvert (Transform)

+ (RCTCursor)RCTCursor:(id)json;

@end
22 changes: 22 additions & 0 deletions packages/react-native/React/Views/RCTConvert+Cursor.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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 "RCTConvert+Cursor.h"


@implementation RCTConvert (Cursor)

RCT_ENUM_CONVERTER(
RCTCursor,
(@{
@"auto" : @(RCTCursorAuto),
@"pointer" : @(RCTCursorPointer),
}),
RCTCursorAuto,
integerValue)

@end
7 changes: 7 additions & 0 deletions packages/react-native/React/Views/RCTView.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
#import <React/RCTComponent.h>
#import <React/RCTPointerEvents.h>

#import "RCTConvert+Cursor.h"

extern const UIAccessibilityTraits SwitchAccessibilityTrait;

@protocol RCTAutoInsetsProtocol;
Expand Down Expand Up @@ -120,6 +122,11 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait;
*/
@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets;

/**
* Used to control hover effects
*/
@property (nonatomic, assign) RCTCursor cursor;

/**
* (Experimental and unused for Paper) Pointer event handlers.
*/
Expand Down
43 changes: 30 additions & 13 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 @@ -861,6 +862,14 @@ - (void)displayLayer:(CALayer *)layer
[self updateClippingForLayer:layer];
}

CGPathRef RCTBorderPathForView(RCTView *view)
{
const RCTCornerRadii cornerRadii = [view cornerRadii];
const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero);
CGPathRef borderPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL);
return borderPath;
}

static BOOL RCTLayerHasShadow(CALayer *layer)
{
return layer.shadowOpacity * CGColorGetAlpha(layer.shadowColor) > 0;
Expand All @@ -873,7 +882,7 @@ static void RCTUpdateShadowPathForView(RCTView *view)
// If view has a solid background color, calculate shadow path from border
const RCTCornerRadii cornerRadii = [view cornerRadii];
const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero);
CGPathRef shadowPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL);
CGPathRef shadowPath = RCTBorderPathForView(view);
view.layer.shadowPath = shadowPath;
CGPathRelease(shadowPath);

Expand All @@ -897,24 +906,32 @@ - (void)updateClippingForLayer:(CALayer *)layer
CGFloat cornerRadius = 0;

if (self.clipsToBounds) {
const RCTCornerRadii cornerRadii = [self cornerRadii];
if (RCTCornerRadiiAreEqual(cornerRadii)) {
cornerRadius = cornerRadii.topLeft;

} else {
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
CGPathRef path =
RCTPathCreateWithRoundedRect(self.bounds, RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero), NULL);
shapeLayer.path = path;
CGPathRelease(path);
mask = shapeLayer;
}
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
CGPathRef path = RCTBorderPathForView(self);
shapeLayer.path = path;
CGPathRelease(path);
mask = shapeLayer;
}

layer.cornerRadius = cornerRadius;
layer.mask = mask;
}

- (void)setCursor:(RCTCursor)cursor
{
if (@available(iOS 17.0, *)) {
UIHoverStyle *hoverStyle = nil;
if (cursor == RCTCursorPointer) {
CGPathRef borderPath = RCTBorderPathForView(self);
UIBezierPath *bezierPath = [UIBezierPath bezierPathWithCGPath:borderPath];
UIShape *shape = [UIShape shapeWithBezierPath:bezierPath];

hoverStyle = [UIHoverStyle styleWithEffect:[UIHoverHighlightEffect effect] shape:[UIShape rectShape]];
}
[self setHoverStyle:hoverStyle];
}
}

#pragma mark Border Color

#define setBorderColor(side) \
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 @@ -11,6 +11,7 @@
#import "RCTBorderCurve.h"
#import "RCTBorderStyle.h"
#import "RCTBridge.h"
#import "RCTConvert+Cursor.h"
#import "RCTConvert+Transform.h"
#import "RCTConvert.h"
#import "RCTLog.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 @@ -46,6 +46,8 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps {
CascadedBorderCurves borderCurves{}; // iOS only?
CascadedBorderStyles borderStyles{};

std::string cursor;

// Shadow
SharedColor shadowColor{};
Size shadowOffset{0, -3};
Expand Down
8 changes: 8 additions & 0 deletions packages/rn-tester/js/examples/View/ViewExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ export default ({
borderRadius: 25,
borderWidth: 1,
marginRight: 10,
cursor: 'pointer',
}}
/>
<View
Expand All @@ -581,6 +582,7 @@ export default ({
borderRadius: 25,
borderWidth: 10,
marginRight: 10,
cursor: 'pointer',
}}
/>
<View
Expand All @@ -593,6 +595,7 @@ export default ({
borderBottomLeftRadius: 50,
borderWidth: 1,
marginRight: 10,
cursor: 'pointer',
}}
/>
<View
Expand All @@ -605,6 +608,7 @@ export default ({
borderBottomLeftRadius: 50,
borderWidth: 10,
marginRight: 10,
cursor: 'pointer',
}}
/>
<View
Expand All @@ -614,6 +618,7 @@ export default ({
borderLeftWidth: 6,
borderTopWidth: 6,
borderTopLeftRadius: 20,
cursor: 'pointer',
}}
/>
<View
Expand All @@ -623,6 +628,7 @@ export default ({
borderRightWidth: 6,
borderTopWidth: 6,
borderTopRightRadius: 20,
cursor: 'pointer',
}}
/>
<View
Expand All @@ -632,6 +638,7 @@ export default ({
borderBottomWidth: 6,
borderLeftWidth: 6,
borderBottomLeftRadius: 20,
cursor: 'pointer',
}}
/>
<View
Expand All @@ -641,6 +648,7 @@ export default ({
borderBottomWidth: 6,
borderRightWidth: 6,
borderBottomRightRadius: 20,
cursor: 'pointer',
}}
/>
</View>
Expand Down

0 comments on commit 941db0d

Please sign in to comment.