Skip to content

Commit 107b378

Browse files
authored
Merge pull request #8 from cubuspl42/picker-avoiding-1
Implement PickerAvoidingView and PickerStateProvider
2 parents dabfcd5 + 40c900d commit 107b378

File tree

6 files changed

+145
-11
lines changed

6 files changed

+145
-11
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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React, { Component } from 'react';
2+
import { StyleSheet, View } from 'react-native';
3+
import { PickerStateContext } from '../PickerStateProvider';
4+
import { IOS_MODAL_HEIGHT } from '../constants';
5+
import PropTypes from 'prop-types';
6+
7+
/**
8+
* PickerAvoidingView is a React component that adjusts the view layout to avoid
9+
* being covered by an open iOS picker modal. It's meant to be similar to
10+
* the built-in KeyboardAvoidingView component, but specifically tailored for
11+
* iOS picker modals.
12+
*
13+
* In order for this component to work correctly, all the pickers and the
14+
* PickerAvoidingView should have a PickerStateProvider ancestor.
15+
*
16+
* @param {React.ReactNode} props.children - The child components that should be
17+
* protected from obstruction by the picker modal
18+
*/
19+
export class PickerAvoidingView extends Component {
20+
static propTypes = {
21+
enabled: PropTypes.bool,
22+
};
23+
24+
static defaultProps = {
25+
enabled: true,
26+
};
27+
28+
render() {
29+
const { enabled, style, ...viewProps } = this.props;
30+
31+
return (
32+
<PickerStateContext.Consumer>
33+
{(context) => {
34+
const isModalShown = context && context.isModalShown;
35+
const effectiveStyle = enabled
36+
? StyleSheet.compose(style, {
37+
paddingBottom: isModalShown ? IOS_MODAL_HEIGHT : 0,
38+
})
39+
: style;
40+
41+
return <View style={effectiveStyle} {...viewProps} />;
42+
}}
43+
</PickerStateContext.Consumer>
44+
);
45+
}
46+
}

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} />;
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: 30 additions & 11 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,23 +246,34 @@ 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
});
252254
}
253255

254-
triggerOpenCloseCallbacks() {
256+
triggerCallbacks() {
255257
const { onOpen, onClose } = this.props;
256258
const { showPicker } = this.state;
257259

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

263-
if (showPicker && onClose) {
264-
onClose();
264+
if (showPicker) {
265+
if (onClose) {
266+
onClose();
267+
}
268+
269+
// If the picker is currently shown, toggling it will start closing
270+
// the modal on iOS. Let's handle this here, instead on relying on
271+
// Modal's onDismiss, because onDismiss is fired _after_ the modal
272+
// closing animation ends. PickerAvoidingView behaves better
273+
// (visually) when it adjusts right after the modal closing starts.
274+
if (this.context) {
275+
this.context.setIsModalShown(false);
276+
}
265277
}
266278
}
267279

@@ -291,7 +303,7 @@ export default class RNPickerSelect extends PureComponent {
291303
return;
292304
}
293305

294-
this.triggerOpenCloseCallbacks();
306+
this.triggerCallbacks();
295307

296308
if (Keyboard.isVisible()) {
297309
const keyboardListener = Keyboard.addListener('keyboardDidHide', () => {
@@ -481,6 +493,13 @@ export default class RNPickerSelect extends PureComponent {
481493
supportedOrientations={['portrait', 'landscape']}
482494
onOrientationChange={this.onOrientationChange}
483495
{...modalProps}
496+
onShow={() => {
497+
if (this.context) {
498+
this.context.setIsModalShown(true);
499+
}
500+
501+
this.scrollToInput();
502+
}}
484503
>
485504
<TouchableOpacity
486505
style={[defaultStyles.modalViewTop, style.modalViewTop]}
@@ -635,4 +654,4 @@ export default class RNPickerSelect extends PureComponent {
635654
}
636655
}
637656

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

0 commit comments

Comments
 (0)