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 9a514e46b7a9ad..1f6f9ed8176e66 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 5312c1f87886e7..1108c21e0bee84 100755
--- a/Libraries/Components/Touchable/TouchableWithoutFeedback.js
+++ b/Libraries/Components/Touchable/TouchableWithoutFeedback.js
@@ -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 8dc69534192bd8..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');
@@ -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) {