Skip to content

Commit 10b1cd8

Browse files
amgleitmanAdam Gleitman
andauthored
Add onMouseEnter and onMouseLeave to Text (cherry-picked from 0.73-stable) (#2149)
* [0.73-stable] New events for RCTUIView (#2137) * Move mouse events from RCTView to superclass RCTUIView * Add focus and responder events * Move mouse event implementations to RCTUIView class --------- Co-authored-by: Adam Gleitman <[email protected]> * [0.73-stable] Add mouse hover events to `RCTTextView` (#2143) * Initial implementation * Refactor and dedupe some code * Basic error handling * Clarify comment about mousemove order * Cleanup: enumerate all text attributes at once instead of doing multiple passes * Use *shadow* view traversal for handling nested mouse events * Remove potentially confusing comment * descendantViewTags doesn't need to worry about duplicates * Distinguish between embedded views and virtual text subviews * Scope _virtualSubviews to macOS only * nit: use separate #if blocks for `setTextStorage:...` and `getRectForCharRange:` inclusions * TARGET_OS_OSX blocks for virtualSubviewTags * Remove #if TARGET_OS_OSX blocks, since these changes are potentially upstreamable * Clarify a TODO --------- Co-authored-by: Adam Gleitman <[email protected]> * Add an example for Text mouse hover events * Add missing macOS props to Text.d.ts * Fix flow errors * Fix lint errors --------- Co-authored-by: Adam Gleitman <[email protected]>
1 parent 73cdf93 commit 10b1cd8

File tree

12 files changed

+446
-176
lines changed

12 files changed

+446
-176
lines changed

packages/react-native/Libraries/Text/Text.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,20 @@ export interface TextPropsAndroid {
101101
android_hyphenationFrequency?: 'normal' | 'none' | 'full' | undefined;
102102
}
103103

104+
// [macOS
105+
export interface TextPropsMacOS {
106+
enableFocusRing?: boolean | undefined;
107+
focusable?: boolean | undefined;
108+
onMouseEnter?: ((event: MouseEvent) => void) | undefined;
109+
onMouseLeave?: ((event: MouseEvent) => void) | undefined;
110+
tooltip?: string | undefined;
111+
}
112+
// macOS]
113+
104114
// https://reactnative.dev/docs/text#props
105115
export interface TextProps
106116
extends TextPropsIOS,
117+
TextPropsMacOS, // [macOS]
107118
TextPropsAndroid,
108119
AccessibilityProps {
109120
/**

packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,24 @@ - (void)uiManagerWillPerformMounting
8585

8686
NSNumber *tag = self.reactTag;
8787
NSMutableArray<NSNumber *> *descendantViewTags = [NSMutableArray new];
88-
[textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
89-
inRange:NSMakeRange(0, textStorage.length)
90-
options:0
91-
usingBlock:^(RCTShadowView *shadowView, NSRange range, __unused BOOL *stop) {
92-
if (!shadowView) {
93-
return;
94-
}
88+
NSMutableArray<NSNumber *> *virtualSubviewTags = [NSMutableArray new]; // [macOS]
89+
90+
// [macOS - Enumerate embedded shadow views and virtual subviews in one loop
91+
[textStorage enumerateAttributesInRange:NSMakeRange(0, textStorage.length)
92+
options:0
93+
usingBlock:^(NSDictionary<NSAttributedStringKey, id> *_Nonnull attrs, NSRange range, __unused BOOL * _Nonnull stop) {
94+
id embeddedViewAttribute = attrs[RCTBaseTextShadowViewEmbeddedShadowViewAttributeName];
95+
if ([embeddedViewAttribute isKindOfClass:[RCTShadowView class]]) {
96+
RCTShadowView *embeddedShadowView = (RCTShadowView *)embeddedViewAttribute;
97+
[descendantViewTags addObject:embeddedShadowView.reactTag];
98+
}
9599

96-
[descendantViewTags addObject:shadowView.reactTag];
97-
}];
100+
id tagAttribute = attrs[RCTTextAttributesTagAttributeName];
101+
if ([tagAttribute isKindOfClass:[NSNumber class]] && ![tagAttribute isEqualToNumber:tag]) {
102+
[virtualSubviewTags addObject:tagAttribute];
103+
}
104+
}];
105+
// macOS]
98106

99107
[_bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, RCTPlatformView *> *viewRegistry) { // [macOS]
100108
RCTTextView *textView = (RCTTextView *)viewRegistry[tag];
@@ -113,11 +121,25 @@ - (void)uiManagerWillPerformMounting
113121
[descendantViews addObject:descendantView];
114122
}];
115123

124+
// [macOS
125+
NSMutableArray<RCTVirtualTextView *> *virtualSubviews = [NSMutableArray arrayWithCapacity:virtualSubviewTags.count];
126+
[virtualSubviewTags
127+
enumerateObjectsUsingBlock:^(NSNumber *_Nonnull virtualSubviewTag, NSUInteger index, BOOL *_Nonnull stop) {
128+
RCTPlatformView *virtualSubview = viewRegistry[virtualSubviewTag];
129+
if ([virtualSubview isKindOfClass:[RCTVirtualTextView class]]) {
130+
[virtualSubviews addObject:(RCTVirtualTextView *)virtualSubview];
131+
}
132+
}];
133+
// macOS]
134+
116135
// Removing all references to Shadow Views to avoid unnecessary retaining.
117136
[textStorage removeAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
118137
range:NSMakeRange(0, textStorage.length)];
119138

120-
[textView setTextStorage:textStorage contentFrame:contentFrame descendantViews:descendantViews];
139+
[textView setTextStorage:textStorage
140+
contentFrame:contentFrame
141+
descendantViews:descendantViews
142+
virtualSubviews:virtualSubviews]; // [macOS]
121143
}];
122144
}
123145

packages/react-native/Libraries/Text/Text/RCTTextView.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#import <React/RCTComponent.h>
99
#import <React/RCTEventDispatcher.h> // [macOS]
10+
#import <React/RCTVirtualTextView.h> // [macOS]
1011

1112
#import <React/RCTUIKit.h> // [macOS]
1213

@@ -22,6 +23,13 @@ NS_ASSUME_NONNULL_BEGIN
2223
contentFrame:(CGRect)contentFrame
2324
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews; // [macOS]
2425

26+
// [macOS
27+
- (void)setTextStorage:(NSTextStorage *)textStorage
28+
contentFrame:(CGRect)contentFrame
29+
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews
30+
virtualSubviews:(NSArray<RCTVirtualTextView *> *_Nullable)virtualSubviews;
31+
// macOS]
32+
2533
/**
2634
* (Experimental and unused for Paper) Pointer event handlers.
2735
*/

packages/react-native/Libraries/Text/Text/RCTTextView.mm

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#endif // [macOS]
1313

1414
#import <React/RCTAssert.h> // [macOS]
15+
#import <React/RCTUIManager.h> // [macOS]
1516
#import <React/RCTUtils.h>
1617
#import <React/UIView+React.h>
1718
#import <React/RCTFocusChangeEvent.h> // [macOS]
@@ -62,6 +63,8 @@ @implementation RCTTextView {
6263

6364
id<RCTEventDispatcherProtocol> _eventDispatcher; // [macOS]
6465
NSArray<RCTUIView *> *_Nullable _descendantViews; // [macOS]
66+
NSArray<RCTVirtualTextView *> *_Nullable _virtualSubviews; // [macOS]
67+
RCTUIView *_Nullable _currentHoveredSubview; // [macOS]
6568
NSTextStorage *_Nullable _textStorage;
6669
CGRect _contentFrame;
6770
}
@@ -99,6 +102,7 @@ - (instancetype)initWithFrame:(CGRect)frame
99102
_textView.layoutManager.usesFontLeading = NO;
100103
_textStorage = _textView.textStorage;
101104
[self addSubview:_textView];
105+
_currentHoveredSubview = nil;
102106
#endif // macOS]
103107
RCTUIViewSetContentModeRedraw(self); // [macOS]
104108
}
@@ -162,6 +166,20 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
162166
contentFrame:(CGRect)contentFrame
163167
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews // [macOS]
164168
{
169+
// [macOS - to keep track of virtualSubviews as well
170+
[self setTextStorage:textStorage
171+
contentFrame:contentFrame
172+
descendantViews:descendantViews
173+
virtualSubviews:nil];
174+
}
175+
176+
- (void)setTextStorage:(NSTextStorage *)textStorage
177+
contentFrame:(CGRect)contentFrame
178+
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews
179+
virtualSubviews:(NSArray<RCTVirtualTextView *> *)virtualSubviews
180+
{
181+
// macOS]
182+
165183
// This lets the textView own its text storage on macOS
166184
// We update and replace the text container `_textView.textStorage.attributedString` when text/layout changes
167185
#if !TARGET_OS_OSX // [macOS]
@@ -204,6 +222,8 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
204222
[self addSubview:view];
205223
}
206224

225+
_virtualSubviews = virtualSubviews; // [macOS]
226+
207227
[self setNeedsDisplay];
208228
}
209229

@@ -398,6 +418,21 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture
398418
}
399419
#else // [macOS
400420

