Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hitSlop prop on iOS and Android #5720

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
16 changes: 15 additions & 1 deletion Libraries/Components/Touchable/TouchableWithoutFeedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down 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
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be more obvious to the reader, something like ** NOTE ** or even "Experimental" would help here

* 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;
},

Choose a reason for hiding this comment

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

undefined This type is incompatible with object type

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
16 changes: 15 additions & 1 deletion 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 @@ -53,7 +54,7 @@ const AccessibilityComponentType = [

const forceTouchAvailable = (UIManager.RCTView.Constants &&
UIManager.RCTView.Constants.forceTouchAvailable) || false;

const statics = {
AccessibilityTraits,
AccessibilityComponentType,
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
Copy link
Contributor

Choose a reason for hiding this comment

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

same as above

* of sibling views always takes precedence if a touch hits two overlapping
* views.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you add a short example here showing how I would extend the area by n pixels in each direction? What's a reasonable number?

*/
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();

}
Loading