[add] View 'hitSlop' shim

Shim the 'hitSlop' prop using a positioned element to extend the size of
a View's touch target without changing layout. Unlike the native
implementation, the touch target may extend past the parent view bounds.
This commit is contained in:
Nicolas Gallagher
2017-03-21 23:51:42 -07:00
parent 782125d169
commit 9c61fe58d3
10 changed files with 145 additions and 10 deletions

View File

@@ -45,9 +45,7 @@ If true, disable all interactions for this component.
**hitSlop**: `{top: number, left: number, bottom: number, right: number}`
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.
to `pressRetentionOffset` when moving off of the button.
**onLayout**: function

View File

@@ -18,8 +18,10 @@ NOTE: `View` will transfer all other props to the rendered HTML element.
**accessibilityLabel**: string
Defines the text available to assistive technologies upon interaction with the
element. (This is implemented using `aria-label`.)
Overrides the text that's read by the screen reader when the user interacts
with the element. By default, the label is constructed by traversing all the
children and accumulating all the `Text` nodes separated by space. (This is
implemented using `aria-label`.)
**accessibilityLiveRegion**: oneOf('assertive', 'off', 'polite') = 'off'
@@ -46,6 +48,15 @@ assistive technologies of a `role` value change.
When `false`, the view is hidden from assistive technologies. (This is
implemented using `aria-hidden`.)
**hitSlop**: {top: number, left: number, bottom: number, right: number}
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.
For example, 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}}`.
**onLayout**: function
Invoked on mount and layout changes with `{ nativeEvent: { layout: { x, y, width,

View File

@@ -53,5 +53,7 @@ input::-webkit-inner-spin-button,input::-webkit-outer-spin-button,input::-webkit
.rn-textAlign-1ttztb7{text-align:inherit}
.rn-textDecoration-bauka4{text-decoration:none}
.rn-appearance-30o5oe{-moz-appearance:none;-webkit-appearance:none;appearance:none}
.rn-zIndex-1lgpqti{z-index:0}
.rn-zIndex-1wyyakw{z-index:-1}
</style>"
`;

View File

@@ -14,6 +14,7 @@
/* @edit start */
const BoundingDimensions = require('./BoundingDimensions');
const normalizeColor = require('normalize-css-color');
const Position = require('./Position');
const React = require('react');
const TouchEventUtils = require('fbjs/lib/TouchEventUtils');
@@ -755,7 +756,40 @@ var TouchableMixin = {
};
var Touchable = {
Mixin: TouchableMixin
Mixin: TouchableMixin,
TOUCH_TARGET_DEBUG: false, // Highlights all touchable targets. Toggle with Inspector.
/**
* Renders a debugging overlay to visualize touch target with hitSlop (might not work on Android).
*/
renderDebugView: ({ color, hitSlop }) => {
if (process.env.NODE_ENV !== 'production') {
if (!Touchable.TOUCH_TARGET_DEBUG) {
return null;
}
const debugHitSlopStyle = {};
hitSlop = hitSlop || { top: 0, bottom: 0, left: 0, right: 0 };
for (const key in hitSlop) {
debugHitSlopStyle[key] = -hitSlop[key];
}
const hexColor = '#' + ('00000000' + normalizeColor(color).toString(16)).substr(-8);
return (
<View
pointerEvents="none"
style={{
position: 'absolute',
borderColor: hexColor.slice(0, -2) + '55', // More opaque
borderWidth: 1,
borderStyle: 'dashed',
backgroundColor: hexColor.slice(0, -2) + '0F', // Less opaque
...debugHitSlopStyle
}}
/>
);
}
}
};
module.exports = Touchable;

View File

@@ -231,6 +231,7 @@ var TouchableHighlight = React.createClass({
render: function() {
const {
children,
/* eslint-disable */
activeOpacity,
onHideUnderlay,
@@ -270,9 +271,10 @@ var TouchableHighlight = React.createClass({
style={[styles.root, this.props.disabled && styles.disabled, this.state.underlayStyle]}
tabIndex={this.props.disabled ? null : '0'}
>
{React.cloneElement(React.Children.only(this.props.children), {
{React.cloneElement(React.Children.only(children), {
ref: CHILD_REF
})}
{Touchable.renderDebugView({ color: 'green', hitSlop: this.props.hitSlop })}
</View>
);
}

View File

@@ -160,6 +160,7 @@ var TouchableOpacity = React.createClass({
render: function() {
const {
children,
/* eslint-disable */
activeOpacity,
focusedOpacity,
@@ -195,7 +196,10 @@ var TouchableOpacity = React.createClass({
onResponderRelease={this.touchableHandleResponderRelease}
onResponderTerminate={this.touchableHandleResponderTerminate}
tabIndex={this.props.disabled ? null : '0'}
/>
>
{children}
{Touchable.renderDebugView({ color: 'blue', hitSlop: this.props.hitSlop })}
</View>
);
}
});

View File

@@ -166,6 +166,15 @@ const TouchableWithoutFeedback = React.createClass({
'TouchableWithoutFeedback does not work well with Text children. Wrap children in a View instead. See ' +
((child._owner && child._owner.getName && child._owner.getName()) || '<unknown>')
);
if (
process.env.NODE_ENV !== 'production' &&
Touchable.TOUCH_TARGET_DEBUG &&
child.type &&
child.type.displayName === 'View'
) {
children = React.Children.toArray(children);
children.push(Touchable.renderDebugView({ color: 'red', hitSlop: this.props.hitSlop }));
}
const style = Touchable.TOUCH_TARGET_DEBUG && child.type && child.type.displayName === 'Text'
? [styles.root, this.props.disabled && styles.disabled, child.props.style, { color: 'red' }]
: [styles.root, this.props.disabled && styles.disabled, child.props.style];

View File

@@ -11,6 +11,39 @@ exports[`components/View prop "children" 1`] = `
</div>
`;
exports[`components/View prop "hitSlop" handles partial offsets 1`] = `
<div
className="rn-alignItems-1oszu61 rn-backgroundColor-wib322 rn-borderTopStyle-1efd50x rn-borderRightStyle-14skgim rn-borderBottomStyle-rull8r rn-borderLeftStyle-mm0ijv rn-borderTopWidth-13yce4e rn-borderRightWidth-fnigne rn-borderBottomWidth-ndvcnb rn-borderLeftWidth-gxnn5r rn-boxSizing-deolkf rn-color-homxoj rn-display-6koalj rn-flexShrink-1qe8dj5 rn-flexBasis-1mlwlqe rn-flexDirection-eqz5dr rn-font-1lw9tu2 rn-listStyle-1ebb2ja rn-marginTop-1mnahxq rn-marginRight-61z16t rn-marginBottom-p1pxzi rn-marginLeft-11wrixw rn-minHeight-ifefl9 rn-minWidth-bcqeeo rn-paddingTop-wk8lta rn-paddingRight-9aemit rn-paddingBottom-1mdbw0j rn-paddingLeft-gy4na3 rn-position-bnwqim rn-textAlign-1ttztb7 rn-textDecoration-bauka4 rn-zIndex-1lgpqti"
>
<span
className="rn-bottom-1p0dtai rn-left-1d2f490 rn-position-u8s1d rn-right-zchlnj rn-zIndex-1wyyakw"
style={
Object {
"top": "-10px",
}
}
/>
</div>
`;
exports[`components/View prop "hitSlop" renders a span with negative position offsets 1`] = `
<div
className="rn-alignItems-1oszu61 rn-backgroundColor-wib322 rn-borderTopStyle-1efd50x rn-borderRightStyle-14skgim rn-borderBottomStyle-rull8r rn-borderLeftStyle-mm0ijv rn-borderTopWidth-13yce4e rn-borderRightWidth-fnigne rn-borderBottomWidth-ndvcnb rn-borderLeftWidth-gxnn5r rn-boxSizing-deolkf rn-color-homxoj rn-display-6koalj rn-flexShrink-1qe8dj5 rn-flexBasis-1mlwlqe rn-flexDirection-eqz5dr rn-font-1lw9tu2 rn-listStyle-1ebb2ja rn-marginTop-1mnahxq rn-marginRight-61z16t rn-marginBottom-p1pxzi rn-marginLeft-11wrixw rn-minHeight-ifefl9 rn-minWidth-bcqeeo rn-paddingTop-wk8lta rn-paddingRight-9aemit rn-paddingBottom-1mdbw0j rn-paddingLeft-gy4na3 rn-position-bnwqim rn-textAlign-1ttztb7 rn-textDecoration-bauka4 rn-zIndex-1lgpqti"
>
<span
className="rn-position-u8s1d rn-zIndex-1wyyakw"
style={
Object {
"bottom": "-10px",
"left": "-5px",
"right": "-5px",
"top": "-10px",
}
}
/>
</div>
`;
exports[`components/View prop "pointerEvents" 1`] = `
<div
className="rn-alignItems-1oszu61 rn-backgroundColor-wib322 rn-borderTopStyle-1efd50x rn-borderRightStyle-14skgim rn-borderBottomStyle-rull8r rn-borderLeftStyle-mm0ijv rn-borderTopWidth-13yce4e rn-borderRightWidth-fnigne rn-borderBottomWidth-ndvcnb rn-borderLeftWidth-gxnn5r rn-boxSizing-deolkf rn-color-homxoj rn-display-6koalj rn-flexShrink-1qe8dj5 rn-flexBasis-1mlwlqe rn-flexDirection-eqz5dr rn-font-1lw9tu2 rn-listStyle-1ebb2ja rn-marginTop-1mnahxq rn-marginRight-61z16t rn-marginBottom-p1pxzi rn-marginLeft-11wrixw rn-minHeight-ifefl9 rn-minWidth-bcqeeo rn-paddingTop-wk8lta rn-paddingRight-9aemit rn-paddingBottom-1mdbw0j rn-paddingLeft-gy4na3 rn-pointerEvents-ah5dr5 rn-position-bnwqim rn-textAlign-1ttztb7 rn-textDecoration-bauka4"

View File

@@ -25,6 +25,20 @@ describe('components/View', () => {
expect(component.toJSON()).toMatchSnapshot();
});
describe('prop "hitSlop"', () => {
it('renders a span with negative position offsets', () => {
const component = renderer.create(
<View hitSlop={{ top: 10, bottom: 10, right: 5, left: 5 }} />
);
expect(component.toJSON()).toMatchSnapshot();
});
it('handles partial offsets', () => {
const component = renderer.create(<View hitSlop={{ top: 10 }} />);
expect(component.toJSON()).toMatchSnapshot();
});
});
test('prop "pointerEvents"', () => {
const component = renderer.create(<View pointerEvents="box-only" />);
expect(component.toJSON()).toMatchSnapshot();

View File

@@ -4,10 +4,21 @@ import createDOMElement from '../../modules/createDOMElement';
import getAccessibilityRole from '../../modules/getAccessibilityRole';
import StyleSheet from '../../apis/StyleSheet';
import ViewPropTypes from './ViewPropTypes';
import { Component, PropTypes } from 'react';
import React, { Component, PropTypes } from 'react';
const emptyObject = {};
const calculateHitSlopStyle = hitSlop => {
const hitStyle = {};
for (const prop in hitSlop) {
if (hitSlop.hasOwnProperty(prop)) {
const value = hitSlop[prop];
hitStyle[prop] = value > 0 ? (-1) * value : 0;
}
}
return hitStyle;
};
class View extends Component {
static displayName = 'View';
@@ -33,10 +44,10 @@ class View extends Component {
render() {
const {
hitSlop,
style,
/* eslint-disable */
collapsable,
hitSlop,
onAccessibilityTap,
onLayout,
onMagicTap,
@@ -50,6 +61,14 @@ class View extends Component {
otherProps.style = [styles.initial, isButton && styles.buttonOnly, style];
if (hitSlop) {
const hitSlopStyle = calculateHitSlopStyle(hitSlop);
const hitSlopChild = createDOMElement('span', { style: [styles.hitSlop, hitSlopStyle] });
otherProps.children = React.Children.toArray(otherProps.children);
otherProps.children.unshift(hitSlopChild);
otherProps.style.unshift(styles.hasHitSlop);
}
const component = isInAButtonView ? 'span' : 'div';
return createDOMElement(component, otherProps);
}
@@ -82,6 +101,15 @@ const styles = StyleSheet.create({
},
buttonOnly: {
appearance: 'none'
},
// this zIndex ordering positions the hitSlop above the View but behind
// its children
hasHitSlop: {
zIndex: 0
},
hitSlop: {
...StyleSheet.absoluteFillObject,
zIndex: -1
}
});