import React, { useRef, useCallback, forwardRef, useImperativeHandle, useState, useEffect, useMemo, } from "react"; import { View, Text, FlatList as RNFlatList } from "react-native"; import type { ViewabilityConfigCallbackPairs, FlatListProps } from "react-native"; import { colorToRgba } from "../../utils/colorToRgba"; import { generate12HourCycleNumbers, generate12HourNumbers, generateAmPmItems, generateNumbers, } from "../../utils/generateNumbers"; import { getAdjustedLimit } from "../../utils/getAdjustedLimit"; import { getDurationAndIndexFromScrollOffset } from "../../utils/getDurationAndIndexFromScrollOffset"; import { getInitialScrollIndex } from "../../utils/getInitialScrollIndex"; import { getNearestInRange } from "../../utils/getNearestInRange"; import PickerItem from "../PickerItem"; import type { DurationScrollProps, DurationScrollRef, ExpoAvAudioInstance } from "./types"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const keyExtractor = (item: any, index: number) => index.toString(); const DurationScroll = forwardRef((props, ref) => { const { aggressivelyGetLatestDuration, allowFontScaling = false, amLabel, Audio, clickSoundAsset, decelerationRate = 0.88, disableInfiniteScroll = false, FlatList = RNFlatList, getValidValue, Haptics, initialValue = 0, interval, is12HourPicker, isAmPmPicker, isDisabled, isItemDisabled, label, limit, LinearGradient, MaskedView, maximumValue, onDurationChange, padNumbersWithZero = false, padWithNItems, pickerColumnWidth, pickerFeedback, pickerGradientOverlayProps, pickerLabelGap, pmLabel, repeatNumbersNTimes = 3, repeatNumbersNTimesNotExplicitlySet, selectedValue, separateAmPmPicker, styles, testID, } = props; const labelPositionStyle = useMemo(() => { // When the style already has an explicit `left` (from legacy percentage system or // user override), don't apply pixel-based positioning. if (styles.pickerLabelContainer.left != null) { return undefined; } const gap = pickerLabelGap ?? 6; const fontSize = styles.pickerItem.fontSize ?? 25; const maxDigitCount = Math.max(2, String(maximumValue).length); const halfNumberWidth = (maxDigitCount * fontSize * 0.55) / 2; return { left: "50%" as const, marginLeft: halfNumberWidth + gap, }; }, [maximumValue, pickerLabelGap, styles.pickerItem.fontSize, styles.pickerLabelContainer.left]); const numberOfItems = useMemo(() => { // guard against negative maximum values if (maximumValue < 0) { return 1; } return Math.floor(maximumValue / interval) + 1; }, [interval, maximumValue]); const safeRepeatNumbersNTimes = useMemo(() => { // do not repeat numbers if there is only one option if (numberOfItems === 1) { return 1; } if (!disableInfiniteScroll && repeatNumbersNTimes < 2) { return 2; } else if (repeatNumbersNTimes < 1 || isNaN(repeatNumbersNTimes)) { 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 (repeatNumbersNTimesNotExplicitlySet) { return Math.max(Math.round(180 / numberOfItems), 1); } return Math.round(repeatNumbersNTimes); }, [ disableInfiniteScroll, numberOfItems, repeatNumbersNTimes, repeatNumbersNTimesNotExplicitlySet, ]); const numbersForFlatList = useMemo(() => { if (isAmPmPicker) { return generateAmPmItems({ amLabel: amLabel ?? "am", padWithNItems, pmLabel: pmLabel ?? "pm", }); } if (is12HourPicker) { // When AM/PM is rendered as a separate column, the hour column shows // 12, 1, 2, ..., 11 (no AM/PM suffix). if (separateAmPmPicker) { return generate12HourCycleNumbers({ disableInfiniteScroll, interval, padNumbersWithZero, padWithNItems, repeatNTimes: safeRepeatNumbersNTimes, }); } return generate12HourNumbers({ disableInfiniteScroll, interval, padNumbersWithZero, padWithNItems, repeatNTimes: safeRepeatNumbersNTimes, }); } return generateNumbers(numberOfItems, { disableInfiniteScroll, interval, padNumbersWithZero, padWithNItems, repeatNTimes: safeRepeatNumbersNTimes, }); }, [ amLabel, disableInfiniteScroll, is12HourPicker, isAmPmPicker, interval, numberOfItems, padNumbersWithZero, padWithNItems, pmLabel, safeRepeatNumbersNTimes, separateAmPmPicker, ]); const initialScrollIndex = useMemo( () => getInitialScrollIndex({ disableInfiniteScroll, interval, numberOfItems, padWithNItems, repeatNumbersNTimes: safeRepeatNumbersNTimes, value: initialValue, }), [ disableInfiniteScroll, initialValue, interval, numberOfItems, padWithNItems, safeRepeatNumbersNTimes, ] ); const adjustedLimited = useMemo( () => getAdjustedLimit(limit, numberOfItems, interval), [interval, limit, numberOfItems] ); const numberOfItemsToShow = 1 + padWithNItems * 2; // keep track of the latest duration as it scrolls const latestDuration = useRef(0); // keep track of the last index scrolled past for haptic/audio feedback const lastFeedbackIndex = useRef(0); const flatListRef = useRef(null); const [clickSound, setClickSound] = useState(null); useEffect(() => { // Audio prop deprecated in v2.2.0 (use pickerFeedback instead) - will be removed in a future version // preload the sound when the component mounts let soundInstance: ExpoAvAudioInstance | null = null; const loadSound = async () => { if (!Audio) { return; } try { const { sound: newSound } = 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 } ); soundInstance = newSound; setClickSound(newSound); } catch (error) { console.warn("Failed to load click sound:", error); } }; loadSound(); return () => { // unload sound when component unmounts soundInstance?.unloadAsync(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [Audio]); const playClickSound = useCallback(async () => { if (!clickSound) return; try { await clickSound.replayAsync(); } catch (error) { console.warn("Failed to play click sound:", error); } }, [clickSound]); const renderItem = useCallback["renderItem"]>>( ({ item }) => ( ), [ adjustedLimited.max, adjustedLimited.min, allowFontScaling, amLabel, is12HourPicker, isAmPmPicker, isItemDisabled, labelPositionStyle, pmLabel, selectedValue, separateAmPmPicker, styles, ] ); const getNearestInRangeValue = useCallback( (value: number) => getValidValue ? getValidValue(value) : getNearestInRange(value, adjustedLimited.min, adjustedLimited.max), [adjustedLimited, getValidValue] ); const onScroll = useCallback["onScroll"]>>( (e) => { // this function is only used when the picker is in a modal and/or has Haptic/Audio feedback // it is used to ensure that the modal gets the latest duration on clicking // the confirm button, even if the scrollview is still scrolling if (!aggressivelyGetLatestDuration && !Haptics && !Audio && !pickerFeedback) { return; } if (aggressivelyGetLatestDuration) { const newValues = getDurationAndIndexFromScrollOffset({ disableInfiniteScroll, interval, itemHeight: styles.pickerItemContainer.height, numberOfItems, padWithNItems, yContentOffset: e.nativeEvent.contentOffset.y, }); if (newValues.duration !== latestDuration.current) { newValues.duration = getNearestInRangeValue(newValues.duration); latestDuration.current = newValues.duration; } } if (pickerFeedback || Haptics || Audio) { 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) { // fire haptic feedback if available try { Haptics?.selectionAsync(); } catch { // do nothing } // play click sound if available try { playClickSound(); } catch { // do nothing } // fire custom feedback if available try { pickerFeedback?.(); } catch { // do nothing } } lastFeedbackIndex.current = feedbackIndex; } } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ aggressivelyGetLatestDuration, getNearestInRangeValue, playClickSound, disableInfiniteScroll, interval, numberOfItems, padWithNItems, styles.pickerItemContainer.height, ] ); const onMomentumScrollEnd = useCallback< NonNullable["onMomentumScrollEnd"]> >( (e) => { const newValues = getDurationAndIndexFromScrollOffset({ disableInfiniteScroll, interval, itemHeight: styles.pickerItemContainer.height, numberOfItems, padWithNItems, yContentOffset: e.nativeEvent.contentOffset.y, }); const snappedDuration = getNearestInRangeValue(newValues.duration); if (snappedDuration !== newValues.duration) { // value is out of limit range — scroll to whichever boundary we snapped to const snapToMax = snappedDuration === adjustedLimited.max; if (snapToMax) { const targetScrollIndex = newValues.index - (newValues.duration - snappedDuration); flatListRef.current?.scrollToIndex({ animated: true, index: targetScrollIndex >= 0 ? targetScrollIndex : snappedDuration - 1, }); } else { const targetScrollIndex = newValues.index + (snappedDuration - newValues.duration); flatListRef.current?.scrollToIndex({ animated: true, index: targetScrollIndex <= numbersForFlatList.length - 1 ? targetScrollIndex : snappedDuration, }); } } newValues.duration = snappedDuration; onDurationChange(newValues.duration); }, [ disableInfiniteScroll, getNearestInRangeValue, interval, styles.pickerItemContainer.height, numberOfItems, padWithNItems, adjustedLimited.max, onDurationChange, numbersForFlatList.length, ] ); const onViewableItemsChanged = useCallback< NonNullable["onViewableItemsChanged"]> >( ({ viewableItems }) => { if (numberOfItems === 1) { return; } if (viewableItems[0]?.index && viewableItems[0].index < numberOfItems * 0.5) { flatListRef.current?.scrollToIndex({ animated: false, index: viewableItems[0].index + numberOfItems, }); } else if ( viewableItems[0]?.index && viewableItems[0].index >= numberOfItems * (safeRepeatNumbersNTimes - 0.5) ) { flatListRef.current?.scrollToIndex({ animated: false, index: viewableItems[0].index - numberOfItems, }); } }, [numberOfItems, safeRepeatNumbersNTimes] ); const [viewabilityConfigCallbackPairs, setViewabilityConfigCallbackPairs] = useState< ViewabilityConfigCallbackPairs | undefined >( !disableInfiniteScroll ? [ { onViewableItemsChanged: onViewableItemsChanged, viewabilityConfig: { viewAreaCoveragePercentThreshold: 0, }, }, ] : 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 ? [ { onViewableItemsChanged: onViewableItemsChanged, viewabilityConfig: { viewAreaCoveragePercentThreshold: 0, }, }, ] : undefined ); }, [disableInfiniteScroll, onViewableItemsChanged]); const getItemLayout = useCallback["getItemLayout"]>>( (_, index) => ({ index, length: styles.pickerItemContainer.height, offset: styles.pickerItemContainer.height * index, }), [styles.pickerItemContainer.height] ); useImperativeHandle(ref, () => ({ latestDuration: latestDuration, reset: (options) => { flatListRef.current?.scrollToIndex({ animated: options?.animated ?? false, index: initialScrollIndex, }); }, setValue: (value, options) => { flatListRef.current?.scrollToIndex({ animated: options?.animated ?? false, index: getInitialScrollIndex({ disableInfiniteScroll, interval, numberOfItems, padWithNItems, repeatNumbersNTimes: safeRepeatNumbersNTimes, value: value, }), }); }, })); const renderContent = useMemo(() => { return ( <> i * styles.pickerItemContainer.height )} style={styles.durationScrollFlatList} testID="duration-scroll-flatlist" viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs} windowSize={numberOfItemsToShow} /> {typeof label === "string" ? ( {label} ) : ( (label ?? null) )} ); }, [ FlatList, allowFontScaling, decelerationRate, flatListRenderKey, getItemLayout, initialScrollIndex, isDisabled, label, labelPositionStyle, numberOfItemsToShow, numbersForFlatList, onMomentumScrollEnd, onScroll, renderItem, styles.durationScrollFlatList, styles.durationScrollFlatListContentContainer, styles.pickerItemContainer.height, styles.pickerLabel, styles.pickerLabelContainer, viewabilityConfigCallbackPairs, ]); const renderLinearGradient = useMemo(() => { if (!LinearGradient) { return null; } let colors: string[]; 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.pickerContainer.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 ( ); }, [ LinearGradient, MaskedView, padWithNItems, pickerGradientOverlayProps, styles.pickerContainer.backgroundColor, styles.pickerGradientOverlay, ]); return ( {MaskedView ? ( {renderContent} ) : ( <> {renderContent} {renderLinearGradient} )} ); }); export default React.memo(DurationScroll);