mirror of
https://github.com/zhigang1992/react-native-web.git
synced 2026-03-29 00:38:18 +08:00
[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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>"
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user