import React , { Component , ReactElement } from 'react' ; import { Platform , Modal , TouchableOpacity , TouchableWithoutFeedback , FlatList , View , Text , NativeSyntheticEvent , NativeScrollEvent , } from 'react-native' ; import * as Animatable from 'react-native-animatable' ; import { defaultProps , propTypes } from './SegmentedPickerPropTypes' ; import styles from './SegmentedPickerStyles' ; import Toolbar from '../Toolbar' ; import SelectionMarker from '../SelectionMarker' ; import UIPicker from '../UIPicker' ; import Cache from '../../services/Cache' ; import UIPickerManager from '../../services/UIPickerManager' ; import { PickerColumn , PickerItem , PickerOptions , Selections , SelectionEvent , UIPickerValueChangeEvent , } from '../../config/interfaces' ; import { ANIMATION_TIME , GUTTER_HEIGHT , ITEM_HEIGHTS , TEST_IDS , TRACKING , } from '../../config/constants' ; const ITEM_HEIGHT = Platform . select ( ITEM_HEIGHTS ) ; const { FLAT_LIST_REF , LAST_SCROLL_OFFSET , SCROLL_DIRECTION , IS_DRAGGING , IS_MOMENTUM_SCROLLING , IS_DIRTY , } = TRACKING ; export interface Props { native : boolean ; options : PickerOptions ; visible : boolean ; defaultSelections : Selections ; size : number ; confirmText : string ; nativeTestID : string ; confirmTextColor : string ; pickerItemTextColor : string ; toolbarBackgroundColor : string ; toolbarBorderColor : string ; selectionBackgroundColor : string ; selectionBorderColor : string ; backgroundColor : string ; onValueChange : ( event : SelectionEvent ) => void ; onCancel : ( event : Selections ) => void ; onConfirm : ( event : Selections ) => void ; } interface State { visible : boolean ; pickersHeight : number ; } interface RenderablePickerItem extends PickerItem { key : string ; column : string ; } export default class SegmentedPicker extends Component < Props , State > { static propTypes = propTypes ; static defaultProps = defaultProps as Partial < Props > ; static param options { PickerOptions } return { PickerOptions } static ApplyPickerOptionDefaults = ( options ) : PickerOptions => ( options . map ( column => ( { ... column , flex : column . flex || 1 , } ) ) ) ; cache : Cache = new Cache ( ) ; uiPickerManager : UIPickerManager = new UIPickerManager ( ) ; selectionChanges : Selections = { } ; modalContainerRef : React . RefObject < any > = React . createRef ( ) ; pickerContainerRef : React . RefObject < any > = React . createRef ( ) ; constructor ( props ) { super ( props ) ; this . state = { visible : false , pickersHeight : 0 , } ; } /**
     * Used in rare circumstances where this component is mounted with the `visible`
     * prop set to true. We must animate the picker in immediately.
     */ componentDidMount ( ) { if ( this . props . visible === true ) { this . show ( ) ; } } /**
     * Animates in-and-out when toggling picker visibility with the `visible` prop.
     */ componentDidUpdate ( prevProps ) { const { visible : visibleProp } = this . props ; const { visible : visibleState } = this . state ; if ( visibleProp === true && prevProps . visible !== true && visibleState !== true ) { this . show ( ) ; } if ( visibleProp === false && prevProps . visible === true ) { this . hide ( ) ; } } return { Promise < void > } show = ( ) : Promise < void > => { this . setState ( { visible : true } ) ; return new Promise ( resolve => setTimeout ( resolve , ANIMATION_TIME ) ) ; } ; return { Promise < void > } hide = async ( ) : Promise < void > => ( new Promise ( async ( resolve ) => { if ( Platform . OS === 'ios' ) { this . setState ( { visible : false } , async ( ) => { await new Promise ( done => setTimeout ( done , ANIMATION_TIME ) ) ; this . cache . purge ( ) ; resolve ( ) ; } ) ; } else { await this . modalContainerRef . current ?. fadeOut ( ANIMATION_TIME ) ; this . setState ( { visible : false } , ( ) => { this . cache . purge ( ) ; resolve ( ) ; } ) ; } } ) ) ; param { string } label param { string } column param { boolean = true param { boolean = true param { boolean = false return { void } selectLabel = ( label , column , animated = true , emitEvent = true , zeroFallback = false ) : void => { const index = this . findItemIndexByLabel ( label , column ) ; if ( index !== - 1 ) { this . selectIndex ( index , column , animated , emitEvent ) ; } else if ( this . columnItems ( column ) . length > 0 && zeroFallback ) { this . selectIndex ( 0 , column , animated , emitEvent ) ; } } ; param { string } value param { string } column param { boolean = true param { boolean = true param { boolean = false return { void } selectValue = ( value , column , animated = true , emitEvent = true , zeroFallback = false ) : void => { const index = this . findItemIndexByValue ( value , column ) ; if ( index !== - 1 ) { this . selectIndex ( index , column , animated , emitEvent ) ; } else if ( this . columnItems ( column ) . length > 0 && zeroFallback ) { this . selectIndex ( 0 , column , animated , emitEvent ) ; } } ; param { number } index param { string } column param { boolean = true param { boolean = true return { void } selectIndex = ( index , column , animated = true , emitEvent = true ) : void => { if ( this . isNative ( ) ) { this . uiPickerManager . selectIndex ( index , column , animated ) ; return ; } const { onValueChange } = this . props ; const list = this . cache . get ( `${ FLAT_LIST_REF }${ column }` ) ; if ( ! list ) { return ; } list . scrollToIndex ( { index , animated , } ) ; const items = this . columnItems ( column ) ; if ( ! this . selectionChanges [ column ] || ( this . selectionChanges [ column ] && this . selectionChanges [ column ] !== items [ index ] . value ) ) { this . selectionChanges = { ... this . selectionChanges , [ column ] : items [ index ] . value , } ; if ( emitEvent ) { onValueChange ( { column , value : items [ index ] . value } ) ; } } } ; return { Promise < Selections > } getCurrentSelections = async ( ) : Promise < Selections > => { if ( this . isNative ( ) ) { const nativeSelections = await this . uiPickerManager . getCurrentSelections ( ) ; return nativeSelections ; } const { options } = this . props ; return Promise . resolve ( options . reduce ( ( columns , column ) => { const lastOffset = this . cache . get ( `${ LAST_SCROLL_OFFSET }${ column . key }` ) ; const index = this . nearestOptionIndex ( lastOffset || 0 , column . key ) ; const items = this . columnItems ( column . key ) ; return { ... columns , [ column . key ] : items [ index ] ?. value , } ; } , { } ) ) ; } ; private return { boolean } private isNative = ( ) : boolean => ( this . props . native && Platform . OS === 'ios' ) ; param { string } key return { PickerColumn } private getColumn = ( key ) : PickerColumn => ( this . props . options . filter ( c => c . key === key ) [ 0 ] ) ; param { string } key return { Array < PickerItem > } private columnItems = ( key ) : Array < PickerItem > => this . getColumn ( key ) ?. items || [ ] ; private param { string } label param { string } column return { number } private findItemIndexByLabel = ( label , column ) : number => { const items = this . columnItems ( column ) ; return items . findIndex ( item => ( item . label === label ) ) ; } ; private param { string } value param { string } column return { number } private findItemIndexByValue = ( value , column ) : number => { const items = this . columnItems ( column ) ; return items . findIndex ( item => ( item . value === value ) ) ; } ; private return { void } private setDefaultSelections = ( ) : void => { const { options , defaultSelections } = this . props ; const dirty = this . cache . get ( IS_DIRTY ) ; if ( ! dirty ) { setTimeout ( ( ) => { Object . keys ( defaultSelections ) . forEach ( column => ( this . selectValue ( defaultSelections [ column ] , column , false , false , true ) ) ) ; options . filter ( column => ( ! Object . keys ( defaultSelections ) . includes ( column . key ) && this . columnItems ( column . key ) . length > 0 ) ) . forEach ( column => ( this . selectIndex ( 0 , column . key , false , false ) ) ) ; } , 0 ) ; } } ; private param { string } column param { object } ref return { void } private setFlatListRef = ( column , ref ) : void => { if ( ref ) { this . cache . set ( `${ FLAT_LIST_REF }${ column }` , ref ) ; this . setDefaultSelections ( ) ; } } ; private return { void } private measurePickersHeight = ( event ) : void => { const { height } = event . nativeEvent . layout ; this . setState ( { pickersHeight : height } ) ; } ; private return { number } private pickersVerticalPadding = ( ) : number => { const { pickersHeight } = this . state ; return ( pickersHeight - ITEM_HEIGHT - ( GUTTER_HEIGHT * 2 ) ) / 2 ; } ; private param { number } offsetY param { string } column return { number } private nearestOptionIndex = ( offsetY , column ) : number => { const scrollDirection = this . cache . get ( `${ SCROLL_DIRECTION }${ column }` ) || 1 ; const rounding = ( scrollDirection === 0 ) ? 'floor' : 'ceil' ; const adjustedOffsetY = ( scrollDirection === 0 ) ? ( ( offsetY / ITEM_HEIGHT ) + 0.35 ) : ( ( offsetY / ITEM_HEIGHT ) - 0.35 ) ; let nearestArrayMember = Math [ rounding ] ( adjustedOffsetY ) || 0 ; const columnSize = this . columnItems ( column ) . length ; if ( Math . sign ( nearestArrayMember ) === - 1 ) { nearestArrayMember = 0 ; } else if ( nearestArrayMember > columnSize - 1 ) { nearestArrayMember = columnSize - 1 ; } return nearestArrayMember ; } ; private param { NativeSyntheticEvent < NativeScrollEvent > } event param { string } column return { void } private onScroll = ( event , column ) : void => { if ( ! event . nativeEvent ) return ; const { y } = event . nativeEvent . contentOffset ; const lastScrollOffset = this . cache . get ( `${ LAST_SCROLL_OFFSET }${ column }` ) ; if ( lastScrollOffset !== null && lastScrollOffset < y ) { this . cache . set ( `${ SCROLL_DIRECTION }${ column }` , 1 ) ; } else { this . cache . set ( `${ SCROLL_DIRECTION }${ column }` , 0 ) ; } this . cache . set ( `${ LAST_SCROLL_OFFSET }${ column }` , y ) ; } ; private param { string } column return { void } private onScrollBeginDrag = ( column ) : void => { this . cache . set ( `${ IS_DRAGGING }${ column }` , true ) ; const dirty = this . cache . get ( IS_DIRTY ) ; if ( ! dirty ) { this . cache . set ( IS_DIRTY , true ) ; } } ; private param { NativeSyntheticEvent < NativeScrollEvent > } event param { string } column return { void } private onScrollEndDrag = ( event , column ) : void => { this . cache . set ( `${ IS_DRAGGING }${ column }` , false ) ; if ( Platform . OS === 'ios' && ! this . cache . get ( `${ IS_MOMENTUM_SCROLLING }${ column }` ) ) { this . selectIndexFromScrollPosition ( event , column , 280 ) ; } } ; private param { object } event param { string } column return { void } private onMomentumScrollBegin = ( event , column ) : void => { this . cache . set ( `${ IS_MOMENTUM_SCROLLING }${ column }` , true ) ; } ; private param { NativeSyntheticEvent < NativeScrollEvent > } event param { string } column return { void } private onMomentumScrollEnd = ( event , column ) : void => { this . cache . set ( `${ IS_MOMENTUM_SCROLLING }${ column }` , false ) ; if ( Platform . OS === 'ios' ) { event . persist ( ) ; this . selectIndexFromScrollPosition ( event , column , 50 ) ; } else { this . selectIndexFromScrollPosition ( event , column ) ; } } ; private param { NativeSyntheticEvent < NativeScrollEvent > } event param { string } column param { number ? } delay return { void } private selectIndexFromScrollPosition = ( event , column , delay = 0 ) : void => { if ( ! event . nativeEvent ) return ; const { y } = event . nativeEvent . contentOffset ; const nearestOptionIndex = this . nearestOptionIndex ( y , column ) ; setTimeout ( ( ) => { const isDragging = this . cache . get ( `${ IS_DRAGGING }${ column }` ) ; const isMomentumScrolling = this . cache . get ( `${ IS_MOMENTUM_SCROLLING }${ column }` ) ; if ( ! isDragging && ! isMomentumScrolling ) { this . selectIndex ( nearestOptionIndex , column ) ; } } , delay ) ; } ; private return { Promise < void > } private onCancel = async ( ) : Promise < void > => { const selections = { ... ( await this . getCurrentSelections ( ) ) } ; if ( this . props . visible !== true ) { await this . hide ( ) ; } this . props . onCancel ( selections ) ; } ; private return { Promise < void > } private onConfirm = async ( ) : Promise < void > => { const selections = { ... ( await this . getCurrentSelections ( ) ) } ; if ( this . props . visible !== true ) { await this . hide ( ) ; } this . props . onConfirm ( selections ) ; } ; private return { ReactElement } private renderPickerItem = ( { item : { label , column , key , testID , } , index , } ) : ReactElement => { const { pickerItemTextColor } = this . props ; return ( < View style = { styles . pickerItem } > < TouchableOpacity activeOpacity = { 0.7 } onPress = { ( ) => this . selectIndex ( index , column ) } testID = { testID || key } > < Text numberOfLines = { 1 } style = { [ styles . pickerItemText , { color : pickerItemTextColor } ] } > { label } < /Text> < /TouchableOpacity> < /View>); } ; private uiPickerValueChange = ( { nativeEvent : { column , value } } ) : void => { const { onValueChange } = this . props ; onValueChange ( { column , value } ) ; } ; render ( ) { const { visible } = this . state ; const { nativeTestID , options , defaultSelections , size , confirmText , confirmTextColor , pickerItemTextColor , toolbarBackgroundColor , toolbarBorderColor , selectionBackgroundColor , selectionBorderColor , backgroundColor , } = this . props ; return ( < Modal visible = { visible } animationType = { Platform . select ( { ios : 'fade' , default : 'none' , } ) } transparent onRequestClose = { this . onCancel } > < Animatable . View useNativeDriver animation = "fadeIn" easing = "ease-out-cubic" duration = { ANIMATION_TIME } ref = { this . modalContainerRef } style = { styles . modalContainer } testID = { TEST_IDS . PICKER } > < TouchableWithoutFeedback onPress = { this . onCancel } testID = { TEST_IDS . CLOSE_AREA } > < View style = { [ styles . closeableContainer , { height : `${ ( 100 - ( size * 100 ) ) }%` } ] } /> < /TouchableWithoutFeedback> < Animatable . View useNativeDriver animation = { { from : { opacity : 0 , translateY : 250 } , to : { opacity : 1 , translateY : 0 } , } } easing = "ease-out-quint" delay = { 100 } duration = { ANIMATION_TIME } ref = { this . pickerContainerRef } style = { [ styles . pickerContainer , { height : `${ size * 100 }%` , backgroundColor } ] } > < Toolbar confirmText = { confirmText } confirmTextColor = { confirmTextColor } toolbarBackground = { toolbarBackgroundColor } toolbarBorderColor = { toolbarBorderColor } onConfirm = { this . onConfirm } / > < View style = { styles . selectableArea } > { } { this . isNative ( ) && ( < View style = { styles . nativePickerContainer } > < UIPicker ref = { this . uiPickerManager . reactRef } nativeTestID = { nativeTestID } style = { styles . nativePicker } options = { SegmentedPicker . ApplyPickerOptionDefaults ( options ) } defaultSelections = { defaultSelections } onValueChange = { this . uiPickerValueChange } onEmitSelections = { this . uiPickerManager . ingestSelections } theme = { { itemHeight : ITEM_HEIGHT , selectionBackgroundColor , selectionBorderColor , pickerItemTextColor , } } /> < /View>)} { } { ! this . isNative ( ) && ( < > < SelectionMarker backgroundColor = { selectionBackgroundColor } borderColor = { selectionBorderColor } / > < View style = { styles . pickerColumns } onLayout = { this . measurePickersHeight } > { SegmentedPicker . ApplyPickerOptionDefaults ( options ) . map ( ( { key : column , testID : columnTestID , flex } ) => ( < View style = { [ styles . pickerColumn , { flex } ] } key = { `${ column }` } > < View style = { styles . pickerList } > < FlatList data = { this . columnItems ( column ) . map ( ( { label , value , key , testID , } ) => ( { label , value , column , testID , key : `${ column }_${ key || label }` , } ) ) } renderItem = { this . renderPickerItem } keyExtractor = { item => item . key } initialNumToRender = { 40 } getItemLayout = { ( data , index ) => ( { length : ITEM_HEIGHT , offset : ITEM_HEIGHT * index , index , } ) } contentContainerStyle = { { paddingTop : this . pickersVerticalPadding ( ) , paddingBottom : this . pickersVerticalPadding ( ) , } } showsVerticalScrollIndicator = { false } ref = { ref => this . setFlatListRef ( column , ref ) } onScroll = { event => this . onScroll ( event , column ) } onScrollBeginDrag = { ( ) => this . onScrollBeginDrag ( column ) } onScrollEndDrag = { event => this . onScrollEndDrag ( event , column ) } onMomentumScrollBegin = { event => ( this . onMomentumScrollBegin ( event , column ) ) } onMomentumScrollEnd = { event => ( this . onMomentumScrollEnd ( event , column ) ) } scrollEventThrottle = { 32 } decelerationRate = { Platform . select ( { ios : 1 , android : undefined , } ) } testID = { `${ columnTestID }` } /> < /View> < /View>))} < /View> < />)} < /View> < /Animatable.View> < /Animatable.View> < /Modal>); } }