Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[0.73-stable] Add mouse hover events to RCTTextView #2143

Merged
22 changes: 20 additions & 2 deletions packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ - (void)uiManagerWillPerformMounting
exclusiveOwnership:YES];

NSNumber *tag = self.reactTag;
NSMutableArray<NSNumber *> *descendantViewTags = [NSMutableArray new];
NSMutableSet<NSNumber *> *descendantViewTags = [NSMutableSet new]; // [macOS] avoids duplicates

#if !TARGET_OS_OSX // [macOS]
amgleitman marked this conversation as resolved.
Show resolved Hide resolved
[textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:NSMakeRange(0, textStorage.length)
options:0
Expand All @@ -95,6 +97,22 @@ - (void)uiManagerWillPerformMounting

[descendantViewTags addObject:shadowView.reactTag];
}];
#else // [macOS
[textStorage enumerateAttributesInRange:NSMakeRange(0, textStorage.length)
options:0
usingBlock:^(NSDictionary<NSAttributedStringKey, id> *_Nonnull attrs, NSRange range, __unused BOOL * _Nonnull stop) {
id embeddedViewAttribute = attrs[RCTBaseTextShadowViewEmbeddedShadowViewAttributeName];
if ([embeddedViewAttribute isKindOfClass:[RCTShadowView class]]) {
RCTShadowView *embeddedShadowView = (RCTShadowView *)embeddedViewAttribute;
[descendantViewTags addObject:embeddedShadowView.reactTag];
}

id tagAttribute = attrs[RCTTextAttributesTagAttributeName];
if ([tagAttribute isKindOfClass:[NSNumber class]] && ![tagAttribute isEqualToNumber:tag]) {
[descendantViewTags addObject:tagAttribute];
}
}];
#endif // macOS]

[_bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, RCTUIView *> *viewRegistry) { // [macOS]
RCTTextView *textView = (RCTTextView *)viewRegistry[tag];
Expand All @@ -103,7 +121,7 @@ - (void)uiManagerWillPerformMounting
}

