From 6fcc1e6b27525adc5e49c44854f70b1afb641ce1 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 16 Dec 2022 17:49:17 +0100 Subject: [PATCH 1/4] fix: scroll to TextInput onOpen --- package.json | 1 + src/index.js | 33 +++++++++++++++++++++++++++++++++ yarn.lock | 8 ++++---- 3 files changed, 38 insertions(+), 4 deletions(-) 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/index.js b/src/index.js index bcbc93e8..5861855d 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,11 @@ import PropTypes from 'prop-types'; 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; export default class RNPickerSelect extends PureComponent { static propTypes = { @@ -31,6 +36,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 +144,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 +222,37 @@ 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) { + this.props.scrollViewRef.current.scrollTo({ + y: textInputBottomY - modalY + this.props.scrollViewContentOffsetY, + }); + } + }); + } + triggerOpenCloseCallbacks() { const { onOpen, onClose } = this.props; const { showPicker } = this.state; if (!showPicker && onOpen) { onOpen(); + this.scrollToInput(); } if (showPicker && onClose) { 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" From 782a91238e2571cbebe390ed850a2536f87ff539 Mon Sep 17 00:00:00 2001 From: Jakub Trzebiatowski Date: Tue, 11 Apr 2023 19:46:05 +0200 Subject: [PATCH 2/4] Implement PickerAvoidingView and PickerStateProvider ...which provide tools for ensuring that a picker is not covered by its own modal on iOS. --- index.d.ts | 8 ++++++++ src/PickerAvoidingView/index.ios.js | 29 +++++++++++++++++++++++++++ src/PickerAvoidingView/index.js | 13 ++++++++++++ src/PickerStateProvider.js | 31 +++++++++++++++++++++++++++++ src/constants.js | 3 +++ src/index.js | 15 +++++++++----- 6 files changed, 94 insertions(+), 5 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..3deca7f8 --- /dev/null +++ b/src/PickerAvoidingView/index.ios.js @@ -0,0 +1,29 @@ +import React 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 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; + + return ( + + {props.children} + + ); +} diff --git a/src/PickerAvoidingView/index.js b/src/PickerAvoidingView/index.js new file mode 100644 index 00000000..e3563a3b --- /dev/null +++ b/src/PickerAvoidingView/index.js @@ -0,0 +1,13 @@ +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) { + 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..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 5861855d..99032cdd 100644 --- a/src/index.js +++ b/src/index.js @@ -5,12 +5,13 @@ 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'; export default class RNPickerSelect extends PureComponent { + static contextType = PickerStateContext; + static propTypes = { onValueChange: PropTypes.func.isRequired, items: PropTypes.arrayOf( @@ -275,6 +276,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( @@ -605,4 +610,4 @@ export default class RNPickerSelect extends PureComponent { } } -export { defaultStyles }; +export { defaultStyles, PickerStateProvider, PickerAvoidingView }; From 6c7122673e0b158cbbb6919e1683a0e08af3ec23 Mon Sep 17 00:00:00 2001 From: Jakub Trzebiatowski Date: Wed, 12 Apr 2023 21:03:18 +0200 Subject: [PATCH 3/4] PickerAvoidingView: Add delay ...to give a more visually pleasant effect. --- src/PickerAvoidingView/index.ios.js | 30 ++++++++++++++++++++++++----- src/constants.js | 3 +++ src/index.js | 24 +++++++++++++++++------ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/PickerAvoidingView/index.ios.js b/src/PickerAvoidingView/index.ios.js index 3deca7f8..5b50feb9 100644 --- a/src/PickerAvoidingView/index.ios.js +++ b/src/PickerAvoidingView/index.ios.js @@ -1,7 +1,12 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; -import { PickerStateContext } from "../PickerStateProvider"; -import { IOS_MODAL_HEIGHT } from "../constants"; +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 @@ -19,10 +24,25 @@ 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]); + return ( - + {props.children} ); diff --git a/src/constants.js b/src/constants.js index 0ebe16cc..b5383f67 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,3 +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 99032cdd..61c3cf00 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,21 @@ 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 { Dimensions } from 'react-native'; import { PickerAvoidingView } from './PickerAvoidingView'; import { PickerStateContext, PickerStateProvider } from './PickerStateProvider'; -import { IOS_MODAL_HEIGHT } from './constants'; +import { IOS_MODAL_ANIMATION_DURATION_MS, IOS_MODAL_HEIGHT } from './constants'; export default class RNPickerSelect extends PureComponent { static contextType = PickerStateContext; @@ -240,9 +248,13 @@ 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, - }); + // 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); } }); } From 45601e3dff41329bed4a87dcdd10da72b42c1e53 Mon Sep 17 00:00:00 2001 From: Jakub Trzebiatowski Date: Thu, 13 Apr 2023 20:06:12 +0200 Subject: [PATCH 4/4] PickerAvoidingView: Add enabled property --- src/PickerAvoidingView/index.ios.js | 20 +++++++++++--------- src/PickerAvoidingView/index.js | 6 ++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/PickerAvoidingView/index.ios.js b/src/PickerAvoidingView/index.ios.js index 5b50feb9..19eddcba 100644 --- a/src/PickerAvoidingView/index.ios.js +++ b/src/PickerAvoidingView/index.ios.js @@ -37,13 +37,15 @@ export function PickerAvoidingView(props) { } }, [isPickerOpen]); - return ( - - {props.children} - - ); + 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 index e3563a3b..9d7c02fc 100644 --- a/src/PickerAvoidingView/index.js +++ b/src/PickerAvoidingView/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import { View } from "react-native"; +import { View } from 'react-native'; /** * As, currently, only on iOS the picker's modal resembles the software keyboard @@ -9,5 +9,7 @@ import { View } from "react-native"; * within the PickerAvoidingView. */ export function PickerAvoidingView(props) { - return {props.children}; + // eslint-disable-next-line no-unused-vars + const { enabled, ...viewProps } = props; + return {props.children}; }