From 951c8bdf315dbd1f2423e4eefbe19775ceab052c Mon Sep 17 00:00:00 2001 From: Jesse Ruder Date: Tue, 2 Feb 2016 10:55:50 -0800 Subject: [PATCH] Add hitSlop prop on iOS and Android --- Examples/UIExplorer/TouchableExample.js | 58 ++++++++++++++++++- Libraries/Components/Touchable/Touchable.js | 10 ++++ .../Components/Touchable/TouchableBounce.js | 14 +++++ .../Touchable/TouchableHighlight.js | 5 ++ .../TouchableNativeFeedback.android.js | 5 ++ .../Components/Touchable/TouchableOpacity.js | 5 ++ .../Touchable/TouchableWithoutFeedback.js | 16 ++++- Libraries/Components/View/View.js | 16 ++++- React/Views/RCTView.h | 5 ++ React/Views/RCTView.m | 10 ++++ React/Views/RCTViewManager.m | 11 ++++ .../react/touch/ReactHitSlopView.java | 28 +++++++++ .../react/uimanager/TouchTargetHelper.java | 26 +++++++-- .../react/views/view/ReactViewGroup.java | 13 ++++- .../react/views/view/ReactViewManager.java | 15 +++++ 15 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/touch/ReactHitSlopView.java diff --git a/Examples/UIExplorer/TouchableExample.js b/Examples/UIExplorer/TouchableExample.js index 515a0c2b858c03..377a6ce48f9005 100644 --- a/Examples/UIExplorer/TouchableExample.js +++ b/Examples/UIExplorer/TouchableExample.js @@ -93,7 +93,14 @@ exports.examples = [ return ; }, platform: 'ios', -}]; +}, { + title: 'Touchable Hit Slop', + description: ' components accept hitSlop prop which extends the touch area ' + + 'without changing the view bounds.', + render: function(): ReactElement { + return ; + }, + }]; var TextOnPressBox = React.createClass({ getInitialState: function() { @@ -243,6 +250,48 @@ var ForceTouchExample = React.createClass({ }, }); +var TouchableHitSlop = React.createClass({ + getInitialState: function() { + return { + timesPressed: 0, + }; + }, + onPress: function() { + this.setState({ + timesPressed: this.state.timesPressed + 1, + }); + }, + render: function() { + var log = ''; + if (this.state.timesPressed > 1) { + log = this.state.timesPressed + 'x onPress'; + } else if (this.state.timesPressed > 0) { + log = 'onPress'; + } + + return ( + + + + + Press Outside This View + + + + + + {log} + + + + ); + } +}); + var heartImage = {uri: 'https://pbs.twimg.com/media/BlXBfT3CQAA6cVZ.png:small'}; var styles = StyleSheet.create({ @@ -264,6 +313,9 @@ var styles = StyleSheet.create({ button: { color: '#007AFF', }, + hitSlopButton: { + color: 'white', + }, wrapper: { borderRadius: 8, }, @@ -271,6 +323,10 @@ var styles = StyleSheet.create({ borderRadius: 8, padding: 6, }, + hitSlopWrapper: { + backgroundColor: 'red', + marginVertical: 30, + }, logBox: { padding: 20, margin: 10, diff --git a/Libraries/Components/Touchable/Touchable.js b/Libraries/Components/Touchable/Touchable.js index 9f457c5c043a2f..6622ce8ad5aea5 100644 --- a/Libraries/Components/Touchable/Touchable.js +++ b/Libraries/Components/Touchable/Touchable.js @@ -432,6 +432,16 @@ var TouchableMixin = { var pressExpandRight = pressRectOffset.right; var pressExpandBottom = pressRectOffset.bottom; + var hitSlop = this.touchableGetHitSlop ? + this.touchableGetHitSlop() : null; + + if (hitSlop) { + pressExpandLeft += hitSlop.left; + pressExpandTop += hitSlop.top; + pressExpandRight += hitSlop.right; + pressExpandBottom += hitSlop.bottom; + } + var touch = TouchEventUtils.extractSingleTouch(e.nativeEvent); var pageX = touch && touch.pageX; var pageY = touch && touch.pageY; diff --git a/Libraries/Components/Touchable/TouchableBounce.js b/Libraries/Components/Touchable/TouchableBounce.js index 1d7abcc71e8397..5ea367ceba5905 100644 --- a/Libraries/Components/Touchable/TouchableBounce.js +++ b/Libraries/Components/Touchable/TouchableBounce.js @@ -54,6 +54,15 @@ var TouchableBounce = React.createClass({ * is disabled. Ensure you pass in a constant to reduce memory allocations. */ pressRetentionOffset: EdgeInsetsPropType, + /** + * This defines how far your touch can start away from the button. This is + * added to `pressRetentionOffset` when moving off of the button. + * ** NOTE ** + * The touch area never extends past the parent view bounds and the Z-index + * of sibling views always takes precedence if a touch hits two overlapping + * views. + */ + hitSlop: EdgeInsetsPropType, }, getInitialState: function(): State { @@ -108,6 +117,10 @@ var TouchableBounce = React.createClass({ return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; }, + touchableGetHitSlop: function(): ?Object { + return this.props.hitSlop; + }, + touchableGetHighlightDelayMS: function(): number { return 0; }, @@ -121,6 +134,7 @@ var TouchableBounce = React.createClass({ accessibilityComponentType={this.props.accessibilityComponentType} accessibilityTraits={this.props.accessibilityTraits} testID={this.props.testID} + hitSlop={this.props.hitSlop} onStartShouldSetResponder={this.touchableHandleStartShouldSetResponder} onResponderTerminationRequest={this.touchableHandleResponderTerminationRequest} onResponderGrant={this.touchableHandleResponderGrant} diff --git a/Libraries/Components/Touchable/TouchableHighlight.js b/Libraries/Components/Touchable/TouchableHighlight.js index ca94126665d33a..b7deb6c7c8949a 100644 --- a/Libraries/Components/Touchable/TouchableHighlight.js +++ b/Libraries/Components/Touchable/TouchableHighlight.js @@ -176,6 +176,10 @@ var TouchableHighlight = React.createClass({ return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; }, + touchableGetHitSlop: function() { + return this.props.hitSlop; + }, + touchableGetHighlightDelayMS: function() { return this.props.delayPressIn; }, @@ -230,6 +234,7 @@ var TouchableHighlight = React.createClass({ ref={UNDERLAY_REF} style={this.state.underlayStyle} onLayout={this.props.onLayout} + hitSlop={this.props.hitSlop} onStartShouldSetResponder={this.touchableHandleStartShouldSetResponder} onResponderTerminationRequest={this.touchableHandleResponderTerminationRequest} onResponderGrant={this.touchableHandleResponderGrant} diff --git a/Libraries/Components/Touchable/TouchableNativeFeedback.android.js b/Libraries/Components/Touchable/TouchableNativeFeedback.android.js index 19439d4e5ff0ba..6d18182b853108 100644 --- a/Libraries/Components/Touchable/TouchableNativeFeedback.android.js +++ b/Libraries/Components/Touchable/TouchableNativeFeedback.android.js @@ -162,6 +162,10 @@ var TouchableNativeFeedback = React.createClass({ return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; }, + touchableGetHitSlop: function() { + return this.props.hitSlop; + }, + touchableGetHighlightDelayMS: function() { return this.props.delayPressIn; }, @@ -205,6 +209,7 @@ var TouchableNativeFeedback = React.createClass({ accessibilityTraits: this.props.accessibilityTraits, testID: this.props.testID, onLayout: this.props.onLayout, + hitSlop: this.props.hitSlop, onStartShouldSetResponder: this.touchableHandleStartShouldSetResponder, onResponderTerminationRequest: this.touchableHandleResponderTerminationRequest, onResponderGrant: this.touchableHandleResponderGrant, diff --git a/Libraries/Components/Touchable/TouchableOpacity.js b/Libraries/Components/Touchable/TouchableOpacity.js index 2cd0abefb4705e..29ceda28947ea1 100644 --- a/Libraries/Components/Touchable/TouchableOpacity.js +++ b/Libraries/Components/Touchable/TouchableOpacity.js @@ -124,6 +124,10 @@ var TouchableOpacity = React.createClass({ return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; }, + touchableGetHitSlop: function() { + return this.props.hitSlop; + }, + touchableGetHighlightDelayMS: function() { return this.props.delayPressIn || 0; }, @@ -160,6 +164,7 @@ var TouchableOpacity = React.createClass({ style={[this.props.style, {opacity: this.state.anim}]} testID={this.props.testID} onLayout={this.props.onLayout} + hitSlop={this.props.hitSlop} onStartShouldSetResponder={this.touchableHandleStartShouldSetResponder} onResponderTerminationRequest={this.touchableHandleResponderTerminationRequest} onResponderGrant={this.touchableHandleResponderGrant} diff --git a/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/Libraries/Components/Touchable/TouchableWithoutFeedback.js index 5a578a205a7c93..1108c21e0bee84 100755 --- a/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -29,7 +29,7 @@ var PRESS_RETENTION_OFFSET = {top: 20, left: 20, right: 20, bottom: 30}; * Do not use unless you have a very good reason. All the elements that * respond to press should have a visual feedback when touched. This is * one of the primary reason a "web" app doesn't feel "native". - * + * * > **NOTE**: TouchableWithoutFeedback supports only one child * > * > If you wish to have several child components, wrap them in a View. @@ -80,6 +80,15 @@ var TouchableWithoutFeedback = React.createClass({ * is disabled. Ensure you pass in a constant to reduce memory allocations. */ pressRetentionOffset: EdgeInsetsPropType, + /** + * This defines how far your touch can start away from the button. This is + * added to `pressRetentionOffset` when moving off of the button. + * ** NOTE ** + * The touch area never extends past the parent view bounds and the Z-index + * of sibling views always takes precedence if a touch hits two overlapping + * views. + */ + hitSlop: EdgeInsetsPropType, }, getInitialState: function() { @@ -118,6 +127,10 @@ var TouchableWithoutFeedback = React.createClass({ return this.props.pressRetentionOffset || PRESS_RETENTION_OFFSET; }, + touchableGetHitSlop: function(): ?Object { + return this.props.hitSlop; + }, + touchableGetHighlightDelayMS: function(): number { return this.props.delayPressIn || 0; }, @@ -140,6 +153,7 @@ var TouchableWithoutFeedback = React.createClass({ accessibilityTraits: this.props.accessibilityTraits, testID: this.props.testID, onLayout: this.props.onLayout, + hitSlop: this.props.hitSlop, onStartShouldSetResponder: this.touchableHandleStartShouldSetResponder, onResponderTerminationRequest: this.touchableHandleResponderTerminationRequest, onResponderGrant: this.touchableHandleResponderGrant, diff --git a/Libraries/Components/View/View.js b/Libraries/Components/View/View.js index a7f31f9a0ac820..20b9d9288344e3 100644 --- a/Libraries/Components/View/View.js +++ b/Libraries/Components/View/View.js @@ -11,6 +11,7 @@ */ 'use strict'; +const EdgeInsetsPropType = require('EdgeInsetsPropType'); const NativeMethodsMixin = require('NativeMethodsMixin'); const PropTypes = require('ReactPropTypes'); const React = require('React'); @@ -53,7 +54,7 @@ const AccessibilityComponentType = [ const forceTouchAvailable = (UIManager.RCTView.Constants && UIManager.RCTView.Constants.forceTouchAvailable) || false; - + const statics = { AccessibilityTraits, AccessibilityComponentType, @@ -201,6 +202,19 @@ const View = React.createClass({ onMoveShouldSetResponder: PropTypes.func, onMoveShouldSetResponderCapture: PropTypes.func, + /** + * This defines how far a touch event can start away from the view. + * Typical interface guidelines recommend touch targets that are at least + * 30 - 40 points/density-independent pixels. If a Touchable view has a + * height of 20 the touchable height can be extended to 40 with + * `hitSlop={{top: 10, bottom: 10, left: 0, right: 0}}` + * ** NOTE ** + * The touch area never extends past the parent view bounds and the Z-index + * of sibling views always takes precedence if a touch hits two overlapping + * views. + */ + hitSlop: EdgeInsetsPropType, + /** * Invoked on mount and layout changes with * diff --git a/React/Views/RCTView.h b/React/Views/RCTView.h index 3f312abd1d87f3..7dc5bf3187ad7d 100644 --- a/React/Views/RCTView.h +++ b/React/Views/RCTView.h @@ -90,4 +90,9 @@ */ @property (nonatomic, assign) RCTBorderStyle borderStyle; +/** + * Insets used when hit testing inside this view. + */ +@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets; + @end diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index 010c62c492933a..ba0aa82a4337ee 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -109,6 +109,7 @@ - (instancetype)initWithFrame:(CGRect)frame _borderBottomLeftRadius = -1; _borderBottomRightRadius = -1; _borderStyle = RCTBorderStyleSolid; + _hitTestEdgeInsets = UIEdgeInsetsZero; _backgroundColor = super.backgroundColor; } @@ -180,6 +181,15 @@ - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event } } +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event +{ + if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero)) { + return [super pointInside:point withEvent:event]; + } + CGRect hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets); + return CGRectContainsPoint(hitFrame, point); +} + - (BOOL)accessibilityActivate { if (_onAccessibilityTap) { diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m index 5c5cb8f7c2d406..4bb2d7981d7875 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -193,6 +193,17 @@ - (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(__unused NSDictio view.borderStyle = json ? [RCTConvert RCTBorderStyle:json] : defaultView.borderStyle; } } +RCT_CUSTOM_VIEW_PROPERTY(hitSlop, UIEdgeInsets, RCTView) +{ + if ([view respondsToSelector:@selector(setHitTestEdgeInsets:)]) { + if (json) { + UIEdgeInsets hitSlopInsets = [RCTConvert UIEdgeInsets:json]; + view.hitTestEdgeInsets = UIEdgeInsetsMake(-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) diff --git a/ReactAndroid/src/main/java/com/facebook/react/touch/ReactHitSlopView.java b/ReactAndroid/src/main/java/com/facebook/react/touch/ReactHitSlopView.java new file mode 100644 index 00000000000000..adda78ab020cfc --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/touch/ReactHitSlopView.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.touch; + +import android.graphics.Rect; + +import javax.annotation.Nullable; + +/** + * This interface should be implemented by all {@link View} subclasses that want to use the + * hitSlop prop to extend their touch areas. + */ +public interface ReactHitSlopView { + + /** + * Called when determining the touch area of a view. + * @return A {@link Rect} representing how far to extend the touch area in each direction. + */ + public @Nullable Rect getHitSlopRect(); + +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java index 9635d5bf6f1f10..e998110b7bdc05 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java @@ -13,12 +13,14 @@ import android.graphics.Matrix; import android.graphics.PointF; +import android.graphics.Rect; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.touch.ReactHitSlopView; /** * Class responsible for identifying which react view should handle a given {@link MotionEvent}. @@ -118,7 +120,7 @@ private static View findTouchTargetView(float[] eventCoords, ViewGroup viewGroup } } return viewGroup; -} + } /** * Returns whether the touch point is within the child View @@ -144,12 +146,24 @@ private static boolean isTransformedTouchPointInView( localX = localXY[0]; localY = localXY[1]; } - if ((localX >= 0 && localX < (child.getRight() - child.getLeft())) - && (localY >= 0 && localY < (child.getBottom() - child.getTop()))) { - outLocalPoint.set(localX, localY); - return true; + if (child instanceof ReactHitSlopView && ((ReactHitSlopView) child).getHitSlopRect() != null) { + Rect hitSlopRect = ((ReactHitSlopView) child).getHitSlopRect(); + if ((localX >= -hitSlopRect.left && localX < (child.getRight() - child.getLeft()) + hitSlopRect.right) + && (localY >= -hitSlopRect.top && localY < (child.getBottom() - child.getTop()) + hitSlopRect.bottom)) { + outLocalPoint.set(localX, localY); + return true; + } + + return false; + } else { + if ((localX >= 0 && localX < (child.getRight() - child.getLeft())) + && (localY >= 0 && localY < (child.getBottom() - child.getTop()))) { + outLocalPoint.set(localX, localY); + return true; + } + + return false; } - return false; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java index bf57e47a65543d..fcd2fd5eb6f72c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java @@ -23,6 +23,7 @@ import com.facebook.infer.annotation.Assertions; import com.facebook.react.common.annotations.VisibleForTesting; +import com.facebook.react.touch.ReactHitSlopView; import com.facebook.react.touch.ReactInterceptingViewGroup; import com.facebook.react.touch.OnInterceptTouchEventListener; import com.facebook.react.uimanager.MeasureSpecAssertions; @@ -34,7 +35,7 @@ * initializes most of the storage needed for them. */ public class ReactViewGroup extends ViewGroup implements - ReactInterceptingViewGroup, ReactClippingViewGroup, ReactPointerEventsView { + ReactInterceptingViewGroup, ReactClippingViewGroup, ReactPointerEventsView, ReactHitSlopView { private static final int ARRAY_CAPACITY_INCREMENT = 12; private static final int DEFAULT_BACKGROUND_COLOR = Color.TRANSPARENT; @@ -87,6 +88,7 @@ public void onLayoutChange( private @Nullable View[] mAllChildren = null; private int mAllChildrenCount; private @Nullable Rect mClippingRect; + private @Nullable Rect mHitSlopRect; private PointerEvents mPointerEvents = PointerEvents.AUTO; private @Nullable ChildrenLayoutChangeListener mChildrenLayoutChangeListener; private @Nullable ReactViewBackgroundDrawable mReactBackgroundDrawable; @@ -513,4 +515,13 @@ private ReactViewBackgroundDrawable getOrCreateReactViewBackground() { return mReactBackgroundDrawable; } + @Override + public @Nullable Rect getHitSlopRect() { + return mHitSlopRect; + } + + public void setHitSlopRect(@Nullable Rect rect) { + mHitSlopRect = rect; + } + } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java index 789b294e1f9425..6a4bd5d264914b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java @@ -14,6 +14,7 @@ import java.util.Locale; import java.util.Map; +import android.graphics.Rect; import android.os.Build; import android.view.View; @@ -75,6 +76,20 @@ public void setBorderStyle(ReactViewGroup view, @Nullable String borderStyle) { view.setBorderStyle(borderStyle); } + @ReactProp(name = "hitSlop") + public void setHitSlop(final ReactViewGroup view, @Nullable ReadableMap hitSlop) { + if (hitSlop == null) { + view.setHitSlopRect(null); + } else { + view.setHitSlopRect(new Rect( + (int) PixelUtil.toPixelFromDIP(hitSlop.getDouble("left")), + (int) PixelUtil.toPixelFromDIP(hitSlop.getDouble("top")), + (int) PixelUtil.toPixelFromDIP(hitSlop.getDouble("right")), + (int) PixelUtil.toPixelFromDIP(hitSlop.getDouble("bottom")) + )); + } + } + @ReactProp(name = "pointerEvents") public void setPointerEvents(ReactViewGroup view, @Nullable String pointerEventsStr) { if (pointerEventsStr != null) {