Skip to content

Commit

Permalink
Add hitSlop prop on iOS and Android
Browse files Browse the repository at this point in the history
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 #5720

Differential Revision: D2941671

Pulled By: androidtrunkagent

fb-gh-sync-id: 07e3eb8b6a36eebf76968fdaac3c6ac335603194
shipit-source-id: 07e3eb8b6a36eebf76968fdaac3c6ac335603194
  • Loading branch information
jesseruder authored and facebook-github-bot-7 committed Feb 17, 2016
1 parent ecf6981 commit 0176ac4
Show file tree
Hide file tree
Showing 15 changed files with 225 additions and 8 deletions.
58 changes: 57 additions & 1 deletion Examples/UIExplorer/TouchableExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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({
Expand All @@ -264,13 +313,20 @@ var styles = StyleSheet.create({
button: {
color: '#007AFF',
},
hitSlopButton: {
color: 'white',
},
wrapper: {
borderRadius: 8,
},
wrapperCustom: {
borderRadius: 8,
padding: 6,
},
hitSlopWrapper: {
backgroundColor: 'red',
marginVertical: 30,
},
logBox: {
padding: 20,
margin: 10,
Expand Down
10 changes: 10 additions & 0 deletions Libraries/Components/Touchable/Touchable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions Libraries/Components/Touchable/TouchableBounce.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
},
Expand All @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions Libraries/Components/Touchable/TouchableHighlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions Libraries/Components/Touchable/TouchableOpacity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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}
Expand Down
14 changes: 14 additions & 0 deletions Libraries/Components/Touchable/TouchableWithoutFeedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
},
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions Libraries/Components/View/View.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
'use strict';

const EdgeInsetsPropType = require('EdgeInsetsPropType');
const NativeMethodsMixin = require('NativeMethodsMixin');
const PropTypes = require('ReactPropTypes');
const React = require('React');
Expand Down Expand Up @@ -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
*
Expand Down
5 changes: 5 additions & 0 deletions React/Views/RCTView.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,9 @@
*/
@property (nonatomic, assign) RCTBorderStyle borderStyle;

/**
* Insets used when hit testing inside this view.
*/
@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets;

@end
10 changes: 10 additions & 0 deletions React/Views/RCTView.m
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_borderBottomLeftRadius = -1;
_borderBottomRightRadius = -1;
_borderStyle = RCTBorderStyleSolid;
_hitTestEdgeInsets = UIEdgeInsetsZero;

_backgroundColor = super.backgroundColor;
}
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions React/Views/RCTViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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();

}
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -118,7 +120,7 @@ private static View findTouchTargetView(float[] eventCoords, ViewGroup viewGroup
}
}
return viewGroup;
}
}

/**
* Returns whether the touch point is within the child View
Expand All @@ -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;
}


Expand Down
Loading

4 comments on commit 0176ac4

@ssssssssssss
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty cool.

@rclai
Copy link
Contributor

@rclai rclai commented on 0176ac4 Mar 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which version is this coming out for?

@aleclarson
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@tuneZola
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Please sign in to comment.