421+
- (BOOL)hasMouseHoverEvent
422+
{
423+
if ([super hasMouseHoverEvent]) {
424+
return YES;
425+
}
426+
427+
// We only care about virtual subviews here.
428+
// Embedded views (e.g., <Text> <View /> </Text>) handle mouse hover events themselves.
429+
NSUInteger indexOfChildWithMouseHoverEvent = [_virtualSubviews indexOfObjectPassingTest:^BOOL(RCTVirtualTextView *_Nonnull childView, NSUInteger idx, BOOL *_Nonnull stop) {
430+
*stop = [childView hasMouseHoverEvent];
431+
return *stop;
432+
}];
433+
return indexOfChildWithMouseHoverEvent != NSNotFound;
434+
}
435+
401436
- (NSView *)hitTest:(NSPoint)point
402437
{
403438
// We will forward mouse click events to the NSTextView ourselves to prevent NSTextView from swallowing events that may be handled in JS (e.g. long press).
@@ -412,6 +447,110 @@ - (NSView *)hitTest:(NSPoint)point
412447
return isTextViewClick ? self : hitView;
413448
}
414449

450+
- (NSNumber *)reactTagAtMouseLocationFromEvent:(NSEvent *)event
451+
{
452+
NSPoint locationInSelf = [self convertPoint:event.locationInWindow fromView:nil];
453+
NSPoint locationInInnerTextView = [self convertPoint:locationInSelf toView:_textView]; // This is needed if the parent <Text> view has padding
454+
return [self reactTagAtPoint:locationInInnerTextView];
455+
}
456+
457+
- (void)mouseEntered:(NSEvent *)event
458+
{
459+
// superclass invokes self.onMouseEnter, so do this first
460+
[super mouseEntered:event];
461+
462+
[self updateHoveredSubviewWithEvent:event];
463+
}
464+
465+
- (void)mouseExited:(NSEvent *)event
466+
{
467+
[self updateHoveredSubviewWithEvent:event];
468+
469+
// superclass invokes self.onMouseLeave, so do this last
470+
[super mouseExited:event];
471+
}
472+
473+
- (void)mouseMoved:(NSEvent *)event
474+
{
475+
[super mouseMoved:event];
476+
[self updateHoveredSubviewWithEvent:event];
477+
}
478+
479+
- (void)updateHoveredSubviewWithEvent:(NSEvent *)event
480+
{
481+
RCTUIView *hoveredView = nil;
482+
483+
if ([event type] != NSEventTypeMouseExited && _virtualSubviews != nil) {
484+
NSNumber *reactTagOfHoveredView = [self reactTagAtMouseLocationFromEvent:event];
485+
486+
if (reactTagOfHoveredView == nil) {
487+
// This happens if we hover over an embedded view, which will handle its own mouse events
488+
return;
489+
}
490+
491+
if ([reactTagOfHoveredView isEqualToNumber:self.reactTag]) {
492+
// We're hovering over the root Text element
493+
hoveredView = self;
494+
} else {
495+
// Maybe we're hovering over a child Text element?
496+
NSUInteger index = [_virtualSubviews indexOfObjectPassingTest:^BOOL(RCTVirtualTextView *_Nonnull view, NSUInteger idx, BOOL *_Nonnull stop) {
497+
*stop = [[view reactTag] isEqualToNumber:reactTagOfHoveredView];
498+
return *stop;
499+
}];
500+
if (index != NSNotFound) {
501+
hoveredView = _virtualSubviews[index];
502+
}
503+
}
504+
}
505+
506+
if (_currentHoveredSubview == hoveredView) {
507+
return;
508+
}
509+
510+
// self will always be an ancestor of any views we pass in here, so it serves as a good default option.
511+
// Also, if we do set from/to nil, we have to call the relevant events on the entire subtree.
512+
RCTUIManager *uiManager = [[_eventDispatcher bridge] uiManager];
513+
RCTShadowView *oldShadowView = [uiManager shadowViewForReactTag:[(_currentHoveredSubview ?: self) reactTag]];
514+
RCTShadowView *newShadowView = [uiManager shadowViewForReactTag:[(hoveredView ?: self) reactTag]];
515+
516+
// Find the common ancestor between the two shadow views
517+
RCTShadowView *commonAncestor = [oldShadowView ancestorSharedWithShadowView:newShadowView];
518+
519+
for (RCTShadowView *exitedShadowView = oldShadowView; exitedShadowView != commonAncestor && exitedShadowView != nil; exitedShadowView = [exitedShadowView reactSuperview]) {
520+
RCTPlatformView *exitedView = [uiManager viewForReactTag:[exitedShadowView reactTag]];
521+
if (![exitedView isKindOfClass:[RCTUIView class]]) {
522+
RCTLogError(@"Unexpected view of type %@ found in hierarchy, must be RCTUIView or subclass", [exitedView class]);
523+
continue;
524+
}
525+
526+
RCTUIView *exitedReactView = (RCTUIView *)exitedView;
527+
[self sendMouseEventWithBlock:[exitedReactView onMouseLeave]
528+
locationInfo:[self locationInfoFromEvent:event]
529+
modifierFlags:event.modifierFlags
530+
additionalData:nil];
531+
}
532+
533+
// We cache these so we can call them from outermost to innermost
534+
NSMutableArray<RCTUIView *> *enteredViewHierarchy = [NSMutableArray new];
535+
for (RCTShadowView *enteredShadowView = newShadowView; enteredShadowView != commonAncestor && enteredShadowView != nil; enteredShadowView = [enteredShadowView reactSuperview]) {
536+
RCTPlatformView *enteredView = [uiManager viewForReactTag:[enteredShadowView reactTag]];
537+
if (![enteredView isKindOfClass:[RCTUIView class]]) {
538+
RCTLogError(@"Unexpected view of type %@ found in hierarchy, must be RCTUIView or subclass", [enteredView class]);
539+
continue;
540+
}
541+
542+
[enteredViewHierarchy addObject:(RCTUIView *)enteredView];
543+
}
544+
for (NSInteger i = [enteredViewHierarchy count] - 1; i >= 0; i--) {
545+
[self sendMouseEventWithBlock:[[enteredViewHierarchy objectAtIndex:i] onMouseEnter]
546+
locationInfo:[self locationInfoFromEvent:event]
547+
modifierFlags:event.modifierFlags
548+
additionalData:nil];
549+
}
550+
551+
_currentHoveredSubview = hoveredView;
552+
}
553+
415554
- (void)rightMouseDown:(NSEvent *)event
416555
{
417556

packages/react-native/Libraries/Text/TextProps.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,5 +288,19 @@ export type TextProps = $ReadOnly<{|
288288
* @platform macos
289289
*/
290290
enableFocusRing?: ?boolean,
291+
292+
/**
293+
* This event is called when the mouse hovers over this component.
294+
*
295+
* @platform macos
296+
*/
297+
onMouseEnter?: ?(event: MouseEvent) => void,
298+
299+
/**
300+
* This event is called when the mouse moves off of this component.
301+
*
302+
* @platform macos
303+
*/
304+
onMouseLeave?: ?(event: MouseEvent) => void,
291305
// macOS]
292306
|}>;

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ NS_ASSUME_NONNULL_END
123123

124124
#import <AppKit/AppKit.h>
125125

126+
#import <React/RCTComponent.h>
127+
126128
NS_ASSUME_NONNULL_BEGIN
127129

128130
//
@@ -403,6 +405,16 @@ CGPathRef UIBezierPathCreateCGPathRef(UIBezierPath *path);
403405

404406
- (void)setNeedsDisplay;
405407

408+
// Methods related to mouse events
409+
- (BOOL)hasMouseHoverEvent;
410+
- (NSDictionary*)locationInfoFromDraggingLocation:(NSPoint)locationInWindow;
411+
- (NSDictionary*)locationInfoFromEvent:(NSEvent*)event;
412+
413+
- (void)sendMouseEventWithBlock:(RCTDirectEventBlock)block
414+
locationInfo:(NSDictionary*)locationInfo
415+
modifierFlags:(NSEventModifierFlags)modifierFlags
416+
additionalData:(NSDictionary*)additionalData;
417+
406418
// FUTURE: When Xcode 14 is no longer supported (CI is building with Xcode 15), we can remove this override since it's now declared on NSView
407419
@property BOOL clipsToBounds;
408420
@property (nonatomic, copy) NSColor *backgroundColor;
@@ -426,6 +438,24 @@ CGPathRef UIBezierPathCreateCGPathRef(UIBezierPath *path);
426438
*/
427439
@property (nonatomic, assign) BOOL enableFocusRing;
428440

441+
// Mouse events
442+
@property (nonatomic, copy) RCTDirectEventBlock onMouseEnter;
443+
@property (nonatomic, copy) RCTDirectEventBlock onMouseLeave;
444+
@property (nonatomic, copy) RCTDirectEventBlock onDragEnter;
445+
@property (nonatomic, copy) RCTDirectEventBlock onDragLeave;
446+
@property (nonatomic, copy) RCTDirectEventBlock onDrop;
447+
448+
// Focus events
449+
@property (nonatomic, copy) RCTBubblingEventBlock onBlur;
450+
@property (nonatomic, copy) RCTBubblingEventBlock onFocus;
451+
452+
@property (nonatomic, copy) RCTBubblingEventBlock onResponderGrant;
453+
@property (nonatomic, copy) RCTBubblingEventBlock onResponderMove;
454+
@property (nonatomic, copy) RCTBubblingEventBlock onResponderRelease;
455+
@property (nonatomic, copy) RCTBubblingEventBlock onResponderTerminate;
456+
@property (nonatomic, copy) RCTBubblingEventBlock onResponderTerminationRequest;
457+
@property (nonatomic, copy) RCTBubblingEventBlock onStartShouldSetResponder;
458+
429459
@end
430460

431461
// UIScrollView

0 commit comments

Comments
 (0)