React-Native

React Native <Select> component with search field

10 May 2022
11 minutes read

Just a very flexible component for searching & selecting an item from a (potentially) long list of options.

There is no built in component in React Native, that properly enables selecting an item from a longer list. There are some 3rd-party solutions, but I found them either not really suitable for small mobile devices (for example producing hard to use dropdowns) or not flexible enough, so here is another take, including an (optional) live search.

Usage

import Select from 'components/Select'

<Select
  disabled={!isConnected}
  labelTextColor='black'
  name={'Select Country'}
  options={
    countries.map((
      country: {
        id: number,
        name: string,
        contractorName: string,
        flag: string
    }) => ({
      id: country.id,
      name: country.name,
      image: country.flag
    }))
  }
  onValueChange={(selectedId: number) => handleLanguageSwitching(selectedId)}
  selectedId={activeCountry.id}
  mainButtonStyle={{
    backgroundColor: 'transparent',
    borderColor: '#6c757d'
  }}
  mainButtonTextStyle={{color: 'black'}}
/>

Reference

Props

NameType
disabled?boolean
onValueChangeFunction
optionsoptions
selectedIdnumber
showSearch?boolean
labelTextColor?string
mainButtonTextStyle?TextStyle
mainButtonStyle?ViewStyle
namestring

Code

import React, { useState, useEffect } from 'react'
import { FlatList, Image, Input, Modal, Platform, Pressable, StyleSheet, Text, TextStyle, View, ViewStyle } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { FontAwesome } from '@expo/vector-icons'

type item = {
  id: number,
  name: string
}

type options = [
  item
]

