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/src/PickerAvoidingView/index.ios.js b/src/PickerAvoidingView/index.ios.js new file mode 100644 index 00000000..f31d143e --- /dev/null +++ b/src/PickerAvoidingView/index.ios.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { PickerStateContext } from '../PickerStateProvider'; +import { IOS_MODAL_HEIGHT } from '../constants'; +import PropTypes from 'prop-types'; + +/** + * PickerAvoidingView is a React component that adjusts the view layout to avoid + * being covered by an open iOS picker 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 class PickerAvoidingView extends Component { + static propTypes = { + enabled: PropTypes.bool, + }; + + static defaultProps = { + enabled: true, + }; + + render() { + const { enabled, style, ...viewProps } = this.props; + + return ( + + {(context) => { + const isModalShown = context && context.isModalShown; + const effectiveStyle = enabled + ? StyleSheet.compose(style, { + paddingBottom: isModalShown ? IOS_MODAL_HEIGHT : 0, + }) + : style; + + return ; + }} + + ); + } +} diff --git a/src/PickerAvoidingView/index.js b/src/PickerAvoidingView/index.js new file mode 100644 index 00000000..c7c105cc --- /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 ; +} diff --git a/src/PickerStateProvider.js b/src/PickerStateProvider.js new file mode 100644 index 00000000..30bfe2d7 --- /dev/null +++ b/src/PickerStateProvider.js @@ -0,0 +1,43 @@ +import React from 'react'; + +/** + * @typedef {Object} PickerStateData + * @property {boolean} isModalOpen - Indicates whether a picker-related modal is + * currently being shown. Note that currently modal is opened for pickers only + * on iOS. + * + * 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 class PickerStateProvider extends React.Component { + constructor(props) { + super(props); + + this.state = { + isModalShown: false, + }; + } + + render() { + const context = { + isModalShown: this.state.isModalShown, + setIsModalShown: (isModalShown) => { + this.setState({ isModalShown }); + }, + }; + + return ( + + {this.props.children} + + ); + } +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..0ebe16cc --- /dev/null +++ b/src/constants.js @@ -0,0 +1,3 @@ +// 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; diff --git a/src/index.js b/src/index.js index 04bae2ac..8b5186e1 100644 --- a/src/index.js +++ b/src/index.js @@ -5,16 +5,17 @@ import isEqual from 'lodash.isequal'; import { Picker } from '@react-native-picker/picker'; import { defaultStyles } from './styles'; import { Dimensions } from 'react-native'; - -// 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 (from iPhone 8 to 14 Pro) -const IOS_MODAL_HEIGHT = 262; +import { PickerAvoidingView } from './PickerAvoidingView'; +import { PickerStateContext, PickerStateProvider } from './PickerStateProvider'; +import { IOS_MODAL_HEIGHT } from './constants'; const preserveSpaces = (label) => { return label.replace(/ /g, '\u00a0'); }; export default class RNPickerSelect extends PureComponent { + static contextType = PickerStateContext; + static propTypes = { onValueChange: PropTypes.func.isRequired, items: PropTypes.arrayOf( @@ -245,23 +246,34 @@ export default class RNPickerSelect extends PureComponent { // If TextInput is below picker modal, scroll up if (textInputBottomY > modalY) { this.props.scrollViewRef.current.scrollTo({ - y: textInputBottomY - modalY + this.props.scrollViewContentOffsetY, + // Add 10 pixels for a more visually pleasant effect + y: textInputBottomY + 10 - modalY + this.props.scrollViewContentOffsetY, }); } }); } - triggerOpenCloseCallbacks() { + triggerCallbacks() { const { onOpen, onClose } = this.props; const { showPicker } = this.state; if (!showPicker && onOpen) { onOpen(); - this.scrollToInput(); } - if (showPicker && onClose) { - onClose(); + if (showPicker) { + if (onClose) { + onClose(); + } + + // If the picker is currently shown, toggling it will start closing + // the modal on iOS. Let's handle this here, instead on relying on + // Modal's onDismiss, because onDismiss is fired _after_ the modal + // closing animation ends. PickerAvoidingView behaves better + // (visually) when it adjusts right after the modal closing starts. + if (this.context) { + this.context.setIsModalShown(false); + } } } @@ -291,7 +303,7 @@ export default class RNPickerSelect extends PureComponent { return; } - this.triggerOpenCloseCallbacks(); + this.triggerCallbacks(); if (Keyboard.isVisible()) { const keyboardListener = Keyboard.addListener('keyboardDidHide', () => { @@ -481,6 +493,13 @@ export default class RNPickerSelect extends PureComponent { supportedOrientations={['portrait', 'landscape']} onOrientationChange={this.onOrientationChange} {...modalProps} + onShow={() => { + if (this.context) { + this.context.setIsModalShown(true); + } + + this.scrollToInput(); + }} >