From 5dc57c34659a9572b8c5d25ee1d105696b636018 Mon Sep 17 00:00:00 2001 From: Jakub Trzebiatowski Date: Tue, 11 Apr 2023 19:46:05 +0200 Subject: [PATCH 1/2] Implement PickerAvoidingView and PickerStateProvider ...which provide tools for ensuring that a picker is not covered by its own modal on iOS. Call scrollToInput() only on iOS, basing on an observation that this method was designed to handle iOS modal specifically. --- index.d.ts | 8 ++++++ src/PickerAvoidingView/index.ios.js | 44 +++++++++++++++++++++++++++++ src/PickerAvoidingView/index.js | 15 ++++++++++ src/PickerStateProvider.js | 43 ++++++++++++++++++++++++++++ src/constants.js | 3 ++ src/index.js | 41 +++++++++++++++++++-------- 6 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/PickerAvoidingView/index.ios.js create mode 100644 src/PickerAvoidingView/index.js create mode 100644 src/PickerStateProvider.js create mode 100644 src/constants.js 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..68360798 --- /dev/null +++ b/src/PickerAvoidingView/index.ios.js @@ -0,0 +1,44 @@ +import React, { Component } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { PickerStateContext } from '../PickerStateProvider'; +import { IOS_MODAL_HEIGHT } from '../constants'; + +/** + * 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 defaultProps = { + enabled: true, + }; + + render() { + const { enabled, style, children, ...otherProps } = this.props; + return ( + + {(context) => { + const isModalShown = context && context.isModalShown; + const effectiveStyle = enabled + ? StyleSheet.compose(style, { + paddingBottom: isModalShown ? IOS_MODAL_HEIGHT : 0, + }) + : style; + + return ( + + {children} + + ); + }} + + ); + } +} 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..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(); + }} > Date: Tue, 11 Apr 2023 19:46:05 +0200 Subject: [PATCH 2/2] Apply PR suggestions #1 Code additions/edits --- src/PickerAvoidingView/index.ios.js | 14 ++++++++------ src/PickerAvoidingView/index.js | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/PickerAvoidingView/index.ios.js b/src/PickerAvoidingView/index.ios.js index 68360798..f31d143e 100644 --- a/src/PickerAvoidingView/index.ios.js +++ b/src/PickerAvoidingView/index.ios.js @@ -2,6 +2,7 @@ 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 @@ -16,12 +17,17 @@ import { IOS_MODAL_HEIGHT } from '../constants'; * 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, children, ...otherProps } = this.props; + const { enabled, style, ...viewProps } = this.props; + return ( {(context) => { @@ -32,11 +38,7 @@ export class PickerAvoidingView extends Component { }) : style; - return ( - - {children} - - ); + return ; }} ); diff --git a/src/PickerAvoidingView/index.js b/src/PickerAvoidingView/index.js index 9d7c02fc..c7c105cc 100644 --- a/src/PickerAvoidingView/index.js +++ b/src/PickerAvoidingView/index.js @@ -11,5 +11,5 @@ import { View } from 'react-native'; export function PickerAvoidingView(props) { // eslint-disable-next-line no-unused-vars const { enabled, ...viewProps } = props; - return {props.children}; + return ; }