NSMutableArray<RCTPlatformView *> *descendantViews = [NSMutableArray arrayWithCapacity:descendantViewTags.count]; // [macOS]
[descendantViewTags
[[descendantViewTags allObjects] // [macOS] because it's an NSMutableSet now
enumerateObjectsUsingBlock:^(NSNumber *_Nonnull descendantViewTag, NSUInteger index, BOOL *_Nonnull stop) {
RCTPlatformView *descendantView = viewRegistry[descendantViewTag]; // [macOS]
if (!descendantView) {
Expand Down
116 changes: 116 additions & 0 deletions packages/react-native/Libraries/Text/Text/RCTTextView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#endif // [macOS]

#import <React/RCTAssert.h> // [macOS]
#import <React/RCTUIManager.h> // [macOS]
#import <React/RCTUtils.h>
#import <React/UIView+React.h>
#import <React/RCTFocusChangeEvent.h> // [macOS]
Expand Down Expand Up @@ -62,6 +63,7 @@ @implementation RCTTextView {

id<RCTEventDispatcherProtocol> _eventDispatcher; // [macOS]
NSArray<RCTUIView *> *_Nullable _descendantViews; // [macOS]
RCTUIView *_Nullable _currentHoveredSubview; // [macOS]
amgleitman marked this conversation as resolved.
Show resolved Hide resolved
NSTextStorage *_Nullable _textStorage;
CGRect _contentFrame;
}
Expand Down Expand Up @@ -99,6 +101,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_textView.layoutManager.usesFontLeading = NO;
_textStorage = _textView.textStorage;
[self addSubview:_textView];
_currentHoveredSubview = nil;
#endif // macOS]
RCTUIViewSetContentModeRedraw(self); // [macOS]
}
Expand Down Expand Up @@ -414,6 +417,20 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture
}
#else // [macOS

- (BOOL)hasMouseHoverEvent
{
if ([super hasMouseHoverEvent]) {
return YES;
}

// All descendant views of an RCTTextView are RCTVirtualTextViews
NSUInteger indexOfChildWithMouseHoverEvent = [_descendantViews indexOfObjectPassingTest:^BOOL(RCTUIView * _Nonnull childView, NSUInteger idx, BOOL * _Nonnull stop) {
*stop = [childView hasMouseHoverEvent];
return *stop;
}];
return indexOfChildWithMouseHoverEvent != NSNotFound;
}

- (NSView *)hitTest:(NSPoint)point
{
// 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).
Expand All @@ -428,6 +445,105 @@ - (NSView *)hitTest:(NSPoint)point
return isTextViewClick ? self : hitView;
}

- (NSNumber *)reactTagAtMouseLocationFromEvent:(NSEvent *)event
{
NSPoint locationInSelf = [self convertPoint:event.locationInWindow fromView:nil];
NSPoint locationInInnerTextView = [self convertPoint:locationInSelf toView:_textView]; // This is needed if the parent <Text> view has padding
return [self reactTagAtPoint:locationInInnerTextView];
}

- (void)mouseEntered:(NSEvent *)event
{
// superclass invokes self.onMouseEnter, so do this first
[super mouseEntered:event];

[self updateHoveredSubviewWithEvent:event];
}

- (void)mouseExited:(NSEvent *)event
{
[self updateHoveredSubviewWithEvent:event];

// superclass invokes self.onMouseLeave, so do this last
[super mouseExited:event];
}

- (void)mouseMoved:(NSEvent *)event
{
[super mouseMoved:event];
[self updateHoveredSubviewWithEvent:event];
}

- (void)updateHoveredSubviewWithEvent:(NSEvent *)event
{
RCTUIView *hoveredView = nil;

if ([event type] != NSEventTypeMouseExited && _descendantViews != nil) {
NSNumber *reactTagOfHoveredView = [self reactTagAtMouseLocationFromEvent:event];

if ([reactTagOfHoveredView isEqualToNumber:self.reactTag]) {
// We're hovering over the root Text element
hoveredView = self;
} else {
// Maybe we're hovering over a child Text element?
NSUInteger index = [_descendantViews indexOfObjectPassingTest:^BOOL(RCTUIView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) {
*stop = [[view reactTag] isEqualToNumber:reactTagOfHoveredView];
return *stop;
}];
if (index != NSNotFound) {
hoveredView = _descendantViews[index];
}
}
}

if (_currentHoveredSubview == hoveredView) {
amgleitman marked this conversation as resolved.
Show resolved Hide resolved
return;
}

// self will always be an ancestor of any views we pass in here, so it serves as a good default option.
// Also, if we do set from/to nil, we have to call the relevant events on the entire subtree.
RCTUIManager *uiManager = [[_eventDispatcher bridge] uiManager];
RCTShadowView *oldShadowView = [uiManager shadowViewForReactTag:[(_currentHoveredSubview ?: self) reactTag]];
RCTShadowView *newShadowView = [uiManager shadowViewForReactTag:[(hoveredView ?: self) reactTag]];

// Find the common ancestor between the two shadow views
RCTShadowView *commonAncestor = [oldShadowView ancestorSharedWithShadowView:newShadowView];

for (RCTShadowView *exitedShadowView = oldShadowView; exitedShadowView != commonAncestor && exitedShadowView != nil; exitedShadowView = [exitedShadowView reactSuperview]) {
RCTPlatformView *exitedView = [uiManager viewForReactTag:[exitedShadowView reactTag]];
if (![exitedView isKindOfClass:[RCTUIView class]]) {
RCTLogError(@"Unexpected view of type %@ found in hierarchy, must be RCTUIView or subclass", [exitedView class]);
continue;
}

RCTUIView *exitedReactView = (RCTUIView *)exitedView;
[self sendMouseEventWithBlock:[exitedReactView onMouseLeave]
locationInfo:[self locationInfoFromEvent:event]
modifierFlags:event.modifierFlags
additionalData:nil];
}

// We cache these so we can call them from outermost to innermost
NSMutableArray<RCTUIView *> *enteredViewHierarchy = [NSMutableArray new];
for (RCTShadowView *enteredShadowView = newShadowView; enteredShadowView != commonAncestor && enteredShadowView != nil; enteredShadowView = [enteredShadowView reactSuperview]) {
RCTPlatformView *enteredView = [uiManager viewForReactTag:[enteredShadowView reactTag]];
if (![enteredView isKindOfClass:[RCTUIView class]]) {
RCTLogError(@"Unexpected view of type %@ found in hierarchy, must be RCTUIView or subclass", [enteredView class]);
continue;
}

[enteredViewHierarchy addObject:(RCTUIView *)enteredView];
}
for (NSInteger i = [enteredViewHierarchy count] - 1; i >= 0; i--) {
[self sendMouseEventWithBlock:[[enteredViewHierarchy objectAtIndex:i] onMouseEnter]
locationInfo:[self locationInfoFromEvent:event]
modifierFlags:event.modifierFlags
additionalData:nil];
}

_currentHoveredSubview = hoveredView;
}

- (void)rightMouseDown:(NSEvent *)event
{

Expand Down
1 change: 1 addition & 0 deletions packages/react-native/React/Base/RCTUIKit.h
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ CGPathRef UIBezierPathCreateCGPathRef(UIBezierPath *path);
- (void)setNeedsDisplay;

// Methods related to mouse events
- (BOOL)hasMouseHoverEvent;
- (NSDictionary*)locationInfoFromDraggingLocation:(NSPoint)locationInWindow;
- (NSDictionary*)locationInfoFromEvent:(NSEvent*)event;

Expand Down
30 changes: 30 additions & 0 deletions packages/react-native/React/Base/macOS/RCTUIKit.m
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ @implementation RCTUIView
BOOL _clipsToBounds;
BOOL _hasMouseOver;
BOOL _userInteractionEnabled;
NSTrackingArea *_trackingArea;
BOOL _mouseDownCanMoveWindow;
}

Expand Down Expand Up @@ -349,6 +350,13 @@ - (void)viewBoundsChanged:(NSNotification*)__unused inNotif
}
}

