UNPKG

react-native-material-dropdown

Version:
767 lines (612 loc) 17 kB
import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; import { Text, View, FlatList, Animated, Modal, TouchableWithoutFeedback, Dimensions, Platform, ViewPropTypes, I18nManager, } from 'react-native'; import Ripple from 'react-native-material-ripple'; import { TextField } from 'react-native-material-textfield'; import DropdownItem from '../item'; import styles from './styles'; export default class Dropdown extends PureComponent { static defaultProps = { hitSlop: { top: 6, right: 4, bottom: 6, left: 4 }, disabled: false, data: [], valueExtractor: ({ value } = {}, index) => value, labelExtractor: ({ label } = {}, index) => label, propsExtractor: () => null, absoluteRTLLayout: false, dropdownOffset: { top: 32, left: 0, }, dropdownMargins: { min: 8, max: 16, }, rippleCentered: false, rippleSequential: true, rippleInsets: { top: 16, right: 0, bottom: -8, left: 0, }, rippleOpacity: 0.54, shadeOpacity: 0.12, rippleDuration: 400, animationDuration: 225, fontSize: 16, textColor: 'rgba(0, 0, 0, .87)', itemColor: 'rgba(0, 0, 0, .54)', baseColor: 'rgba(0, 0, 0, .38)', itemCount: 4, itemPadding: 8, supportedOrientations: [ 'portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right', ], useNativeDriver: false, }; static propTypes = { ...TouchableWithoutFeedback.propTypes, disabled: PropTypes.bool, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, ]), data: PropTypes.arrayOf(PropTypes.object), valueExtractor: PropTypes.func, labelExtractor: PropTypes.func, propsExtractor: PropTypes.func, absoluteRTLLayout: PropTypes.bool, dropdownOffset: PropTypes.shape({ top: PropTypes.number.isRequired, left: PropTypes.number.isRequired, }), dropdownMargins: PropTypes.shape({ min: PropTypes.number.isRequired, max: PropTypes.number.isRequired, }), dropdownPosition: PropTypes.number, rippleColor: PropTypes.string, rippleCentered: PropTypes.bool, rippleSequential: PropTypes.bool, rippleInsets: PropTypes.shape({ top: PropTypes.number, right: PropTypes.number, bottom: PropTypes.number, left: PropTypes.number, }), rippleOpacity: PropTypes.number, shadeOpacity: PropTypes.number, rippleDuration: PropTypes.number, animationDuration: PropTypes.number, fontSize: PropTypes.number, textColor: PropTypes.string, itemColor: PropTypes.string, selectedItemColor: PropTypes.string, disabledItemColor: PropTypes.string, baseColor: PropTypes.string, itemTextStyle: Text.propTypes.style, itemCount: PropTypes.number, itemPadding: PropTypes.number, onLayout: PropTypes.func, onFocus: PropTypes.func, onBlur: PropTypes.func, onChangeText: PropTypes.func, renderBase: PropTypes.func, renderAccessory: PropTypes.func, containerStyle: (ViewPropTypes || View.propTypes).style, overlayStyle: (ViewPropTypes || View.propTypes).style, pickerStyle: (ViewPropTypes || View.propTypes).style, supportedOrientations: PropTypes.arrayOf(PropTypes.string), useNativeDriver: PropTypes.bool, }; constructor(props) { super(props); this.onPress = this.onPress.bind(this); this.onClose = this.onClose.bind(this); this.onSelect = this.onSelect.bind(this); this.onLayout = this.onLayout.bind(this); this.updateRippleRef = this.updateRef.bind(this, 'ripple'); this.updateContainerRef = this.updateRef.bind(this, 'container'); this.updateScrollRef = this.updateRef.bind(this, 'scroll'); this.renderAccessory = this.renderAccessory.bind(this); this.renderItem = this.renderItem.bind(this); this.keyExtractor = this.keyExtractor.bind(this); this.blur = () => this.onClose(); this.focus = this.onPress; let { value } = this.props; this.mounted = false; this.focused = false; this.state = { opacity: new Animated.Value(0), selected: -1, modal: false, value, }; } componentWillReceiveProps({ value }) { if (value !== this.props.value) { this.setState({ value }); } } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } onPress(event) { let { data, disabled, onFocus, itemPadding, rippleDuration, dropdownOffset, dropdownMargins: { min: minMargin, max: maxMargin }, animationDuration, absoluteRTLLayout, useNativeDriver, } = this.props; if (disabled) { return; } let itemCount = data.length; let timestamp = Date.now(); if (null != event) { /* Adjust event location */ event.nativeEvent.locationY -= this.rippleInsets().top; event.nativeEvent.locationX -= this.rippleInsets().left; /* Start ripple directly from event */ this.ripple.startRipple(event); } if (!itemCount) { return; } this.focused = true; if ('function' === typeof onFocus) { onFocus(); } let dimensions = Dimensions.get('window'); this.container.measureInWindow((x, y, containerWidth, containerHeight) => { let { opacity } = this.state; /* Adjust coordinates for relative layout in RTL locale */ if (I18nManager.isRTL && !absoluteRTLLayout) { x = dimensions.width - (x + containerWidth); } let delay = Math.max(0, rippleDuration - animationDuration - (Date.now() - timestamp)); let selected = this.selectedIndex(); let leftInset; let left = x + dropdownOffset.left - maxMargin; if (left > minMargin) { leftInset = maxMargin; } else { left = minMargin; leftInset = minMargin; } let right = x + containerWidth + maxMargin; let rightInset; if (dimensions.width - right > minMargin) { rightInset = maxMargin; } else { right = dimensions.width - minMargin; rightInset = minMargin; } let top = y + dropdownOffset.top - itemPadding; this.setState({ modal: true, width: right - left, top, left, leftInset, rightInset, selected, }); setTimeout((() => { if (this.mounted) { this.resetScrollOffset(); Animated .timing(opacity, { duration: animationDuration, toValue: 1, useNativeDriver, }) .start(() => { if (this.mounted && 'ios' === Platform.OS) { let { flashScrollIndicators } = this.scroll || {}; if ('function' === typeof flashScrollIndicators) { flashScrollIndicators.call(this.scroll); } } }); } }), delay); }); } onClose(value = this.state.value) { let { onBlur, animationDuration, useNativeDriver } = this.props; let { opacity } = this.state; Animated .timing(opacity, { duration: animationDuration, toValue: 0, useNativeDriver, }) .start(() => { this.focused = false; if ('function' === typeof onBlur) { onBlur(); } if (this.mounted) { this.setState({ value, modal: false }); } }); } onSelect(index) { let { data, valueExtractor, onChangeText, animationDuration, rippleDuration, } = this.props; let value = valueExtractor(data[index], index); let delay = Math.max(0, rippleDuration - animationDuration); if ('function' === typeof onChangeText) { onChangeText(value, index, data); } setTimeout(() => this.onClose(value), delay); } onLayout(event) { let { onLayout } = this.props; if ('function' === typeof onLayout) { onLayout(event); } } value() { let { value } = this.state; return value; } selectedIndex() { let { value } = this.state; let { data, valueExtractor } = this.props; return data .findIndex((item, index) => null != item && value === valueExtractor(item, index)); } selectedItem() { let { data } = this.props; return data[this.selectedIndex()]; } isFocused() { return this.focused; } itemSize() { let { fontSize, itemPadding } = this.props; return Math.ceil(fontSize * 1.5 + itemPadding * 2); } visibleItemCount() { let { data, itemCount } = this.props; return Math.min(data.length, itemCount); } tailItemCount() { return Math.max(this.visibleItemCount() - 2, 0); } rippleInsets() { let { top = 16, right = 0, bottom = -8, left = 0, } = this.props.rippleInsets || {}; return { top, right, bottom, left }; } resetScrollOffset() { let { selected } = this.state; let { data, dropdownPosition } = this.props; let offset = 0; let itemCount = data.length; let itemSize = this.itemSize(); let tailItemCount = this.tailItemCount(); let visibleItemCount = this.visibleItemCount(); if (itemCount > visibleItemCount) { if (null == dropdownPosition) { switch (selected) { case -1: break; case 0: case 1: break; default: if (selected >= itemCount - tailItemCount) { offset = itemSize * (itemCount - visibleItemCount); } else { offset = itemSize * (selected - 1); } } } else { let index = selected - dropdownPosition; if (dropdownPosition < 0) { index -= visibleItemCount; } index = Math.max(0, index); index = Math.min(index, itemCount - visibleItemCount); if (~selected) { offset = itemSize * index; } } } if (this.scroll) { this.scroll.scrollToOffset({ offset, animated: false }); } } updateRef(name, ref) { this[name] = ref; } keyExtractor(item, index) { let { valueExtractor } = this.props; return `${index}-${valueExtractor(item, index)}`; } renderBase(props) { let { value } = this.state; let { data, renderBase, labelExtractor, dropdownOffset, renderAccessory = this.renderAccessory, } = this.props; let index = this.selectedIndex(); let title; if (~index) { title = labelExtractor(data[index], index); } if (null == title) { title = value; } if ('function' === typeof renderBase) { return renderBase({ ...props, title, value, renderAccessory }); } title = null == title || 'string' === typeof title? title: String(title); return ( <TextField label='' labelHeight={dropdownOffset.top - Platform.select({ ios: 1, android: 2 })} {...props} value={title} editable={false} onChangeText={undefined} renderAccessory={renderAccessory} /> ); } renderRipple() { let { baseColor, rippleColor = baseColor, rippleOpacity, rippleDuration, rippleCentered, rippleSequential, } = this.props; let { bottom, ...insets } = this.rippleInsets(); let style = { ...insets, height: this.itemSize() - bottom, position: 'absolute', }; return ( <Ripple style={style} rippleColor={rippleColor} rippleDuration={rippleDuration} rippleOpacity={rippleOpacity} rippleCentered={rippleCentered} rippleSequential={rippleSequential} ref={this.updateRippleRef} /> ); } renderAccessory() { let { baseColor: backgroundColor } = this.props; let triangleStyle = { backgroundColor }; return ( <View style={styles.accessory}> <View style={styles.triangleContainer}> <View style={[styles.triangle, triangleStyle]} /> </View> </View> ); } renderItem({ item, index }) { if (null == item) { return null; } let { selected, leftInset, rightInset } = this.state; let { valueExtractor, labelExtractor, propsExtractor, textColor, itemColor, baseColor, selectedItemColor = textColor, disabledItemColor = baseColor, fontSize, itemTextStyle, rippleOpacity, rippleDuration, shadeOpacity, } = this.props; let props = propsExtractor(item, index); let { style, disabled } = props = { rippleDuration, rippleOpacity, rippleColor: baseColor, shadeColor: baseColor, shadeOpacity, ...props, onPress: this.onSelect, }; let value = valueExtractor(item, index); let label = labelExtractor(item, index); let title = null == label? value: label; let color = disabled? disabledItemColor: ~selected? index === selected? selectedItemColor: itemColor: selectedItemColor; let textStyle = { color, fontSize }; props.style = [ style, { height: this.itemSize(), paddingLeft: leftInset, paddingRight: rightInset, }, ]; return ( <DropdownItem index={index} {...props}> <Text style={[styles.item, itemTextStyle, textStyle]} numberOfLines={1}> {title} </Text> </DropdownItem> ); } render() { let { renderBase, renderAccessory, containerStyle, overlayStyle: overlayStyleOverrides, pickerStyle: pickerStyleOverrides, rippleInsets, rippleOpacity, rippleCentered, rippleSequential, hitSlop, pressRetentionOffset, testID, nativeID, accessible, accessibilityLabel, supportedOrientations, ...props } = this.props; let { data, disabled, itemPadding, dropdownPosition, } = props; let { left, top, width, opacity, selected, modal } = this.state; let itemCount = data.length; let visibleItemCount = this.visibleItemCount(); let tailItemCount = this.tailItemCount(); let itemSize = this.itemSize(); let height = 2 * itemPadding + itemSize * visibleItemCount; let translateY = -itemPadding; if (null == dropdownPosition) { switch (selected) { case -1: translateY -= 1 === itemCount? 0 : itemSize; break; case 0: break; default: if (selected >= itemCount - tailItemCount) { translateY -= itemSize * (visibleItemCount - (itemCount - selected)); } else { translateY -= itemSize; } } } else { if (dropdownPosition < 0) { translateY -= itemSize * (visibleItemCount + dropdownPosition); } else { translateY -= itemSize * dropdownPosition; } } let overlayStyle = { opacity }; let pickerStyle = { width, height, top, left, transform: [{ translateY }], }; let touchableProps = { disabled, hitSlop, pressRetentionOffset, onPress: this.onPress, testID, nativeID, accessible, accessibilityLabel, }; return ( <View onLayout={this.onLayout} ref={this.updateContainerRef} style={containerStyle}> <TouchableWithoutFeedback {...touchableProps}> <View pointerEvents='box-only'> {this.renderBase(props)} {this.renderRipple()} </View> </TouchableWithoutFeedback> <Modal visible={modal} transparent={true} onRequestClose={this.blur} supportedOrientations={supportedOrientations} > <Animated.View style={[styles.overlay, overlayStyle, overlayStyleOverrides]} onStartShouldSetResponder={() => true} onResponderRelease={this.blur} > <View style={[styles.picker, pickerStyle, pickerStyleOverrides]} onStartShouldSetResponder={() => true} > <FlatList ref={this.updateScrollRef} data={data} style={styles.scroll} renderItem={this.renderItem} keyExtractor={this.keyExtractor} scrollEnabled={visibleItemCount < itemCount} contentContainerStyle={styles.scrollContainer} /> </View> </Animated.View> </Modal> </View> ); } }