Add hitSlop prop on iOS and Android
Summary:New prop `hitSlop` allows extending the touch area of Touchable components. This makes it easier to touch small buttons without needing to change your styles. It takes `top`, `bottom`, `left`, and `right` same as the `pressRetentionOffset` prop. When a touch is moved, `hitSlop` is combined with `pressRetentionOffset` to determine how far the touch can move off the button before deactivating the button. On Android I had to add a new file `ids.xml` to generate a unique ID to use for the tag where I store the `hitSlop` state. The iOS side is more straightforward. terribleben worked on the iOS and JS parts of this diff. Fixes #110 Closes https://github.com/facebook/react-native/pull/5720 Differential Revision: D2941671 Pulled By: androidtrunkagent fb-gh-sync-id: 07e3eb8b6a36eebf76968fdaac3c6ac335603194 shipit-source-id: 07e3eb8b6a36eebf76968fdaac3c6ac335603194
This commit is contained in:
parent
ecf6981093
commit
0176ac488e
|
@ -93,7 +93,14 @@ exports.examples = [
|
|||
return <ForceTouchExample />;
|
||||
},
|
||||
platform: 'ios',
|
||||
}];
|
||||
}, {
|
||||
title: 'Touchable Hit Slop',
|
||||
description: '<Touchable*> components accept hitSlop prop which extends the touch area ' +
|
||||
'without changing the view bounds.',
|
||||
render: function(): ReactElement {
|
||||
return <TouchableHitSlop />;
|
||||
},
|
||||
}];
|
||||
|
||||
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 (
|
||||
<View testID="touchable_hit_slop">
|
||||
<View style={[styles.row, {justifyContent: 'center'}]}>
|
||||
<TouchableOpacity
|
||||
onPress={this.onPress}
|
||||
style={styles.hitSlopWrapper}
|
||||
hitSlop={{top: 30, bottom: 30, left: 60, right: 60}}
|
||||
testID="touchable_hit_slop_button">
|
||||
<Text style={styles.hitSlopButton}>
|
||||
Press Outside This View
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.logBox}>
|
||||
<Text>
|
||||
{log}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -90,4 +90,9 @@
|
|||
*/
|
||||
@property (nonatomic, assign) RCTBorderStyle borderStyle;
|
||||
|
||||
/**
|
||||
* Insets used when hit testing inside this view.
|
||||
*/
|
||||
@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets;
|
||||
|
||||
@end
|
||||
|
|
|
@ -109,6 +109,7 @@ static NSString *RCTRecursiveAccessibilityLabel(UIView *view)
|
|||
_borderBottomLeftRadius = -1;
|
||||
_borderBottomRightRadius = -1;
|
||||
_borderStyle = RCTBorderStyleSolid;
|
||||
_hitTestEdgeInsets = UIEdgeInsetsZero;
|
||||
|
||||
_backgroundColor = super.backgroundColor;
|
||||
}
|
||||
|
@ -180,6 +181,15 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)
|
|||
}
|
||||
}
|
||||
|
||||
- (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) {
|
||||
|
|
|
@ -193,6 +193,17 @@ RCT_CUSTOM_VIEW_PROPERTY(borderStyle, RCTBorderStyle, RCTView)
|
|||
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)
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
}
|
|
@ -13,12 +13,14 @@ import javax.annotation.Nullable;
|
|||
|
||||
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 @@ public class TouchTargetHelper {
|
|||
}
|
||||
}
|
||||
return viewGroup;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the touch point is within the child View
|
||||
|
@ -144,12 +146,24 @@ public class TouchTargetHelper {
|
|||
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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ import android.view.ViewGroup;
|
|||
|
||||
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 @@ import com.facebook.react.uimanager.ReactPointerEventsView;
|
|||
* 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 class ReactViewGroup extends ViewGroup implements
|
|||
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 @@ public class ReactViewGroup extends ViewGroup implements
|
|||
return mReactBackgroundDrawable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Rect getHitSlopRect() {
|
||||
return mHitSlopRect;
|
||||
}
|
||||
|
||||
public void setHitSlopRect(@Nullable Rect rect) {
|
||||
mHitSlopRect = rect;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import javax.annotation.Nullable;
|
|||
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 class ReactViewManager extends ViewGroupManager<ReactViewGroup> {
|
|||
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) {
|
||||
|
|
Loading…
Reference in New Issue