diff --git a/index.d.ts b/index.d.ts index 9f111bc0..bf466e69 100644 --- a/index.d.ts +++ b/index.d.ts @@ -92,3 +92,11 @@ declare class Picker extends React.Component { } export default Picker; + +type PickerStateProviderProps = { + readonly children: React.ReactChild; +}; + +export const PickerStateProvider: React.ComponentType; + +export const PickerAvoidingView: React.ComponentType; diff --git a/package.json b/package.json index ddf97b89..25ef7b91 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "lodash.isequal": "^4.5.0" }, "devDependencies": { + "@react-native-picker/picker": ">=2.1.0", "@types/react-native": "^0.60.22", "babel-jest": "^23.6.0", "babel-preset-react-native": "^4.0.1", diff --git a/src/PickerAvoidingView/index.ios.js b/src/PickerAvoidingView/index.ios.js new file mode 100644 index 00000000..19eddcba --- /dev/null +++ b/src/PickerAvoidingView/index.ios.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { PickerStateContext } from '../PickerStateProvider'; +import { IOS_MODAL_ANIMATION_DURATION_MS, IOS_MODAL_HEIGHT } from '../constants'; + +function schedule(callback, timeout) { + const handle = setTimeout(callback, timeout); + return () => clearTimeout(handle); +} + +/** + * PickerAvoidingView is a React component that adjusts the view layout to avoid + * being covered by an open iOS UIPickerView modal. It's meant to be similar to + * the built-in KeyboardAvoidingView component, but specifically tailored for + * iOS picker modals. + * + * In order for this component to work correctly, all the pickers and the + * PickerAvoidingView should have a PickerStateProvider ancestor. + * + * @param {React.ReactNode} props.children - The child components that should be + * protected from obstruction by the picker modal + */ +export function PickerAvoidingView(props) { + const context = React.useContext(PickerStateContext); + const isPickerOpen = context && context.isPickerOpen; + + const [shouldAddSpace, setShouldAddSpace] = React.useState(false); + + React.useEffect(() => { + if (isPickerOpen) { + // Add a delay, as adding the padding before the modal fully expanded gives a visually unpleasant effect + return schedule(() => { + setShouldAddSpace(true); + }, IOS_MODAL_ANIMATION_DURATION_MS); + } else { + setShouldAddSpace(false); + } + }, [isPickerOpen]); + + const style = props.enabled + ? StyleSheet.compose(props.style, { + paddingBottom: shouldAddSpace ? IOS_MODAL_HEIGHT : 0, + }) + : props.style; + + return {props.children}; +} + +PickerAvoidingView.defaultProps = { + enabled: true, +}; diff --git a/src/PickerAvoidingView/index.js b/src/PickerAvoidingView/index.js new file mode 100644 index 00000000..9d7c02fc --- /dev/null +++ b/src/PickerAvoidingView/index.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { View } from 'react-native'; + +/** + * As, currently, only on iOS the picker's modal resembles the software keyboard + * in any way, the default implementation doesn't have any avoiding logic. + * + * @param {React.ReactNode} props.children - The child components to render + * within the PickerAvoidingView. + */ +export function PickerAvoidingView(props) { + // eslint-disable-next-line no-unused-vars + const { enabled, ...viewProps } = props; + return {props.children}; +} diff --git a/src/PickerStateProvider.js b/src/PickerStateProvider.js new file mode 100644 index 00000000..d46dca1d --- /dev/null +++ b/src/PickerStateProvider.js @@ -0,0 +1,31 @@ +import React from 'react'; + +/** + * @typedef {Object} PickerStateData + * @property {boolean} isPickerOpen - Indicates whether any picker is currently open + * + * PickerStateContext is a context that gives access to PickerStateData. + */ +export const PickerStateContext = React.createContext(); + +/** + * PickerStateProvider provides PickerStateContext and manages the necessary + * state. + * + * This component should be used as a single top-level provider for all picker + * instances in your application. + */ +export function PickerStateProvider(props) { + const [isPickerOpen, setIsPickerOpen] = React.useState(false); + + const context = { + isPickerOpen, + setIsPickerOpen, + }; + + return ( + + {props.children} + + ); +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..b5383f67 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,6 @@ +// Measuring the modal before rendering is not working reliably, so we need to hardcode the height +// This height was tested thoroughly on several iPhone models (iPhone SE, from iPhone 8 to 14 Pro, and 14 Pro Max) +export const IOS_MODAL_HEIGHT = 262; + +// An approximated duration of the modal opening +export const IOS_MODAL_ANIMATION_DURATION_MS = 500; diff --git a/src/index.js b/src/index.js index bcbc93e8..61c3cf00 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,25 @@ import React, { PureComponent } from 'react'; -import { Keyboard, Modal, Platform, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { + Dimensions, + Keyboard, + Modal, + Platform, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import PropTypes from 'prop-types'; import isEqual from 'lodash.isequal'; import { Picker } from '@react-native-picker/picker'; import { defaultStyles } from './styles'; +import { PickerAvoidingView } from './PickerAvoidingView'; +import { PickerStateContext, PickerStateProvider } from './PickerStateProvider'; +import { IOS_MODAL_ANIMATION_DURATION_MS, IOS_MODAL_HEIGHT } from './constants'; export default class RNPickerSelect extends PureComponent { + static contextType = PickerStateContext; + static propTypes = { onValueChange: PropTypes.func.isRequired, items: PropTypes.arrayOf( @@ -31,6 +45,8 @@ export default class RNPickerSelect extends PureComponent { onOpen: PropTypes.func, useNativeAndroidPickerStyle: PropTypes.bool, fixAndroidTouchableBug: PropTypes.bool, + scrollViewRef: PropTypes.any, + scrollViewContentOffsetY: PropTypes.number, // Custom Modal props (iOS only) doneText: PropTypes.string, @@ -137,6 +153,7 @@ export default class RNPickerSelect extends PureComponent { this.onValueChange = this.onValueChange.bind(this); this.onOrientationChange = this.onOrientationChange.bind(this); this.setInputRef = this.setInputRef.bind(this); + this.scrollToInput = this.scrollToInput.bind(this); this.togglePicker = this.togglePicker.bind(this); this.renderInputAccessoryView = this.renderInputAccessoryView.bind(this); } @@ -214,12 +231,41 @@ export default class RNPickerSelect extends PureComponent { return {}; } + scrollToInput() { + if ( + this.props.scrollViewRef == null || + this.props.scrollViewContentOffsetY == null || + this.inputRef == null + ) { + return; + } + + this.inputRef.measureInWindow((_x, y, _width, height) => { + // Bottom y-position of TextInput on screen + const textInputBottomY = y + height; + // Top y-position of picker modal on screen + const modalY = Dimensions.get('window').height - IOS_MODAL_HEIGHT; + + // If TextInput is below picker modal, scroll up + if (textInputBottomY > modalY) { + // Wait until the modal animation finishes, so the scrolling is effective when PickerAvoidingView is + // used + setTimeout(() => { + this.props.scrollViewRef.current.scrollTo({ + y: textInputBottomY - modalY + 10 + this.props.scrollViewContentOffsetY, + }); + }, IOS_MODAL_ANIMATION_DURATION_MS + 50); + } + }); + } + triggerOpenCloseCallbacks() { const { onOpen, onClose } = this.props; const { showPicker } = this.state; if (!showPicker && onOpen) { onOpen(); + this.scrollToInput(); } if (showPicker && onClose) { @@ -242,6 +288,10 @@ export default class RNPickerSelect extends PureComponent { const animationType = modalProps && modalProps.animationType ? modalProps.animationType : 'slide'; + if (this.context) { + this.context.setIsPickerOpen(!showPicker); + } + this.triggerOpenCloseCallbacks(); this.setState( @@ -572,4 +622,4 @@ export default class RNPickerSelect extends PureComponent { } } -export { defaultStyles }; +export { defaultStyles, PickerStateProvider, PickerAvoidingView }; diff --git a/yarn.lock b/yarn.lock index 57aff63b..8c6cfe18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -780,10 +780,10 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" -"@react-native-picker/picker@^1.8.3": - version "1.8.3" - resolved "https://registry.yarnpkg.com/@react-native-picker/picker/-/picker-1.8.3.tgz#fcbf969a4add749fc37ef064a5eb55eadc93db39" - integrity sha512-zfr8k9L5BJVN7fIrmrto1cCptZjkGoiKWeZTsCR+XormQnWj0Tqrv0S9Ni3SvdT5JZ2OAQ9H+edMRSUvrAxwQA== +"@react-native-picker/picker@>=2.1.0": + version "2.4.8" + resolved "https://registry.yarnpkg.com/@react-native-picker/picker/-/picker-2.4.8.tgz#a1a21f3d6ecadedbc3f0b691a444ddd7baa081f8" + integrity sha512-5NQ5XPo1B03YNqKFrV6h9L3CQaHlB80wd4ETHUEABRP2iLh7FHLVObX2GfziD+K/VJb8G4KZcZ23NFBFP1f7bg== "@types/minimatch@^3.0.3": version "3.0.3"