From 9c61fe58d3ddefdfa9782c4477fe13c94c911ba3 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 21 Mar 2017 23:51:42 -0700 Subject: [PATCH] [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. --- docs/components/TouchableWithoutFeedback.md | 4 +-- docs/components/View.md | 15 ++++++-- .../renderApplication-test.js.snap | 2 ++ src/components/Touchable/Touchable.js | 36 ++++++++++++++++++- .../Touchable/TouchableHighlight.js | 4 ++- src/components/Touchable/TouchableOpacity.js | 6 +++- .../Touchable/TouchableWithoutFeedback.js | 9 +++++ .../__snapshots__/index-test.js.snap | 33 +++++++++++++++++ src/components/View/__tests__/index-test.js | 14 ++++++++ src/components/View/index.js | 32 +++++++++++++++-- 10 files changed, 145 insertions(+), 10 deletions(-) diff --git a/docs/components/TouchableWithoutFeedback.md b/docs/components/TouchableWithoutFeedback.md index ec8bfdd9..da0db519 100644 --- a/docs/components/TouchableWithoutFeedback.md +++ b/docs/components/TouchableWithoutFeedback.md @@ -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 diff --git a/docs/components/View.md b/docs/components/View.md index 6ff383b0..da636484 100644 --- a/docs/components/View.md +++ b/docs/components/View.md @@ -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, diff --git a/src/apis/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap b/src/apis/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap index 865d2041..69e0b2f5 100644 --- a/src/apis/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap +++ b/src/apis/AppRegistry/__tests__/__snapshots__/renderApplication-test.js.snap @@ -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} " `; diff --git a/src/components/Touchable/Touchable.js b/src/components/Touchable/Touchable.js index b93b993f..9bdab464 100644 --- a/src/components/Touchable/Touchable.js +++ b/src/components/Touchable/Touchable.js @@ -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 ( + + ); + } + } }; module.exports = Touchable; diff --git a/src/components/Touchable/TouchableHighlight.js b/src/components/Touchable/TouchableHighlight.js index 1f063a50..79ed5dcc 100644 --- a/src/components/Touchable/TouchableHighlight.js +++ b/src/components/Touchable/TouchableHighlight.js @@ -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 })} ); } diff --git a/src/components/Touchable/TouchableOpacity.js b/src/components/Touchable/TouchableOpacity.js index 82fa4953..17ad3469 100644 --- a/src/components/Touchable/TouchableOpacity.js +++ b/src/components/Touchable/TouchableOpacity.js @@ -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 })} + ); } }); diff --git a/src/components/Touchable/TouchableWithoutFeedback.js b/src/components/Touchable/TouchableWithoutFeedback.js index 59509e93..34d842a8 100644 --- a/src/components/Touchable/TouchableWithoutFeedback.js +++ b/src/components/Touchable/TouchableWithoutFeedback.js @@ -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()) || '') ); + 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]; diff --git a/src/components/View/__tests__/__snapshots__/index-test.js.snap b/src/components/View/__tests__/__snapshots__/index-test.js.snap index b321c25c..9dd2ab44 100644 --- a/src/components/View/__tests__/__snapshots__/index-test.js.snap +++ b/src/components/View/__tests__/__snapshots__/index-test.js.snap @@ -11,6 +11,39 @@ exports[`components/View prop "children" 1`] = ` `; +exports[`components/View prop "hitSlop" handles partial offsets 1`] = ` +
+ +
+`; + +exports[`components/View prop "hitSlop" renders a span with negative position offsets 1`] = ` +
+ +
+`; + exports[`components/View prop "pointerEvents" 1`] = `
{ expect(component.toJSON()).toMatchSnapshot(); }); + describe('prop "hitSlop"', () => { + it('renders a span with negative position offsets', () => { + const component = renderer.create( + + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it('handles partial offsets', () => { + const component = renderer.create(); + expect(component.toJSON()).toMatchSnapshot(); + }); + }); + test('prop "pointerEvents"', () => { const component = renderer.create(); expect(component.toJSON()).toMatchSnapshot(); diff --git a/src/components/View/index.js b/src/components/View/index.js index 3ebc0611..d8f6e3ba 100644 --- a/src/components/View/index.js +++ b/src/components/View/index.js @@ -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 } });