Skip to content

Commit 41e5822

Browse files
authored
iOS Picker fixes (#1022)
* ios picker internal state inside Portal * fix ios picker colors
1 parent 9ede62f commit 41e5822

File tree

2 files changed

+186
-44
lines changed

2 files changed

+186
-44
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as React from "react";
2+
import { StyleSheet, Keyboard } from "react-native";
3+
import { SafeAreaView } from "react-native-safe-area-context";
4+
import { Picker as NativePickerComponent } from "@react-native-picker/picker";
5+
import Portal from "../Portal/Portal";
6+
import { Button } from "../Button";
7+
import { useDeepCompareMemo } from "../../utilities";
8+
import {
9+
CommonPickerProps,
10+
SinglePickerProps,
11+
normalizeToPickerOptions,
12+
PickerOption,
13+
} from "./PickerCommon";
14+
import PickerInputContainer from "./PickerInputContainer";
15+
import { ReadTheme, withTheme } from "@draftbit/theme";
16+
import { IconSlot } from "../../interfaces/Icon";
17+
18+
/**
19+
* Duplicated version of NativePicker.tsx for maintaining state inside the Portal container to avoid this issue
20+
* https://github.com/react-native-picker/picker/issues/615
21+
*/
22+
23+
interface PortalPickerContentProps extends IconSlot {
24+
value: string | number | undefined;
25+
options: PickerOption[];
26+
placeholder?: string;
27+
onValueChange?: (value: string | number) => void;
28+
onClose: () => void;
29+
theme: ReadTheme;
30+
autoDismissKeyboard?: boolean;
31+
}
32+
33+
const PortalPickerContent: React.FC<PortalPickerContentProps> = ({
34+
value,
35+
options,
36+
placeholder,
37+
onValueChange,
38+
onClose,
39+
Icon,
40+
theme,
41+
autoDismissKeyboard = true,
42+
}) => {
43+
const pickerRef = React.useRef<NativePickerComponent<string | number>>(null);
44+
45+
// Manage value state inside the Portal to avoid stale state issues across the Portal boundary
46+
const [internalValue, setInternalValue] = React.useState<
47+
string | number | undefined
48+
>(value);
49+
50+
React.useEffect(() => {
51+
setInternalValue(value);
52+
}, [value]);
53+
54+
React.useEffect(() => {
55+
if (autoDismissKeyboard) {
56+
Keyboard.dismiss();
57+
}
58+
}, [autoDismissKeyboard]);
59+
60+
return (
61+
<SafeAreaView
62+
style={[
63+
styles.iosPickerContent,
64+
{ backgroundColor: theme.colors.background.base },
65+
]}
66+
>
67+
<Button
68+
Icon={Icon}
69+
onPress={onClose}
70+
style={[styles.iosButton, { color: theme.colors.branding.primary }]}
71+
title="Close"
72+
/>
73+
<NativePickerComponent
74+
ref={pickerRef}
75+
testID="native-picker-component"
76+
selectedValue={internalValue}
77+
onValueChange={(newValue) => {
78+
setInternalValue(newValue);
79+
if (newValue !== placeholder) {
80+
onValueChange?.(newValue);
81+
} else if (newValue === placeholder) {
82+
onValueChange?.("");
83+
}
84+
}}
85+
style={[
86+
styles.iosNativePicker,
87+
{ backgroundColor: theme.colors.background.base },
88+
]}
89+
onBlur={onClose}
90+
>
91+
{options.map((option) => (
92+
<NativePickerComponent.Item
93+
testID="native-picker-item"
94+
label={option.label.toString()}
95+
value={option.value}
96+
key={option.value}
97+
color={theme.colors.text.strong}
98+
/>
99+
))}
100+
</NativePickerComponent>
101+
</SafeAreaView>
102+
);
103+
};
104+
105+
const NativePicker: React.FC<CommonPickerProps & SinglePickerProps> = ({
106+
options: optionsProp = [],
107+
onValueChange,
108+
Icon,
109+
placeholder,
110+
value,
111+
autoDismissKeyboard = true,
112+
theme,
113+
disabled,
114+
...rest
115+
}) => {
116+
const [pickerVisible, setPickerVisible] = React.useState(false);
117+
118+
const options = useDeepCompareMemo(() => {
119+
const normalizedOptions = normalizeToPickerOptions(optionsProp);
120+
121+
// Underlying Picker component defaults selection to first element when value is not provided (or undefined)
122+
// Placholder must be the 1st option in order to allow selection of the 'actual' 1st option
123+
if (placeholder) {
124+
return [{ label: placeholder, value: placeholder }, ...normalizedOptions];
125+
} else {
126+
return normalizedOptions;
127+
}
128+
}, [placeholder, optionsProp]);
129+
130+
// When no placeholder is provided then first item should be marked selected to reflect underlying Picker internal state
131+
if (!placeholder && options.length && !value && value !== options[0].value) {
132+
onValueChange?.(options[0].value);
133+
}
134+
135+
return (
136+
<PickerInputContainer
137+
testID="native-picker"
138+
Icon={Icon}
139+
placeholder={placeholder}
140+
selectedValue={value}
141+
options={options}
142+
onPress={() => setPickerVisible(!pickerVisible)}
143+
disabled={disabled}
144+
{...rest}
145+
>
146+
{pickerVisible && !disabled && (
147+
<Portal>
148+
<PortalPickerContent
149+
value={value}
150+
options={options}
151+
placeholder={placeholder}
152+
onValueChange={onValueChange}
153+
onClose={() => setPickerVisible(false)}
154+
Icon={Icon}
155+
theme={theme}
156+
autoDismissKeyboard={autoDismissKeyboard}
157+
/>
158+
</Portal>
159+
)}
160+
</PickerInputContainer>
161+
);
162+
};
163+
164+
const styles = StyleSheet.create({
165+
iosNativePicker: {
166+
backgroundColor: "white",
167+
},
168+
iosPickerContent: {
169+
width: "100%",
170+
position: "absolute",
171+
bottom: 0,
172+
backgroundColor: "white",
173+
},
174+
iosButton: {
175+
backgroundColor: "transparent",
176+
borderWidth: 0,
177+
},
178+
});
179+
180+
export default withTheme(NativePicker);

packages/core/src/components/Picker/NativePicker.tsx

Lines changed: 6 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import * as React from "react";
22
import { StyleSheet, Platform, Keyboard } from "react-native";
3-
import { SafeAreaView } from "react-native-safe-area-context";
43
import { Picker as NativePickerComponent } from "@react-native-picker/picker";
5-
import Portal from "../Portal/Portal";
6-
import { Button } from "../Button";
74
import { useDeepCompareMemo } from "../../utilities";
85
import {
96
CommonPickerProps,
@@ -13,7 +10,6 @@ import {
1310
import PickerInputContainer from "./PickerInputContainer";
1411
import { withTheme } from "@draftbit/theme";
1512

16-
const isIos = Platform.OS === "ios";
1713
const isAndroid = Platform.OS === "android";
1814
const isWeb = Platform.OS === "web";
1915

@@ -61,7 +57,7 @@ const NativePicker: React.FC<CommonPickerProps & SinglePickerProps> = ({
6157
onValueChange?.("");
6258
}
6359
}}
64-
style={isIos ? styles.iosNativePicker : styles.nativePicker}
60+
style={styles.nativePicker}
6561
onBlur={() => setPickerVisible(false)}
6662
>
6763
{options.map((option) => (
@@ -75,29 +71,6 @@ const NativePicker: React.FC<CommonPickerProps & SinglePickerProps> = ({
7571
</NativePickerComponent>
7672
);
7773

78-
const renderPicker = () => {
79-
if (isIos) {
80-
return (
81-
<Portal>
82-
<SafeAreaView style={styles.iosPickerContent}>
83-
<Button
84-
Icon={Icon}
85-
onPress={() => setPickerVisible(!pickerVisible)}
86-
style={[
87-
styles.iosButton,
88-
{ color: theme.colors.branding.primary },
89-
]}
90-
title="Close"
91-
/>
92-
{renderNativePicker()}
93-
</SafeAreaView>
94-
</Portal>
95-
);
96-
} else {
97-
return renderNativePicker();
98-
}
99-
};
100-
10174
React.useEffect(() => {
10275
if (pickerVisible && pickerRef.current) {
10376
pickerRef?.current?.focus();
@@ -123,7 +96,9 @@ const NativePicker: React.FC<CommonPickerProps & SinglePickerProps> = ({
12396
>
12497
{/* Web version is collapsed by default, always show to allow direct expand */}
12598
{/* Android version needs to always be visible to allow .focus() call to launch the dialog */}
126-
{(pickerVisible || isAndroid || isWeb) && !disabled && renderPicker()}
99+
{(pickerVisible || isAndroid || isWeb) &&
100+
!disabled &&
101+
renderNativePicker()}
127102
</PickerInputContainer>
128103
);
129104
};
@@ -141,26 +116,13 @@ const styles = StyleSheet.create({
141116
opacity: 0,
142117
...Platform.select({
143118
web: {
144-
height: "100%", //To have the <select/> element fill the height
119+
height: "100%",
145120
},
146121
android: {
147-
opacity: 0, // picker is a dialog, we don't want to show the default 'picker button' component
122+
opacity: 0,
148123
},
149124
}),
150125
},
151-
iosNativePicker: {
152-
backgroundColor: "white",
153-
},
154-
iosPickerContent: {
155-
width: "100%",
156-
position: "absolute",
157-
bottom: 0,
158-
backgroundColor: "white",
159-
},
160-
iosButton: {
161-
backgroundColor: "transparent",
162-
borderWidth: 0,
163-
},
164126
});
165127

166128
export default withTheme(NativePicker);

0 commit comments

Comments
 (0)