Skip to content

Commit 6c340fd

Browse files
committed
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.
1 parent dabfcd5 commit 6c340fd

File tree

6 files changed

+127
-7
lines changed

6 files changed

+127
-7
lines changed

index.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,11 @@ declare class Picker extends React.Component<PickerSelectProps> {
9292
}
9393

9494
export default Picker;
95+
96+
type PickerStateProviderProps = {
97+
readonly children: React.ReactChild;
98+
};
99+
100+
export const PickerStateProvider: React.ComponentType<PickerStateProviderProps>;
101+
102+
export const PickerAvoidingView: React.ComponentType<React.PropsWithChildren>;

src/PickerAvoidingView/index.ios.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
import { StyleSheet, View } from 'react-native';
3+
import { PickerStateContext } from '../PickerStateProvider';
4+
import { IOS_MODAL_HEIGHT } from '../constants';
5+
6+
/**
7+
* PickerAvoidingView is a React component that adjusts the view layout to avoid
8+
* being covered by an open iOS picker modal. It's meant to be similar to
9+
* the built-in KeyboardAvoidingView component, but specifically tailored for
10+
* iOS picker modals.
11+
*
12+
* In order for this component to work correctly, all the pickers and the
13+
* PickerAvoidingView should have a PickerStateProvider ancestor.
14+
*
15+
* @param {React.ReactNode} props.children - The child components that should be
16+
* protected from obstruction by the picker modal
17+
*/
18+
export function PickerAvoidingView(props) {
19+
const context = React.useContext(PickerStateContext);
20+
const isModalShown = context && context.isModalShown;
21+
22+
const style = props.enabled
23+
? StyleSheet.compose(props.style, {
24+
paddingBottom: isModalShown ? IOS_MODAL_HEIGHT : 0,
25+
})
26+
: props.style;
27+
28+
return <View style={style}>{props.children}</View>;
29+
}
30+
31+
PickerAvoidingView.defaultProps = {
32+
enabled: true,
33+
};

src/PickerAvoidingView/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from 'react';
2+
import { View } from 'react-native';
3+
4+
/**
5+
* As, currently, only on iOS the picker's modal resembles the software keyboard
6+
* in any way, the default implementation doesn't have any avoiding logic.
7+
*
8+
* @param {React.ReactNode} props.children - The child components to render
9+
* within the PickerAvoidingView.
10+
*/
11+
export function PickerAvoidingView(props) {
12+
// eslint-disable-next-line no-unused-vars
13+
const { enabled, ...viewProps } = props;
14+
return <View {...viewProps}>{props.children}</View>;
15+
}

src/PickerStateProvider.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react';
2+
3+
/**
4+
* @typedef {Object} PickerStateData
5+
* @property {boolean} isModalOpen - Indicates whether a picker-related modal is
6+
* currently being shown. Note that currently modal is opened for pickers only
7+
* on iOS.
8+
*
9+
* PickerStateContext is a context that gives access to PickerStateData.
10+
*/
11+
export const PickerStateContext = React.createContext();
12+
13+
/**
14+
* PickerStateProvider provides PickerStateContext and manages the necessary
15+
* state.
16+
*
17+
* This component should be used as a single top-level provider for all picker
18+
* instances in your application.
19+
*/
20+
export class PickerStateProvider extends React.Component {
21+
constructor(props) {
22+
super(props);
23+
24+
this.state = {
25+
isModalShown: false,
26+
};
27+
}
28+
29+
render() {
30+
const context = {
31+
isModalShown: this.state.isModalShown,
32+
setIsModalShown: (isModalShown) => {
33+
this.setState({ isModalShown });
34+
},
35+
};
36+
37+
return (
38+
<PickerStateContext.Provider value={context}>
39+
{this.props.children}
40+
</PickerStateContext.Provider>
41+
);
42+
}
43+
}

src/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Measuring the modal before rendering is not working reliably, so we need to hardcode the height
2+
// This height was tested thoroughly on several iPhone models (iPhone SE, from iPhone 8 to 14 Pro, and 14 Pro Max)
3+
export const IOS_MODAL_HEIGHT = 262;

src/index.js

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import isEqual from 'lodash.isequal';
55
import { Picker } from '@react-native-picker/picker';
66
import { defaultStyles } from './styles';
77
import { Dimensions } from 'react-native';
8-
9-
// Measuring the modal before rendering is not working reliably, so we need to hardcode the height
10-
// This height was tested thoroughly on several iPhone Models (from iPhone 8 to 14 Pro)
11-
const IOS_MODAL_HEIGHT = 262;
8+
import { PickerAvoidingView } from './PickerAvoidingView';
9+
import { PickerStateContext, PickerStateProvider } from './PickerStateProvider';
10+
import { IOS_MODAL_HEIGHT } from './constants';
1211

1312
const preserveSpaces = (label) => {
1413
return label.replace(/ /g, '\u00a0');
1514
};
1615

1716
export default class RNPickerSelect extends PureComponent {
17+
static contextType = PickerStateContext;
18+
1819
static propTypes = {
1920
onValueChange: PropTypes.func.isRequired,
2021
items: PropTypes.arrayOf(
@@ -245,7 +246,8 @@ export default class RNPickerSelect extends PureComponent {
245246
// If TextInput is below picker modal, scroll up
246247
if (textInputBottomY > modalY) {
247248
this.props.scrollViewRef.current.scrollTo({
248-
y: textInputBottomY - modalY + this.props.scrollViewContentOffsetY,
249+
// Add 10 pixels for a more visually pleasant effect
250+
y: textInputBottomY + 10 - modalY + this.props.scrollViewContentOffsetY,
249251
});
250252
}
251253
});
@@ -257,7 +259,6 @@ export default class RNPickerSelect extends PureComponent {
257259

258260
if (!showPicker && onOpen) {
259261
onOpen();
260-
this.scrollToInput();
261262
}
262263

263264
if (showPicker && onClose) {
@@ -286,11 +287,21 @@ export default class RNPickerSelect extends PureComponent {
286287

287288
togglePicker(animate = false, postToggleCallback) {
288289
const { disabled } = this.props;
290+
const { showPicker } = this.state;
289291

290292
if (disabled) {
291293
return;
292294
}
293295

296+
// If the picker is currently shown, toggling it will start closing the
297+
// modal on iOS. Let's handle this here, instead on relying on
298+
// Modal's onDismiss, because onDismiss is fired _after_ the modal
299+
// closing animation ends. PickerAvoidingView behaves better (visually)
300+
// when it adjusts right after the modal closing starts.
301+
if (showPicker && this.context) {
302+
this.context.setIsModalShown(false);
303+
}
304+
294305
this.triggerOpenCloseCallbacks();
295306

296307
if (Keyboard.isVisible()) {
@@ -481,6 +492,13 @@ export default class RNPickerSelect extends PureComponent {
481492
supportedOrientations={['portrait', 'landscape']}
482493
onOrientationChange={this.onOrientationChange}
483494
{...modalProps}
495+
onShow={() => {
496+
if (this.context) {
497+
this.context.setIsModalShown(true);
498+
}
499+
500+
this.scrollToInput();
501+
}}
484502
>
485503
<TouchableOpacity
486504
style={[defaultStyles.modalViewTop, style.modalViewTop]}
@@ -635,4 +653,4 @@ export default class RNPickerSelect extends PureComponent {
635653
}
636654
}
637655

638-
export { defaultStyles };
656+
export { defaultStyles, PickerStateProvider, PickerAvoidingView };

0 commit comments

Comments
 (0)