Skip to content

Add PickerAvoidingView and PickerStateProvider for better iOS modal handling #507

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,11 @@ declare class Picker extends React.Component<PickerSelectProps> {
}

export default Picker;

type PickerStateProviderProps = {
readonly children: React.ReactChild;
};

export const PickerStateProvider: React.ComponentType<PickerStateProviderProps>;

export const PickerAvoidingView: React.ComponentType<React.PropsWithChildren>;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions src/PickerAvoidingView/index.ios.js
Original file line number Diff line number Diff line change
@@ -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 <View style={style}>{props.children}</View>;
}

PickerAvoidingView.defaultProps = {
enabled: true,
};
15 changes: 15 additions & 0 deletions src/PickerAvoidingView/index.js
Original file line number Diff line number Diff line change
@@ -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 <View {...viewProps}>{props.children}</View>;
}
31 changes: 31 additions & 0 deletions src/PickerStateProvider.js
Original file line number Diff line number Diff line change
@@ -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 (
<PickerStateContext.Provider value={context}>
{props.children}
</PickerStateContext.Provider>
);
}
6 changes: 6 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -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;
54 changes: 52 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -572,4 +622,4 @@ export default class RNPickerSelect extends PureComponent {
}
}

export { defaultStyles };
export { defaultStyles, PickerStateProvider, PickerAvoidingView };
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down