UNPKG

@salient-sys/react-native-infinite-picker

Version:

A simple, flexible, performant duration picker for React Native apps 🔥 Great for timers, alarms and duration inputs ⏰🕰️⏳ Includes iOS-style haptic and audio feedback 🍏

357 lines (347 loc) 15.3 kB
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } import React, { useRef, useCallback, forwardRef, useImperativeHandle, useState, useEffect, useMemo } from "react"; import { View, Text, FlatList as RNFlatList } from "react-native"; import { colorToRgba } from "../../utils/colorToRgba"; import { findNearestEnabledItemAndIndex } from "../../utils/findNearestEnabledItemAndIndex"; import { generateFlatListData } from "../../utils/generateFlatListData"; import { getInitialScrollIndex } from "../../utils/getInitialScrollIndex"; import { getPickerItemAndIndexFromScrollOffset } from "../../utils/getPickerItemAndIndexFromScrollOffset"; import { generateStyles } from "./styles"; const InfinitePicker = /*#__PURE__*/forwardRef((props, ref) => { var _pickerItems$; const { aggressivelyGetLatestValue, allowFontScaling = false, Audio, clickSoundAsset, disableInfiniteScroll = false, FlatList = RNFlatList, Haptics, initialValue, isDisabled, label, LinearGradient, MaskedView, onChange, padWithNItems, pickerFeedback, pickerGradientOverlayProps, pickerItems, repeatValuesNTimes = 3, styles: customStyles, testID } = props; const styles = useMemo(() => generateStyles(customStyles), [customStyles]); const safeRepeatValuesNTimes = useMemo(() => { const numberOfItems = pickerItems.length; // do not repeat values if there is only one option if (numberOfItems === 1) { return 1; } if (!disableInfiniteScroll && repeatValuesNTimes < 2) { return 2; } else if (repeatValuesNTimes < 1 || isNaN(repeatValuesNTimes)) { return 1; } // if this variable is not explicitly set, we calculate a reasonable value based on // the number of items in the picker, avoiding regular jumps up/down the list // whilst avoiding rendering too many items in the picker if (!props.repeatValuesNTimes) { return Math.max(Math.round(180 / numberOfItems), 1); } return Math.round(repeatValuesNTimes); }, [disableInfiniteScroll, pickerItems.length, props.repeatValuesNTimes, repeatValuesNTimes]); const flatListData = useMemo(() => { return generateFlatListData(pickerItems, { repeatNTimes: safeRepeatValuesNTimes, disableInfiniteScroll, padWithNItems }); }, [disableInfiniteScroll, padWithNItems, pickerItems, safeRepeatValuesNTimes]); const initialScrollIndex = useMemo(() => getInitialScrollIndex({ disableInfiniteScroll, initialValue, numberOfItems: pickerItems.length, padWithNItems, pickerItems, repeatValuesNTimes: safeRepeatValuesNTimes }), [disableInfiniteScroll, initialValue, padWithNItems, pickerItems, safeRepeatValuesNTimes]); const numberOfItemsToShow = 1 + padWithNItems * 2; // keep track of the latest value as it scrolls const latestValue = useRef((_pickerItems$ = pickerItems[0]) === null || _pickerItems$ === void 0 ? void 0 : _pickerItems$.value); // keep track of the last index scrolled past for haptic/audio feedback const lastFeedbackIndex = useRef(0); const flatListRef = useRef(null); const [clickSound, setClickSound] = useState(); // Preload the sound when the component mounts useEffect(() => { const loadSound = async () => { if (Audio) { const { sound } = await Audio.Sound.createAsync(clickSoundAsset ?? { // use a hosted sound as a fallback (do not use local asset due to loader issues // in some environments when including mp3 in library) uri: "https://drive.google.com/uc?export=download&id=10e1YkbNsRh-vGx1jmS1Nntz8xzkBp4_I" }, { shouldPlay: false }); setClickSound(sound); } }; loadSound(); // Unload sound when component unmounts return () => { clickSound === null || clickSound === void 0 || clickSound.unloadAsync(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [Audio]); const renderItem = useCallback(({ item }) => { const { isDisabled, label } = item; return /*#__PURE__*/React.createElement(View, { style: styles.pickerItemContainer, testID: "picker-item" }, /*#__PURE__*/React.createElement(Text, { allowFontScaling: allowFontScaling, style: [styles.pickerItem, isDisabled && styles.disabledPickerItem] }, label)); }, [allowFontScaling, styles.disabledPickerItem, styles.pickerItem, styles.pickerItemContainer]); const onScroll = useCallback(e => { // this function is used to ensure that the latest value is always available // even if the scrollview is still scrolling (if aggressivelyGetLatestValue is true) if (!aggressivelyGetLatestValue && !Haptics && !Audio && !pickerFeedback) { return; } if (aggressivelyGetLatestValue) { const { index: newIndex, pickerItem: newPickerItem } = getPickerItemAndIndexFromScrollOffset({ disableInfiniteScroll, itemHeight: styles.pickerItemContainer.height, padWithNItems, pickerItems, yContentOffset: e.nativeEvent.contentOffset.y }); if (newPickerItem.value !== latestValue.current) { // check whether item is disabled // if so, get the value of the closest enabled item if (newPickerItem.isDisabled) { const { pickerItem: nearestEnabledItem } = findNearestEnabledItemAndIndex({ index: newIndex, pickerItems }); latestValue.current = (nearestEnabledItem === null || nearestEnabledItem === void 0 ? void 0 : nearestEnabledItem.value) ?? null; } else { latestValue.current = newPickerItem.value; } } } if (Haptics || Audio || pickerFeedback) { const feedbackIndex = Math.round((e.nativeEvent.contentOffset.y + styles.pickerItemContainer.height / 2) / styles.pickerItemContainer.height); if (feedbackIndex !== lastFeedbackIndex.current) { // this check stops the feedback firing when the component mounts if (lastFeedbackIndex.current) { try { // fire haptic feedback if available Haptics === null || Haptics === void 0 || Haptics.selectionAsync(); // play click sound if available clickSound === null || clickSound === void 0 || clickSound.replayAsync(); // fire custom feedback if available pickerFeedback === null || pickerFeedback === void 0 || pickerFeedback(); } catch { // do nothing } } lastFeedbackIndex.current = feedbackIndex; } } }, // eslint-disable-next-line react-hooks/exhaustive-deps [aggressivelyGetLatestValue, clickSound, disableInfiniteScroll, padWithNItems, pickerItems, styles.pickerItemContainer.height]); const onMomentumScrollEnd = useCallback(e => { const { index: newIndex, pickerItem: newPickerItem } = getPickerItemAndIndexFromScrollOffset({ disableInfiniteScroll, itemHeight: styles.pickerItemContainer.height, padWithNItems, pickerItems, yContentOffset: e.nativeEvent.contentOffset.y }); let newValue = newPickerItem.value; // check whether item is disabled // if so, scroll to the closest enabled item if (newPickerItem.isDisabled) { const { index: nearestEnabledItemIndex, pickerItem: nearestEnabledItem } = findNearestEnabledItemAndIndex({ index: newIndex, pickerItems }); if (nearestEnabledItemIndex) { var _flatListRef$current; (_flatListRef$current = flatListRef.current) === null || _flatListRef$current === void 0 || _flatListRef$current.scrollToIndex({ animated: true, index: nearestEnabledItemIndex }); newValue = nearestEnabledItem.value; } } onChange(newValue); }, [disableInfiniteScroll, styles.pickerItemContainer.height, padWithNItems, pickerItems, onChange]); const onViewableItemsChanged = useCallback(({ viewableItems }) => { var _viewableItems$, _viewableItems$2; const numberOfItems = pickerItems.length; if (numberOfItems === 1) { return; } if ((_viewableItems$ = viewableItems[0]) !== null && _viewableItems$ !== void 0 && _viewableItems$.index && viewableItems[0].index < numberOfItems * 0.5) { var _flatListRef$current2; (_flatListRef$current2 = flatListRef.current) === null || _flatListRef$current2 === void 0 || _flatListRef$current2.scrollToIndex({ animated: false, index: viewableItems[0].index + numberOfItems }); } else if ((_viewableItems$2 = viewableItems[0]) !== null && _viewableItems$2 !== void 0 && _viewableItems$2.index && viewableItems[0].index >= numberOfItems * (safeRepeatValuesNTimes - 0.5)) { var _flatListRef$current3; (_flatListRef$current3 = flatListRef.current) === null || _flatListRef$current3 === void 0 || _flatListRef$current3.scrollToIndex({ animated: false, index: viewableItems[0].index - numberOfItems }); } }, [pickerItems.length, safeRepeatValuesNTimes]); const [viewabilityConfigCallbackPairs, setViewabilityConfigCallbackPairs] = useState(!disableInfiniteScroll ? [{ viewabilityConfig: { viewAreaCoveragePercentThreshold: 0 }, onViewableItemsChanged: onViewableItemsChanged }] : undefined); const [flatListRenderKey, setFlatListRenderKey] = useState(0); const initialRender = useRef(true); useEffect(() => { // don't run on first render if (initialRender.current) { initialRender.current = false; return; } // if the onViewableItemsChanged callback changes, we need to update viewabilityConfigCallbackPairs // which requires the FlatList to be remounted, hence the increase of the FlatList key setFlatListRenderKey(prev => prev + 1); setViewabilityConfigCallbackPairs(!disableInfiniteScroll ? [{ viewabilityConfig: { viewAreaCoveragePercentThreshold: 0 }, onViewableItemsChanged: onViewableItemsChanged }] : undefined); }, [disableInfiniteScroll, onViewableItemsChanged]); const getItemLayout = useCallback((_, index) => ({ length: styles.pickerItemContainer.height, offset: styles.pickerItemContainer.height * index, index }), [styles.pickerItemContainer.height]); useImperativeHandle(ref, () => ({ reset: options => { var _flatListRef$current4; (_flatListRef$current4 = flatListRef.current) === null || _flatListRef$current4 === void 0 || _flatListRef$current4.scrollToIndex({ animated: (options === null || options === void 0 ? void 0 : options.animated) ?? false, index: initialScrollIndex }); }, setValue: (value, options) => { var _flatListRef$current5; (_flatListRef$current5 = flatListRef.current) === null || _flatListRef$current5 === void 0 || _flatListRef$current5.scrollToIndex({ animated: (options === null || options === void 0 ? void 0 : options.animated) ?? false, index: getInitialScrollIndex({ disableInfiniteScroll, initialValue: value, numberOfItems: pickerItems.length, padWithNItems, pickerItems, repeatValuesNTimes: safeRepeatValuesNTimes }) }); }, latestValue }), [disableInfiniteScroll, initialScrollIndex, padWithNItems, pickerItems, safeRepeatValuesNTimes]); const renderContent = useMemo(() => { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(FlatList, { key: flatListRenderKey, ref: flatListRef, contentContainerStyle: styles.pickerFlatListContentContainer, data: flatListData, decelerationRate: 0.88, getItemLayout: getItemLayout, initialScrollIndex: initialScrollIndex, keyExtractor: (item, index) => `${item.value}-${index}`, nestedScrollEnabled: true, onMomentumScrollEnd: onMomentumScrollEnd, onScroll: onScroll, renderItem: renderItem, scrollEnabled: !isDisabled, scrollEventThrottle: 16, showsVerticalScrollIndicator: false, snapToAlignment: "start" // used in place of snapToInterval due to bug on Android , snapToOffsets: [...Array(flatListData.length)].map((_, i) => i * styles.pickerItemContainer.height), style: styles.pickerFlatList, testID: "duration-scroll-flatlist", viewabilityConfigCallbackPairs: viewabilityConfigCallbackPairs, windowSize: numberOfItemsToShow }), /*#__PURE__*/React.createElement(View, { pointerEvents: "none", style: styles.pickerLabelContainer }, typeof label === "string" ? /*#__PURE__*/React.createElement(Text, { allowFontScaling: allowFontScaling, style: styles.pickerLabel }, label) : label ?? null)); }, [FlatList, flatListRenderKey, styles.pickerFlatListContentContainer, styles.pickerFlatList, flatListData, getItemLayout, initialScrollIndex, onMomentumScrollEnd, onScroll, renderItem, isDisabled, viewabilityConfigCallbackPairs, numberOfItemsToShow, styles.pickerLabelContainer, styles.pickerLabel, styles.pickerItemContainer.height, label, allowFontScaling]); const renderLinearGradient = useMemo(() => { if (!LinearGradient) { return null; } let colors; if (MaskedView) { // if using masked view, we only care about the opacity colors = ["rgba(0,0,0,0)", "rgba(0,0,0,1)", "rgba(0,0,0,1)", "rgba(0,0,0,0)"]; } else { const backgroundColor = styles.pickerFlatListContainer.backgroundColor ?? "white"; const transparentBackgroundColor = colorToRgba({ color: backgroundColor, opacity: 0 }); colors = [backgroundColor, transparentBackgroundColor, transparentBackgroundColor, backgroundColor]; } // calculate the gradient height to cover the top item and bottom item const gradientHeight = padWithNItems > 0 ? 1 / (padWithNItems * 2 + 1) : 0.3; return /*#__PURE__*/React.createElement(LinearGradient, _extends({ colors: colors, locations: [0, gradientHeight, 1 - gradientHeight, 1], pointerEvents: "none", style: styles.pickerGradientOverlay }, pickerGradientOverlayProps)); }, [LinearGradient, MaskedView, padWithNItems, pickerGradientOverlayProps, styles.pickerFlatListContainer.backgroundColor, styles.pickerGradientOverlay]); return /*#__PURE__*/React.createElement(View, { pointerEvents: isDisabled ? "none" : undefined, style: [styles.pickerFlatListContainer, { height: styles.pickerItemContainer.height * numberOfItemsToShow }, isDisabled && styles.disabledPickerContainer], testID: testID }, MaskedView ? /*#__PURE__*/React.createElement(MaskedView, { maskElement: renderLinearGradient, style: [styles.maskedView] }, renderContent) : /*#__PURE__*/React.createElement(React.Fragment, null, renderContent, renderLinearGradient)); }); export default /*#__PURE__*/React.memo(InfinitePicker); //# sourceMappingURL=index.js.map