export default function Select(props: {
  disabled?: boolean,
  onValueChange: Function,
  options: options,
  selectedId: number,
  showSearch?: boolean,
  labelTextColor?: 'string',
  mainButtonTextStyle?: TextStyle,
  mainButtonStyle?: ViewStyle,
  name: string
}) {
  const [selectedItemId, setSelectedItemId] = useState(props.selectedId)
  const [selectedItemText, setSelectedItemText] = useState('')
  const [modalVisible, setModalVisible] = useState(false)
  const [search, setSearch] = useState('')
  const [filteredOptions, setFilteredOptions] = useState(props.options)
  const showSearch = (props?.options.length > 15 || props.showSearch) && props.showSearch !== false
  const insets = useSafeAreaInsets()

  const setItem = (item: {id: number, name: string}) => {
    setModalVisible(false)
    setSelectedItemId(item.id)
    setSelectedItemText(item.name)
    setSearch('')

    // Return id and name
    props.onValueChange(item.id, item.name)
  }

  // Handle selectedId changes from parent
  useEffect(() => {
    setSelectedItemId(props.selectedId)

    // If item is preselected from parent, e.g. selectedId is defined
    if(props.options.length > 0 && props.selectedId > 0) {
      const selectedItem = props.options.find(item => item.id === props.selectedId)
      if(selectedItem?.name) {
        setSelectedItemText(selectedItem.name)
      }
    }
  }, [props.selectedId])

  // Handle options changes from parent
  // Set up with options data
  useEffect(() => {
    if(props.options) {
      setFilteredOptions(props.options)
      if(!props.selectedId || props.selectedId === 0) {
        setSelectedItemText('Please select …')
      }
    }
    // setSelectedItemText, if only one option exists
    if(props.options.length === 1) {
      setItem(props.options?.[0])
    }
  }, [props.options])

  // Handle search
  useEffect(() => {
    // Check if searched text is not blank
    if (search.length > 0) {
      // Inserted text is not blank
      // Filter the options and update FilteredDataSource
      const newData = props.options.filter((item: {name: string}) => {
        // Applying filter for the inserted text in search bar
        const itemData = item.name
          ? item.name.toUpperCase()
          : ''.toUpperCase()
        const textData = search.toUpperCase()
        return itemData.indexOf(textData) > -1
      })
      setFilteredOptions(newData)
    } else {
      // Inserted text is blank
      // Update FilteredDataSource with props.options
      setFilteredOptions(props.options)
    }
  }, [search])

  // We need to call this Component as a function, otherwise the search field loses focus on every keystroke
  const FlatListHeader = () => {
    return (
      <>
        { showSearch &&
          <>
            <SafeAreaView>
              <FontAwesome name='search' size={17} style={styles.searchIcon} />
              <Input
                autoCorrect={false}
                containerStyle={{marginTop: 0}}
                onChangeText={(text: string) => setSearch(text)}
                placeholder='Suchen …'
                style={styles.searchInput}
                underlineColorAndroid='transparent'
                value={search}
              />
              {/* clear button */}
              { (search.length > 0) ? (
                <Pressable
                  onPress={() => setSearch('')}
                  style={styles.searchClearButton}
                >
                  <FontAwesome name='times-circle' size={17} color='#999' />
                </Pressable>
              ) : null
              }
            </SafeAreaView>
            <FlatListSeparator />
          </>
        }
      </>
    )
  }

  type Item = {
    id: number
    name: string
    image?: string
  }

  // Single list item
  const FlatListItem = ({ item }: { item: Item }) => {
    const isActive = selectedItemId === item.id

    return (
      <Pressable
        onPress={() => setItem(item)}
        style={
          ({ pressed }) => ({
            opacity: pressed ? 0.5 : 1,
            ...styles.item,
            ...{backgroundColor: isActive ? '#c9e9e8' : 'transparent'},
            ...{paddingRight: insets.right},
            ...{paddingLeft: insets.left}
          })
        }
      >
        <Text style={styles.itemText}>
          {item.name}
        </Text>
        { item.image &&
          <Image
            style={styles.itemImage}
            resizeMode={'contain'}
            source={{ uri: item.image }}
          />
        }
      </Pressable>
    )
  }

  const FlatListSeparator = () => {
    return (
      <View
        style={{
          height: StyleSheet.hairlineWidth,
          width: '100%',
          backgroundColor: '#999',
        }}
      />
    )
  }

  const LabelAndButton = () => {
    return (
      <View style={styles.labelAndButton}>
        <Text
          style={{fontWeight: 'bold', color: props.labelTextColor}}
        >
          {props.name}
        </Text>
        <Pressable
          disabled={props.disabled}
          onPress={() => { setModalVisible(true) }}
          style={{
            ...styles.mainButton,
            ...{backgroundColor: props.disabled ? '#dcdde2' : 'white'},
            ...props.mainButtonStyle
          }}
        >
          <Text
            numberOfLines={1}
            style={{
              flex: 1,
              ...{color: props.disabled ? '#888' : 'black'},
              ...props.mainButtonTextStyle
            }}
          >
            {selectedItemText?.replace(/(\r\n|\n|\r)/gm, '')}
          </Text>
          <FontAwesome
            name='angle-down'
            size={17}
            style={{
              ...{color: props.disabled ? '#888' : 'black'},
              marginTop: 1,
              ...props.mainButtonTextStyle
            }}
          />
        </Pressable>
      </View>
    )
  }

  return (
    <>
      <Modal
        animationType='slide'
        hardwareAccelerated
        onRequestClose={() => setModalVisible(false)}
        presentationStyle='formSheet'
        statusBarTranslucent={true}
        supportedOrientations={['portrait', 'landscape']}
        transparent={false}
        visible={modalVisible}
      >
        <SafeAreaView style={styles.modalHeader}>
          <Pressable
            onPress={() => { setModalVisible(!modalVisible) }}
            style={{flex: 1}}
          >
            <Text style={styles.modalCloseText}>
              Fertig
            </Text>
          </Pressable>
          <Text style={{fontSize: 18, fontWeight: '800'}}>
            Bitte auswählen …
          </Text>
          {/* empty element needed for flex alignment */}
          <View style={{flex: 1}} />
        </SafeAreaView>
        <FlatList
          data={filteredOptions}
          keyExtractor={(item, index) => index.toString()}
          initialNumToRender={20}
          keyboardShouldPersistTaps='handled'
          ItemSeparatorComponent={FlatListSeparator}
          ListHeaderComponent={FlatListHeader()}
          ListFooterComponent={<View style={{height: insets.bottom}} />}
          renderItem={FlatListItem}
          style={{backgroundColor: '#f5f5f5', height: '100%'}}
        />
      </Modal>

      <LabelAndButton />
    </>
  )
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: 'white',
    flex: 1
  },
  labelAndButton: {
    marginTop: 15,
    marginLeft: 0,
    marginBottom: 7
  },
  mainButton: {
    justifyContent: 'space-between',
    flexDirection: 'row',
    fontFamily: 'Muli',
    fontSize: 15,
    textAlignVertical: 'top',
    marginTop: 8,
    paddingHorizontal: 10,
    paddingTop: 11,
    paddingBottom: 11,
    backgroundColor: 'white',
    borderColor: '#979baa',
    borderRadius: 8,
    borderWidth: 1
  },
  modalHeader: {
    flexDirection: 'row',
    paddingRight: 15,
    paddingVertical: 15,
    paddingLeft: 15,
    borderBottomColor: '#999',
    borderBottomWidth: StyleSheet.hairlineWidth,
  },
  modalCloseText: {
    color: '#27b6af',
    fontSize: 16,
    fontWeight: 'bold',
    marginTop: 2
  },
  searchIcon: {
    position: 'relative',
    top: Platform.OS === 'ios' ? 25 : 33,
    left: 25,
    zIndex: 1,
    width: 20,
    color: '#999',
  },
  searchInput: {
    marginTop: -18,
    marginBottom: 15,
    marginHorizontal: 15,
    paddingLeft: 30,
    backgroundColor: '#e3e4e8',
    borderRadius: 8,
    borderColor: 'transparent',
  },
  searchClearButton: {
    position: 'absolute',
    right: 15,
    top: Platform.OS === 'ios' ? 20 : 28,
    zIndex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    width: 30,
    height: 30,
  },
  item: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between'
  },
  itemImage: {
    width: 60,
    height: 30,
    marginRight: 15
  },
  itemText: {
    fontSize: 16,
    flex: 1,
    padding: 15,
  },
})