UNPKG

react-native-multicolumn-modal-picker

Version:

It is a highly customizable React Native component for the iOS platform. It allows you to configure up to 3 selection columns in the same picker. By default, it can be used as a simple selector.

777 lines (754 loc) 26.2 kB
import React, { useEffect, useMemo, useRef, useState } from "react"; import { Picker } from "@react-native-picker/picker"; import { View, Text, Modal, Pressable, Animated, Dimensions, Platform, TextInput, Appearance, TouchableOpacity, StatusBar, StyleSheet, } from "react-native"; import PropTypes from "prop-types"; /** * @param {Object} props * - Required Props: * - visible * - column1 * - selectedValue1 * - onValueChange1 * - onClose * @param {string} [props.acceptButtonText = 'Accept'] * - Text for the `“Accept”` button. * - The default value is `'Accept'`. * @param {object} [props.acceptButtonTextStyle] * - Style of the `“Accept”` button text. * - Accepts standard React Native style properties. * @param {('top'|'bottom'|'cancel'|'none')} [props.actionButtons = 'none'] * - Visibility and position of the `“Accept”` and `“Cancel”` action buttons. * - The default value is `'none'`. * @param {string} [props.actionButtonsBorderColor] * - Border color of the action buttons. * - It is only applicable when `actionButtons` is set to `'top` or `'bottom'`. * - Accepts standard React Native color values. * @param {string} [props.bgColor] * - Background color of modal. * - Accepts standard React Native color values. * @param {string} [props.cancelButtonBgColor] * - Background color of the `“Cancel”` button that appears at the bottom of the picker. * - Accepts standard React Native color values. * @param {string} [props.cancelButtonText = 'Cancel'] * - Text for the cancel button. * - The default value is `'Cancel'`. * @param {object} [props.cancelButtonTextStyle] * - Style of the `“Cancel”` button text. * - Accepts standard React Native style properties. * @param {Array} [props.column1] * - Data for the first column. * - `This is required.` * @param {Array} [props.column2] * - Data for the second column. * @param {Array} [props.column3] * - Data for the third column. * @param {number} [props.hPadding] * - Provides extra space around the picker, compensates horizontal space around selector columns. * @param {object} [props.itemStyle] * - Style of the picker items. * - Accepts standard React Native style properties. * @param {function} [props.onAccept] * - Callback function when `“Accept”` button is pressed. * @param {function} [props.onCancel] * - Callback function when `“Cancel”` button is pressed. * @param {function} props.onClose * - Callback function when modal is closed. * - `This is required.` * @param {function} props.onValueChange1 * - Callback function when value in first column changes. * - `This is required.` * @param {function} [props.onValueChange2] * - Callback function when value in second column changes. * @param {function} [props.onValueChange3] * - Callback function when value in third column changes. * @param {string} [props.rightInfo] * - Additional information to right side of modal. * @param {object} [props.rightInfoTextStyle] * - Style of text for additional information to right side of modal. * - Accepts standard React Native style properties. * @param {boolean} [props.searchBar = false] * - Whether to show search bar or not. * - The default value is `false`. * @param {object} [props.searchBoxStyle] * - Style of search box. * - Accepts standard React Native style properties. * @param {string} [props.searchPlaceholder = 'Search here'] * - Placeholder text for search box. * - The default value is `'Search here'`. * @param {string} [props.searchElementsColor = '#B7B7B7'] * - Color of placeholder text and clear button in the search box. * - Accepts standard React Native color values. * - The default value is `'#B7B7B7'`. * @param {object} [props.searchTextStyle] * - Style of search text. * - Accepts standard React Native style properties. * @param {(string|number)} props.selectedValue1 * - Selected value in first column. * - `This is required.` * @param {(string|number)} [props.selectedValue2] * - Selected value in second column. * - Can be a string or a number. * @param {(string|number)} [props.selectedValue3] * - Selected value in third column. * - Can be a string or a number. * @param {string} [props.selectionHighlightColor = '#555555'] * - Color of the selection highlight. * - Accepts standard React Native color values. * - The default value is `'#555555'`. * @param {string} [props.title] * - Title text for modal picker. * @param {object} [props.titleStyle] * - Style of the title text. * - Accepts standard React Native style properties. * @param {boolean} props.visible * - Whether modal picker is visible or not. * - `This is required.` */ const MultiColumnModalPicker = ({ acceptButtonText = "Accept", acceptButtonTextStyle, actionButtons = "none", actionButtonsBorderColor, bgColor, cancelButtonBgColor, cancelButtonText = "Cancel", cancelButtonTextStyle, column1, column2, column3, hPadding, itemStyle, onAccept, onCancel, onClose, onValueChange1, onValueChange2, onValueChange3, rightInfo, rightInfoTextStyle, searchBar = false, searchBoxStyle, searchElementsColor = "#B7B7B7", searchPlaceholder = "Search here", searchTextStyle, selectedValue1, selectedValue2, selectedValue3, selectionHighlightColor = "#555555", title, titleStyle, visible, ...rest }) => { if (Platform.OS === "ios") { const searchRef = useRef(); const windowHeight = Dimensions.get("window").height; const [onLayoutCalled, setOnLayoutCalled] = useState(false); const themeMode = Appearance.getColorScheme(); const animation = useRef(new Animated.Value(windowHeight)).current; const [pickerHeight, setPickerHeight] = useState(0); const [searchValue, setSearchValue] = useState(""); const [isSearchBarFocus, setIsSearchBarFocus] = useState(false); const [showSearchBar, setShowSearchBar] = useState(searchBar); let columns = [column1, column2, column3]; let selectedValues = [selectedValue1, selectedValue2, selectedValue3]; const [previousValue, setPreviousValue] = useState([null, null, null]); const unrecognizedProps = useMemo(() => { const props = Object.keys(rest); return props; }, [JSON.stringify(rest)]); useEffect(() => { if (__DEV__ && unrecognizedProps.length > 0) { const isPlural = unrecognizedProps.length > 1; console.warn( `The propert${isPlural ? "ies" : "y"} "${unrecognizedProps.join( ", " )}" ${ isPlural ? "are" : "is" } not recognized by MultiColumnModalPicker. \nTips: \n1. Check the spelling of the propert${ isPlural ? "ies" : "y" }. \n2. The following properties were removed in version 1.0.4 of react-native-multicolumn-modal-picker: \n ['acceptButtonTextColor', 'allItemsColor', 'cancelButtonTextColor', 'col1ItemsColor', 'col2ItemsColor', 'col3ItemsColor','rightInfoSize', 'rightInfoTextColor', 'showOnTop', 'titleComponent', 'theme'] \n3. For more information, check the documentation at: https://github.com/Rio9735/react-native-multicolumn-modal-picker` ); } }, [unrecognizedProps]); useEffect(() => { if (selectedValue1 === undefined) { console.warn('The prop "selectedValue1" is required'); } }, [selectedValue1]); useEffect(() => { if ( !searchBar || (column1 && column2) || (column2 && column3) || (column1 && column3) ) { setShowSearchBar(false); } else setShowSearchBar(true); }, [searchBar, column2, column3]); useEffect(() => { if (visible) { setPreviousValue([ selectedValue1 || null, selectedValue2 || null, selectedValue3 || null, ]); } }, [visible]); const onCloseHandle = () => { try { onClose(); } catch (e) { __DEV__ && console.error( "The onClose property is required in the react-native-multicolumn-modal-picker component. For more details, please refer to the documentation of react-native-multicolumn-modal-picker: https://github.com/Rio9735/react-native-multicolumn-modal-picker", e ); } }; const handleCancel = () => { showSearchBar && closeSearch(); onValueChange1(previousValue[0]); onValueChange2 && onValueChange2(previousValue[1]); onValueChange3 && onValueChange3(previousValue[2]); onCloseHandle(); onCancel && onCancel(); }; const handleAccept = () => { showSearchBar && closeSearch(); onAccept && onAccept(); onCloseHandle(); }; const renderPicker = () => { let onValueChanges = [onValueChange1, onValueChange2, onValueChange3]; return columns.map((column, index) => { if (!column) { return null; } return ( <Picker key={index} style={{ flex: 1 }} itemStyle={[ styles.itemText, { color: themeMode === "light" ? "#222222" : "#FFFFFF" }, itemStyle && itemStyle, ]} selectionColor="rgba(255,255,255,0)" selectedValue={selectedValues[index]} onValueChange={onValueChanges[index]} > {column.map((item, index) => ( <Picker.Item key={index} label={item.label} value={item.value} /> ))} </Picker> ); }); }; useEffect(() => { setOnLayoutCalled(false); if (isSearchBarFocus) { searchRef.current.blur(); } }, [title, searchBar, actionButtons]); const handleLayout = (event) => { if (pickerHeight === 0 || !onLayoutCalled) { const { height: newHeight } = event.nativeEvent.layout; setPickerHeight(newHeight); setOnLayoutCalled(true); } }; useEffect(() => { StatusBar.setHidden(isSearchBarFocus, "fade"); Animated.timing(animation, { toValue: isSearchBarFocus ? 10 : windowHeight - pickerHeight - 10, duration: isSearchBarFocus ? 250 : 350, useNativeDriver: true, }).start(); }, [isSearchBarFocus, pickerHeight]); const ignoreSpecialChars = (str) => { return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); }; const isMatch = (item, text) => { return ( ignoreSpecialChars(item.label.toLowerCase()).includes( ignoreSpecialChars(text.toLowerCase()) ) || item.value.includes(text) ); }; const sortResults = (a, b, text) => { return ( ignoreSpecialChars(a.label.toLowerCase()).indexOf( ignoreSpecialChars(text.toLowerCase()) ) - ignoreSpecialChars(b.label.toLowerCase()).indexOf( ignoreSpecialChars(text.toLowerCase()) ) ); }; const handleSearch = (text) => { try { setSearchValue(text); if (text === "") { onValueChange1(previousValue[0]); } else { const exactMatch = column1.find((item) => item.value === text); if (exactMatch) { onValueChange1(exactMatch.value); } else { const filteredData = column1.filter((item) => isMatch(item, text)); if (filteredData.length > 0) { filteredData.sort((a, b) => sortResults(a, b, text)); onValueChange1(filteredData[0].value); } else { onValueChange1(previousValue[0]); } } } } catch (error) { __DEV__ && console.error("An error occurred during the search: ", error); } }; const closeSearch = () => { searchRef.current?.blur && searchRef.current.blur(); setIsSearchBarFocus(false); setSearchValue(""); }; return ( <Modal animationType="slide" transparent={true} visible={visible} onRequestClose={handleCancel} > <Pressable style={{ flex: 1 }} onPress={handleAccept} /> <Animated.View onLayout={handleLayout} style={[ styles.modalMainContainer, { transform: [ { translateY: animation, }, ], }, ]} > <View style={[ styles.pickerWrapperStyle, { backgroundColor: themeMode === "light" ? "#F6F6F6" : "#333333", }, ]} > <View style={[ { borderRadius: 10, backgroundColor: bgColor ? bgColor : themeMode === "light" ? "#F6F6F6" : "#333333", }, ]} > {actionButtons === "top" && ( <View style={[ styles.buttonsContainer, { borderBottomColor: actionButtonsBorderColor ? actionButtonsBorderColor : themeMode === "light" ? "#BBBBBB" : "#717171", }, ]} > <TouchableOpacity style={{ flex: 1, paddingLeft: 20 }} onPress={handleCancel} > <Text style={[ styles.mainText, { color: themeMode === "light" ? "#555555" : "#FFFFFF", }, cancelButtonTextStyle && cancelButtonTextStyle, ]} > {cancelButtonText} </Text> </TouchableOpacity> <TouchableOpacity style={styles.acceptBtn} onPress={handleAccept} > <Text style={[ styles.mainText, { color: themeMode === "light" ? "#444444" : "#FFFFFF", }, acceptButtonTextStyle && acceptButtonTextStyle, ]} > {acceptButtonText} </Text> </TouchableOpacity> </View> )} {title && ( <View style={[ styles.titleView, { borderBottomColor: actionButtonsBorderColor ? actionButtonsBorderColor : themeMode === "light" ? "#BBBBBB" : "#717171", }, ]} > <Text style={[ styles.titleText, { color: themeMode === "light" ? "#888888" : "#B2B2B2" }, titleStyle && titleStyle, ]} > {title} </Text> </View> )} {showSearchBar && ( <View style={{ alignItems: "center", justifyContent: "center", }} > <View style={[ styles.searchBar, { backgroundColor: themeMode === "dark" ? "rgba(222,222,222,0.1)" : "rgba(1,1,1,0.1)", }, searchBoxStyle && searchBoxStyle, ]} > <TextInput style={[ styles.searchInput, { color: themeMode === "dark" ? "#FFFFFF" : "#222222", }, searchTextStyle && searchTextStyle, ]} ref={searchRef} maxLength={30} placeholderTextColor={searchElementsColor} placeholder={searchPlaceholder} onFocus={() => setIsSearchBarFocus(true)} spellCheck={false} value={searchValue} onChangeText={handleSearch} onBlur={closeSearch} /> {searchValue.length > 0 && ( <TouchableOpacity style={[ styles.searchClearBtn, { backgroundColor: searchElementsColor !== "#B7B7B7" ? searchElementsColor : themeMode === "dark" ? "rgba(255,255,255,0.5)" : "rgba(1,1,1,0.5)", }, ]} onPress={() => { setSearchValue(""); }} > <Text style={{ fontSize: 14, fontWeight: "800", color: bgColor ? bgColor : themeMode === "dark" ? "rgba(1,1,1,0.3)" : "rgba(255,255,255,0.3)", }} > X </Text> </TouchableOpacity> )} </View> </View> )} <View style={{ flexDirection: "row" }}> <View style={[ styles.selectionHighlight, { backgroundColor: selectionHighlightColor }, ]} /> {(hPadding || rightInfo) && ( <View style={ rightInfo ? hPadding !== null && rightInfo > 0 ? { minWidth: hPadding } : { flex: 1 } : { minWidth: hPadding } } /> )} {renderPicker()} {rightInfo && ( <View style={styles.rightInfoContainer}> <Text style={[ styles.rightInfoText, { color: themeMode === "light" ? "#444444" : "#B2B2B2", }, rightInfoTextStyle && rightInfoTextStyle, ]} > {rightInfo} </Text> </View> )} {hPadding > 0 && <View style={{ minWidth: hPadding }} />} </View> {actionButtons === "bottom" && ( <TouchableOpacity style={[ styles.buttonAlt, styles.acceptBtnAlt, { borderTopColor: actionButtonsBorderColor ? actionButtonsBorderColor : themeMode === "light" ? "#BBBBBB" : "#717171", }, ]} onPress={handleAccept} > <Text style={[ styles.mainText, { color: themeMode === "light" ? "#444444" : "#FFFFFF", }, acceptButtonTextStyle && acceptButtonTextStyle, ]} > {acceptButtonText} </Text> </TouchableOpacity> )} </View> </View> {(actionButtons === "cancel" || actionButtons === "bottom") && ( <View style={[ styles.buttonAltView, { backgroundColor: cancelButtonBgColor ? cancelButtonBgColor : themeMode === "light" ? "#F6F6F6" : "#333333", }, ]} > <TouchableOpacity style={styles.buttonAlt} onPress={handleCancel}> <Text style={[ styles.mainText, { color: "#D84D46", }, cancelButtonTextStyle && cancelButtonTextStyle, ]} > {cancelButtonText} </Text> </TouchableOpacity> </View> )} </Animated.View> </Modal> ); } else if (__DEV__) { console.warn( "Thank you for using react-native-multicolumn-modal-picker! Please note that it is currently only available for iOS. I am considering making it compatible with Android as well. Your opinion is important to me! If you find it useful and would like to see Android optimization, please vote here: [https://docs.google.com/forms/d/e/1FAIpQLSd3CWTSYT7DJcdCAMP_pE5IG8DrHc0QE2q42jden6JhgyP2PA/viewform?usp=sf_link]. Stay tuned for upcoming updates!" ); } }; MultiColumnModalPicker.propTypes = { acceptButtonText: PropTypes.string, acceptButtonTextStyle: PropTypes.object, actionButtons: PropTypes.oneOf(["top", "bottom", "cancel", "none"]), actionButtonsBorderColor: PropTypes.string, bgColor: PropTypes.string, cancelButtonBgColor: PropTypes.string, cancelButtonText: PropTypes.string, cancelButtonTextStyle: PropTypes.object, column1: PropTypes.array.isRequired, column2: PropTypes.array, column3: PropTypes.array, hPadding: PropTypes.number, itemStyle: PropTypes.object, onAccept: PropTypes.func, onCancel: PropTypes.func, onClose: PropTypes.func.isRequired, onValueChange1: PropTypes.func.isRequired, onValueChange2: PropTypes.func, onValueChange3: PropTypes.func, rightInfo: PropTypes.string, rightInfoTextStyle: PropTypes.object, searchBar: PropTypes.bool, searchBoxStyle: PropTypes.object, searchPlaceholder: PropTypes.string, searchElementsColor: PropTypes.string, searchTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), selectedValue1: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), selectedValue2: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), selectedValue3: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), selectionHighlightColor: PropTypes.string, title: PropTypes.string, titleStyle: PropTypes.object, visible: PropTypes.bool.isRequired, }; const styles = StyleSheet.create({ mainText: { fontSize: 18, }, titleText: { textAlign: "center", fontSize: 15, }, itemText: { fontSize: 18, }, rightInfoContainer: { minWidth: 100, justifyContent: "center", alignItems: "flex-end", paddingRight: 30, }, rightInfoText: { fontSize: 18, }, modalMainContainer: { position: "absolute", width: "100%", paddingHorizontal: 10, }, pickerWrapperStyle: { borderRadius: 12, shadowColor: "#000", shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.2, shadowRadius: 1, elevation: 1, }, buttonsContainer: { minHeight: 55, flexDirection: "row", alignItems: "center", borderBottomWidth: 0.5, }, acceptBtn: { flex: 1, paddingRight: 20, alignItems: "flex-end", }, buttonAltView: { minHeight: 55, borderRadius: 10, marginTop: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 1, elevation: 1, }, buttonAlt: { flex: 1, justifyContent: "center", alignItems: "center", }, acceptBtnAlt: { minHeight: 55, borderTopWidth: 0.5, }, titleView: { alignSelf: "center", paddingVertical: 5, paddingHorizontal: 20, borderBottomWidth: 0.5, }, searchBar: { flexDirection: "row", alignItems: "center", width: "90%", marginTop: 20, paddingHorizontal: 15, borderRadius: 10, }, searchInput: { flex: 1, minHeight: 40, fontSize: 18, }, searchClearBtn: { marginLeft: 5, minHeight: 20, minWidth: 20, alignItems: "center", justifyContent: "center", borderRadius: 100, }, selectionHighlight: { width: "94%", opacity: 0.3, minHeight: 40, top: "50%", left: "3%", transform: [{ translateY: -20 }], borderRadius: 10, position: "absolute", }, }); export default MultiColumnModalPicker;