From b24f37122ea8ef1e20a049caddb4e710ef06fe4c Mon Sep 17 00:00:00 2001 From: Alec Larson Date: Sat, 16 Mar 2019 14:32:24 -0400 Subject: [PATCH] fix: reimplement native "hitSlop" property --- React/Views/RCTView.h | 4 +++ React/Views/RCTView.m | 65 ++++++++++++++++++++++++++++++++---- React/Views/RCTViewManager.m | 12 +++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/React/Views/RCTView.h b/React/Views/RCTView.h index 5ed0ffd558..1e81be1754 100644 --- a/React/Views/RCTView.h +++ b/React/Views/RCTView.h @@ -128,5 +128,9 @@ */ @property (nonatomic, assign) RCTBorderStyle borderStyle; +/** + * Insets used when hit testing inside this view. + */ +@property (nonatomic, assign) NSEdgeInsets hitTestEdgeInsets; @end diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index 5446f0e680..1720a078f1 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -124,6 +124,7 @@ - (instancetype)initWithFrame:(CGRect)frame _borderBottomStartRadius = -1; _borderBottomEndRadius = -1; _borderStyle = RCTBorderStyleSolid; + _hitTestEdgeInsets = NSEdgeInsetsZero; self.clipsToBounds = NO; } @@ -196,21 +197,71 @@ - (void)setTransform:(CATransform3D)transform - (NSView *)hitTest:(CGPoint)point { - // TODO: implement pointerEvents + // TODO: implement "isUserInteractionEnabled" +// BOOL canReceiveTouchEvents = ([self isUserInteractionEnabled] && ![self isHidden]); +// if(!canReceiveTouchEvents) { +// return nil; +// } + + if (self.isHidden) { + return nil; + } + + // `hitSubview` is the topmost subview which was hit. The hit point can + // be outside the bounds of `view` (e.g., if -clipsToBounds is NO). + NSView *hitSubview = nil; + BOOL isPointInside = [self pointInside:point]; + BOOL needsHitSubview = !(_pointerEvents == RCTPointerEventsNone || _pointerEvents == RCTPointerEventsBoxOnly); + if (needsHitSubview && (![self clipsToBounds] || isPointInside)) { + // Take z-index into account when calculating the touch target. + NSArray *sortedSubviews = [self reactZIndexSortedSubviews]; + + // The default behaviour of UIKit is that if a view does not contain a point, + // then no subviews will be returned from hit testing, even if they contain + // the hit point. By doing hit testing directly on the subviews, we bypass + // the strict containment policy (i.e., UIKit guarantees that every ancestor + // of the hit view will return YES from -pointInside:withEvent:). See: + // - https://developer.apple.com/library/ios/qa/qa2013/qa1812.html + for (NSView *subview in [sortedSubviews reverseObjectEnumerator]) { + CGPoint convertedPoint = [subview convertPoint:point fromView:self]; + hitSubview = [subview hitTest:convertedPoint]; + if (hitSubview != nil) { + break; + } + } + } + + NSView *hitView = (isPointInside ? self : nil); + return hitSubview ?: hitView; + + // TODO: implement "pointerEvents" // switch (_pointerEvents) { // case RCTPointerEventsNone: // return nil; // case RCTPointerEventsUnspecified: -// return RCTViewHitTest(self, point, event) ?: [super hitTest:point withEvent:event]; +// return hitSubview ?: hitView; // case RCTPointerEventsBoxOnly: -// return [super hitTest:point withEvent:event] ? self: nil; +// return hitView; // case RCTPointerEventsBoxNone: -// return RCTViewHitTest(self, point, event); +// return hitSubview; // default: -// RCTLogError(@"Invalid pointer-events specified %zd on %@", _pointerEvents, self); -// return [super hitTest:point withEvent:event]; +// RCTLogError(@"Invalid pointer-events specified %lld on %@", (long long)_pointerEvents, self); +// return hitSubview ?: hitView; // } - return [super hitTest:point]; +} + +static inline CGRect NSEdgeInsetsInsetRect(CGRect rect, NSEdgeInsets insets) { + rect.origin.x += insets.left; + rect.origin.y += insets.top; + rect.size.width -= (insets.left + insets.right); + rect.size.height -= (insets.top + insets.bottom); + return rect; +} + +- (BOOL)pointInside:(CGPoint)point +{ + CGRect hitFrame = NSEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets); + return CGRectContainsPoint(hitFrame, point); } - (NSView *)reactAccessibilityElement diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m index b172e7a330..3d79fdd9a5 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -223,6 +223,18 @@ - (void)checkLayerExists:(NSView *)view view.borderStyle = json ? [RCTConvert RCTBorderStyle:json] : defaultView.borderStyle; } } +RCT_CUSTOM_VIEW_PROPERTY(hitSlop, UIEdgeInsets, RCTView) +{ + if ([view respondsToSelector:@selector(setHitTestEdgeInsets:)]) { + if (json) { + NSEdgeInsets hitSlopInsets = [RCTConvert NSEdgeInsets:json]; + view.hitTestEdgeInsets = NSEdgeInsetsMake(-hitSlopInsets.top, -hitSlopInsets.left, -hitSlopInsets.bottom, -hitSlopInsets.right); + } else { + view.hitTestEdgeInsets = defaultView.hitTestEdgeInsets; + } + } +} + // RCT_EXPORT_VIEW_PROPERTY(onAccessibilityTap, RCTDirectEventBlock) // RCT_EXPORT_VIEW_PROPERTY(onMagicTap, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onDragEnter, RCTDirectEventBlock)