- (BOOL)hasMouseHoverEvent
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
{
// This can be overridden by subclasses as needed.
// e.g., RCTTextView, which consolidates its JS children into a single gigantic NSAttributedString
return self.onMouseEnter || self.onMouseLeave;
}

- (NSDictionary*)locationInfoFromDraggingLocation:(NSPoint)locationInWindow
{
NSPoint locationInView = [self convertPoint:locationInWindow fromView:nil];
Expand Down Expand Up @@ -425,6 +433,28 @@ - (void)sendMouseEventWithBlock:(RCTDirectEventBlock)block
block(body);
}

- (void)updateTrackingAreas
{
BOOL hasMouseHoverEvent = [self hasMouseHoverEvent];
BOOL wouldRecreateIdenticalTrackingArea = hasMouseHoverEvent && _trackingArea && NSEqualRects(self.bounds, [_trackingArea rect]);

if (!wouldRecreateIdenticalTrackingArea) {
if (_trackingArea) {
[self removeTrackingArea:_trackingArea];
}

if (hasMouseHoverEvent) {
_trackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds
options:NSTrackingActiveAlways|NSTrackingMouseEnteredAndExited
owner:self
userInfo:nil];
[self addTrackingArea:_trackingArea];
}
}

[super updateTrackingAreas];
}

- (BOOL)mouseDownCanMoveWindow{
return _mouseDownCanMoveWindow;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native/React/Views/RCTShadowView.h
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ typedef void (^RCTApplierBlock)(NSDictionary<NSNumber *, RCTPlatformView *> *vie
*/
- (CGRect)measureLayoutRelativeToAncestor:(RCTShadowView *)ancestor;

// [macOS
/**
* Returns the closest ancestor shared by this shadow view and another specified shadow view.
*/
- (RCTShadowView *)ancestorSharedWithShadowView:(RCTShadowView *)shadowView;
// macOS]

/**
* Checks if the current shadow view is a descendant of the provided `ancestor`
*/
Expand Down
19 changes: 19 additions & 0 deletions packages/react-native/React/Views/RCTShadowView.m
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,25 @@ - (CGRect)measureLayoutRelativeToAncestor:(RCTShadowView *)ancestor
return (CGRect){offset, self.layoutMetrics.frame.size};
}

// [macOS
- (RCTShadowView *)ancestorSharedWithShadowView:(RCTShadowView *)shadowView
{
// TODO: This can be optimized by climbing up both hierarchies at the same time
amgleitman marked this conversation as resolved.
Show resolved Hide resolved
NSMutableSet<RCTShadowView *> *selfSuperviews = [NSMutableSet set];
for (RCTShadowView *view = self; view != nil; view = [view reactSuperview]) {
[selfSuperviews addObject:view];
}

for (RCTShadowView *candidateView = shadowView; candidateView != nil; candidateView = [candidateView reactSuperview]) {
if ([selfSuperviews containsObject:candidateView]) {
return candidateView;
}
}

return nil;
}
// macOS]

- (BOOL)viewIsDescendantOf:(RCTShadowView *)ancestor
{
RCTShadowView *shadowView = self;
Expand Down
23 changes: 0 additions & 23 deletions packages/react-native/React/Views/RCTView.m
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ @implementation RCTView {
RCTUIColor *_backgroundColor; // [macOS]
id<RCTEventDispatcherProtocol> _eventDispatcher; // [macOS]
#if TARGET_OS_OSX // [macOS
NSTrackingArea *_trackingArea;
BOOL _mouseDownCanMoveWindow;
#endif // macOS]
NSMutableDictionary<NSString *, NSDictionary *> *accessibilityActionsNameMap;
Expand Down Expand Up @@ -1466,28 +1465,6 @@ - (BOOL)acceptsFirstResponder
return [self focusable] || [super acceptsFirstResponder];
}

- (void)updateTrackingAreas
{
BOOL hasMouseHoverEvent = self.onMouseEnter || self.onMouseLeave;
BOOL wouldRecreateIdenticalTrackingArea = hasMouseHoverEvent && _trackingArea && NSEqualRects(self.bounds, [_trackingArea rect]);

if (!wouldRecreateIdenticalTrackingArea) {
if (_trackingArea) {
[self removeTrackingArea:_trackingArea];
}

if (hasMouseHoverEvent) {
_trackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds
options:NSTrackingActiveAlways|NSTrackingMouseEnteredAndExited
owner:self
userInfo:nil];
[self addTrackingArea:_trackingArea];
}
}

[super updateTrackingAreas];
}

- (BOOL)mouseDownCanMoveWindow{
return _mouseDownCanMoveWindow;
}
Expand Down