Skip to content

Commit 97d02f9

Browse files
committed
feat: floating dropdown
1 parent 10b416e commit 97d02f9

File tree

6 files changed

+189
-79
lines changed

6 files changed

+189
-79
lines changed

example/src/Examples/DropdownExample.tsx

+29
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import {
55
IconButton,
66
useTheme,
77
Divider,
8+
Switch,
89
} from 'react-native-paper';
910
import { ScrollView, StyleSheet, View } from 'react-native';
1011

1112
const DropdownExample = () => {
1213
const theme = useTheme();
1314
const [selectedValue, setSelectedValue] = useState<number | null>(1);
15+
const [mode, setMode] = useState<'modal' | 'floating'>('modal');
1416

1517
return (
1618
<ScrollView style={{ backgroundColor: theme.colors.background }}>
@@ -104,6 +106,33 @@ const DropdownExample = () => {
104106
</Dropdown>
105107
</View>
106108
</List.Section>
109+
<List.Section>
110+
<List.Item
111+
title="Floating dropdown mode"
112+
description="Use floating dropdown instead of modal"
113+
right={() => (
114+
<Switch
115+
value={mode === 'floating'}
116+
onValueChange={() =>
117+
setMode(mode === 'modal' ? 'floating' : 'modal')
118+
}
119+
/>
120+
)}
121+
/>
122+
<View style={styles.dropdown}>
123+
<Dropdown mode={mode}>
124+
<Dropdown.Option value={1} label="Option 1" />
125+
<Dropdown.Option value={2} label="Option 2" />
126+
<Dropdown.Option value={3} label="Option 3" />
127+
<Dropdown.Option value={4} label="Option 4" />
128+
<Dropdown.Option value={5} label="Option 5" />
129+
<Dropdown.Option value={6} label="Option 6" />
130+
<Dropdown.Option value={7} label="Option 7" />
131+
<Dropdown.Option value={8} label="Option 8" />
132+
<Dropdown.Option value={9} label="Option 9" />
133+
</Dropdown>
134+
</View>
135+
</List.Section>
107136
<List.Section title="Empty dropdown">
108137
<View style={styles.dropdown}>
109138
<Dropdown />

src/components/Dropdown/Dropdown.tsx

+67-35
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { Platform, StyleSheet, View } from 'react-native';
2+
import { Dimensions, Platform, StyleSheet, View } from 'react-native';
33
import Surface from '../Surface';
44
import TouchableRipple from '../TouchableRipple/TouchableRipple';
55
import IconButton from '../IconButton';
@@ -9,8 +9,16 @@ import DropdownContent from './DropdownContent';
99
import HelperText from '../HelperText';
1010
import * as List from '../List/List';
1111
import Portal from '../Portal/Portal';
12+
import { APPROX_STATUSBAR_HEIGHT } from '../../constants';
1213

1314
type Props<T> = {
15+
/**
16+
* Extra padding to add at the top of header to account for translucent status bar.
17+
* This is automatically handled on iOS >= 11 including iPhone X using `SafeAreaView`.
18+
* If you are using Expo, we assume translucent status bar and set a height for status bar automatically.
19+
* Pass `0` or a custom value to disable the default behaviour, and customize the height.
20+
*/
21+
statusBarHeight?: number;
1422
/**
1523
* Placeholder label when there is no selected value
1624
*/
@@ -49,6 +57,11 @@ type Props<T> = {
4957
style: { color: string };
5058
key: React.Key;
5159
}) => React.ReactNode;
60+
/**
61+
* A dropdown can be either be rendered inside a modal or inside a floating menu
62+
* The default value is 'floating' if the platform is web and 'modal' otherwise
63+
*/
64+
mode?: 'modal' | 'floating';
5265
};
5366

5467
type OptionProps<T> = {
@@ -60,19 +73,22 @@ type OptionProps<T> = {
6073
const DEFAULT_EMPTY_DROPDOWN_LABEL = 'No available options';
6174
const DEFAULT_PLACEHOLDER_LABEL = 'Select an option';
6275
const DROPDOWN_NULL_OPTION_KEY = 'DROPDOWN_NULL_OPTION_KEY';
76+
const DEFAULT_MAX_HEIGHT = 350;
6377

6478
export type DropdownContextProps<T> = {
6579
closeMenu(): void;
6680
selectOption(option: OptionProps<T> | null): void;
67-
maxHeight?: number;
6881
required: boolean;
6982
emptyDropdownLabel: string;
7083
selectedValue?: T;
7184
dropdownCoordinates: {
72-
top: number;
85+
top?: number;
86+
bottom?: number;
7387
left: number;
7488
width: number;
89+
maxHeight?: number;
7590
};
91+
mode: 'modal' | 'floating';
7692
};
7793

7894
export type DropdownRefAttributes<T> = {
@@ -140,27 +156,40 @@ const Dropdown = React.forwardRef(function <T>(
140156
{
141157
children,
142158
emptyDropdownLabel = DEFAULT_EMPTY_DROPDOWN_LABEL,
143-
maxHeight,
159+
maxHeight = DEFAULT_MAX_HEIGHT,
144160
onSelect,
145161
placeholder = DEFAULT_PLACEHOLDER_LABEL,
146162
renderNoneOption,
147163
required = false,
148164
selectedValue,
165+
mode = Platform.select({ web: 'floating', default: 'modal' }),
166+
statusBarHeight = APPROX_STATUSBAR_HEIGHT,
149167
}: Props<T>,
150168
ref:
151169
| ((instance: DropdownRefAttributes<T> | null) => void)
152170
| React.MutableRefObject<DropdownRefAttributes<T> | null>
153171
| null
154172
) {
173+
const computedStatusBarHeight = Platform.select({
174+
android: statusBarHeight,
175+
default: 0,
176+
});
155177
const theme = useTheme();
156-
const rootViewRef = React.useRef<View>(null);
178+
const anchorRef = React.useRef<View>(null);
179+
const menuRef = React.useRef<View>(null);
157180
const [isMenuOpen, setMenuOpen] = React.useState(false);
158-
const [dropdownCoordinates, setCoordinates] = React.useState({
159-
top: 0,
181+
const [dropdownCoordinates, setMenuPosition] = React.useState<{
182+
top?: number;
183+
bottom?: number;
184+
left: number;
185+
width: number;
186+
maxHeight?: number;
187+
}>({
160188
left: 0,
161189
width: 0,
162190
});
163191
const [selected, setSelected] = React.useState<OptionProps<T> | null>(null);
192+
const windowHeight = Dimensions.get('window').height;
164193

165194
const toggleMenuOpen = () => {
166195
if (isMenuOpen) {
@@ -173,16 +202,19 @@ const Dropdown = React.forwardRef(function <T>(
173202
const closeMenu = () => setMenuOpen(false);
174203

175204
const openMenu = () => {
176-
if (Platform.OS === 'web') {
177-
rootViewRef.current?.measure((_x, _y, width, height, pageX, pageY) =>
178-
setCoordinates({
205+
if (mode === 'floating') {
206+
anchorRef.current?.measureInWindow((x, y, width, height) => {
207+
let top = y + height + computedStatusBarHeight;
208+
setMenuPosition({
209+
left: x,
179210
width: width,
180-
left: pageX,
181-
top: pageY + height,
182-
})
183-
);
211+
top: top,
212+
bottom: 48,
213+
maxHeight: Math.min(maxHeight, windowHeight - top),
214+
});
215+
});
184216
}
185-
setMenuOpen(true);
217+
requestAnimationFrame(() => setMenuOpen(true));
186218
};
187219

188220
const renderLabel = () => {
@@ -258,7 +290,7 @@ const Dropdown = React.forwardRef(function <T>(
258290
}));
259291

260292
return (
261-
<View ref={rootViewRef}>
293+
<View ref={anchorRef} collapsable={false}>
262294
<TouchableRipple borderless onPress={toggleMenuOpen}>
263295
<Surface
264296
style={[
@@ -274,28 +306,28 @@ const Dropdown = React.forwardRef(function <T>(
274306
<View style={styles.label}>{renderLabel()}</View>
275307
<IconButton
276308
style={styles.icon}
277-
icon="menu-down"
309+
icon={isMenuOpen && mode === 'floating' ? 'menu-up' : 'menu-down'}
278310
onPress={toggleMenuOpen}
279311
/>
280312
</Surface>
281313
</TouchableRipple>
282-
{isMenuOpen && (
283-
<Portal>
284-
<DropdownContext.Provider
285-
value={{
286-
selectedValue: selected?.value,
287-
maxHeight,
288-
dropdownCoordinates,
289-
emptyDropdownLabel,
290-
required,
291-
closeMenu,
292-
selectOption,
293-
}}
294-
>
295-
<DropdownContent>{renderOptions()}</DropdownContent>
296-
</DropdownContext.Provider>
297-
</Portal>
298-
)}
314+
<Portal>
315+
<DropdownContext.Provider
316+
value={{
317+
selectedValue: selected?.value,
318+
dropdownCoordinates,
319+
emptyDropdownLabel,
320+
required,
321+
closeMenu,
322+
selectOption,
323+
mode,
324+
}}
325+
>
326+
<DropdownContent visible={isMenuOpen}>
327+
<View ref={menuRef}>{renderOptions()}</View>
328+
</DropdownContent>
329+
</DropdownContext.Provider>
330+
</Portal>
299331
</View>
300332
);
301333
}) as (<T = any>(
@@ -306,7 +338,7 @@ const Dropdown = React.forwardRef(function <T>(
306338
const styles = StyleSheet.create({
307339
container: {
308340
flexDirection: 'row',
309-
borderWidth: 1,
341+
borderWidth: 1.2,
310342
alignItems: 'center',
311343
elevation: 1,
312344
},

src/components/Dropdown/DropdownContent.native.tsx

-20
This file was deleted.
+23-24
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,34 @@
1-
import Surface from '../Surface';
2-
import {
3-
ScrollView,
4-
StyleSheet,
5-
TouchableWithoutFeedback,
6-
View,
7-
} from 'react-native';
8-
import React, { useContext } from 'react';
1+
import React from 'react';
92
import { DropdownContext } from './Dropdown';
3+
import DropdownContentModal from './DropdownContentModal';
4+
import DropdownContentFloating from './DropdownContentFloating';
105

116
type Props = {
127
children: React.ReactNode;
8+
visible: boolean;
139
};
1410

15-
const DEFAULT_MAX_HEIGHT = 350;
16-
1711
const DropdownContent = (props: Props) => {
18-
const {
19-
closeMenu,
20-
dropdownCoordinates,
21-
maxHeight = DEFAULT_MAX_HEIGHT,
22-
} = useContext(DropdownContext);
12+
const context = React.useContext(DropdownContext);
13+
14+
if (!context) return null;
15+
16+
const { mode } = context;
2317

24-
return (
25-
<TouchableWithoutFeedback onPress={closeMenu}>
26-
<View style={[StyleSheet.absoluteFill]}>
27-
<Surface style={[dropdownCoordinates, { maxHeight }]}>
28-
<ScrollView>{props.children}</ScrollView>
29-
</Surface>
30-
</View>
31-
</TouchableWithoutFeedback>
32-
);
18+
switch (mode) {
19+
case 'floating':
20+
return (
21+
<DropdownContentFloating {...props}>
22+
{props.children}
23+
</DropdownContentFloating>
24+
);
25+
case 'modal':
26+
return (
27+
<DropdownContentModal {...props}>{props.children}</DropdownContentModal>
28+
);
29+
default:
30+
throw new Error('Unkown mode ' + mode);
31+
}
3332
};
3433

3534
export default DropdownContent;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Surface from '../Surface';
2+
import {
3+
ScrollView,
4+
StyleSheet,
5+
TouchableWithoutFeedback,
6+
View,
7+
} from 'react-native';
8+
import React from 'react';
9+
import { DropdownContext } from './Dropdown';
10+
import { withTheme } from '../../core/theming';
11+
12+
type Props = {
13+
children: React.ReactNode;
14+
visible: boolean;
15+
theme: ReactNativePaper.Theme;
16+
};
17+
18+
const DropdownContentFloating = ({ children, visible, theme }: Props) => {
19+
const { closeMenu, dropdownCoordinates } = React.useContext(DropdownContext);
20+
21+
if (!visible) return null;
22+
23+
return (
24+
<TouchableWithoutFeedback onPress={closeMenu}>
25+
<View style={[StyleSheet.absoluteFill]}>
26+
<Surface
27+
style={[
28+
dropdownCoordinates,
29+
styles.container,
30+
{
31+
borderBottomRightRadius: theme.roundness,
32+
borderBottomLeftRadius: theme.roundness,
33+
},
34+
]}
35+
>
36+
<ScrollView>{children}</ScrollView>
37+
</Surface>
38+
</View>
39+
</TouchableWithoutFeedback>
40+
);
41+
};
42+
43+
const styles = StyleSheet.create({
44+
container: {
45+
elevation: 8,
46+
},
47+
});
48+
49+
export default withTheme(DropdownContentFloating);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react';
2+
import { ScrollView } from 'react-native';
3+
import { DropdownContext } from './Dropdown';
4+
import Dialog from '../Dialog/Dialog';
5+
6+
type Props = {
7+
children: React.ReactNode;
8+
visible: boolean;
9+
};
10+
11+
const DropdownContentModal = ({ children, visible }: Props) => {
12+
const { closeMenu } = React.useContext(DropdownContext);
13+
14+
return (
15+
<Dialog visible={visible} onDismiss={closeMenu}>
16+
<ScrollView>{children}</ScrollView>
17+
</Dialog>
18+
);
19+
};
20+
21+
export default DropdownContentModal;

0 commit comments

Comments
 (0)