import React, { forwardRef, useRef, useState, useMemo, useEffect, useCallback, createElement } from 'react' import { GestureResponderEvent, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView, StyleSheet, View } from 'react-native' import Reanimated, { AnimatedRef, useAnimatedRef, useScrollViewOffset } from 'react-native-reanimated' import { useTransformStyle, splitStyle, splitProps, useLayout, usePrevious, isAndroid, isIOS, isHarmony, extendObject } from '../utils' import useNodesRef, { HandlerRef } from '../useNodesRef' import PickerIndicator from './pickerViewIndicator' import PickerMask from './pickerViewMask' import MpxPickerViewColumnItem from './pickerViewColumnItem' import MpxPickerViewColumnItemLite from './pickerViewColumnItemLite' import { PickerViewColumnAnimationContext } from '../mpx-picker-view/pickerVIewContext' import { calcHeightOffsets } from './pickerViewFaces' interface ColumnProps { columnIndex: number columnData: React.ReactNode[] initialIndex: number onSelectChange: Function style: { [key: string]: any } 'enable-var'?: boolean 'external-var-context'?: Record wrapperStyle: { height: number itemHeight: number } pickerMaskStyle: Record pickerIndicatorStyle: Record enableWheelAnimation?: boolean } const visibleCount = 5 const _PickerViewColumn = forwardRef, ColumnProps>((props: ColumnProps, ref) => { const { columnData, columnIndex, initialIndex, onSelectChange, style, wrapperStyle, pickerMaskStyle, pickerIndicatorStyle, enableWheelAnimation = true, 'enable-var': enableVar, 'external-var-context': externalVarContext } = props const { normalStyle, hasSelfPercent, setWidth, setHeight } = useTransformStyle(style, { enableVar, externalVarContext }) const { textStyle = {} } = splitStyle(normalStyle) const { textProps = {} } = splitProps(props) const scrollViewRef = useAnimatedRef() const offsetYShared = useScrollViewOffset(scrollViewRef as AnimatedRef) useNodesRef(props, ref, scrollViewRef as AnimatedRef, { style: normalStyle }) const { height: pickerH, itemHeight } = wrapperStyle const [itemRawH, setItemRawH] = useState(itemHeight) const maxIndex = useMemo(() => columnData.length - 1, [columnData]) const prevScrollingInfo = useRef({ index: initialIndex, y: 0 }) const dragging = useRef(false) const scrolling = useRef(false) const timerResetPosition = useRef(null) const timerScrollTo = useRef(null) const timerClickOnce = useRef(null) const activeIndex = useRef(initialIndex) const prevIndex = usePrevious(initialIndex) const prevMaxIndex = usePrevious(maxIndex) const { layoutProps } = useLayout({ props, hasSelfPercent, setWidth, setHeight, nodeRef: scrollViewRef }) const paddingHeight = useMemo( () => Math.round((pickerH - itemHeight) / 2), [pickerH, itemHeight] ) const snapToOffsets = useMemo( () => Array.from({ length: maxIndex + 1 }, (_, i) => i * itemRawH), [maxIndex, itemRawH] ) const contentContainerStyle = useMemo(() => { return [{ paddingVertical: paddingHeight }] }, [paddingHeight]) const initialContentOffsetY = useMemo( () => initialIndex * itemRawH, [initialIndex, itemRawH] ) const getIndex = useCallback((y: number) => { const calc = Math.round(y / itemRawH) return Math.max(0, Math.min(calc, maxIndex)) }, [itemRawH, maxIndex]) const clearTimerResetPosition = useCallback(() => { if (timerResetPosition.current) { clearTimeout(timerResetPosition.current) timerResetPosition.current = null } }, []) const clearTimerScrollTo = useCallback(() => { if (timerScrollTo.current) { clearTimeout(timerScrollTo.current) timerScrollTo.current = null } }, []) const clearTimerClickOnce = useCallback(() => { if (timerClickOnce.current) { clearTimeout(timerClickOnce.current) timerClickOnce.current = null } }, []) useEffect(() => { return () => { clearTimerResetPosition() clearTimerScrollTo() } }, []) useEffect(() => { if ( !scrollViewRef.current || !itemRawH || dragging.current || scrolling.current || prevIndex == null || initialIndex === prevIndex || initialIndex === activeIndex.current || maxIndex !== prevMaxIndex ) { return } clearTimerScrollTo() timerScrollTo.current = setTimeout(() => { scrollViewRef.current?.scrollTo({ x: 0, y: initialIndex * itemRawH, animated: false }) activeIndex.current = initialIndex }, isIOS ? 0 : 200) }, [itemRawH, maxIndex, initialIndex]) const onItemLayout = useCallback((e: LayoutChangeEvent) => { const { height: rawH } = e.nativeEvent.layout const roundedH = Math.round(rawH) if (roundedH && roundedH !== itemRawH) { setItemRawH(roundedH) } }, [itemRawH]) const resetScrollPosition = useCallback((y: number) => { if (dragging.current || scrolling.current) { return } scrolling.current = true const targetIndex = getIndex(y) scrollViewRef.current?.scrollTo({ x: 0, y: targetIndex * itemRawH, animated: false }) }, [itemRawH, getIndex]) const onMomentumScrollBegin = useCallback(() => { isIOS && clearTimerResetPosition() scrolling.current = true }, []) const onMomentumScrollEnd = useCallback((e: NativeSyntheticEvent | { nativeEvent: { contentOffset: { y: number } } }) => { scrolling.current = false const { y: scrollY } = e.nativeEvent.contentOffset if (isIOS && scrollY % itemRawH !== 0) { return resetScrollPosition(scrollY) } const calcIndex = getIndex(scrollY) if (calcIndex !== activeIndex.current) { activeIndex.current = calcIndex onSelectChange(calcIndex) } }, [itemRawH, getIndex, onSelectChange, resetScrollPosition]) const onScrollBeginDrag = useCallback(() => { isIOS && clearTimerResetPosition() dragging.current = true prevScrollingInfo.current = { index: activeIndex.current, y: activeIndex.current * itemRawH } }, [itemRawH]) const onScrollEndDrag = useCallback((e: NativeSyntheticEvent) => { dragging.current = false if (!isAndroid) { const { y } = e.nativeEvent.contentOffset if (y % itemRawH === 0 || (isHarmony && y > snapToOffsets[maxIndex])) { onMomentumScrollEnd({ nativeEvent: { contentOffset: { y } } }) } else if (y > 0 && y < snapToOffsets[maxIndex]) { timerResetPosition.current = setTimeout(() => { resetScrollPosition(y) }, 10) } } }, [itemRawH, maxIndex, snapToOffsets, onMomentumScrollEnd, resetScrollPosition]) const onScroll = useCallback((e: NativeSyntheticEvent) => { // 全局注册的振动触感 hook const onPickerVibrate = global.__mpx?.config?.rnConfig?.onPickerVibrate if (typeof onPickerVibrate !== 'function') { return } const { y } = e.nativeEvent.contentOffset const { index: prevIndex, y: _y } = prevScrollingInfo.current if (dragging.current || scrolling.current) { if (Math.abs(y - _y) >= itemRawH) { const currentId = getIndex(y) if (currentId !== prevIndex) { prevScrollingInfo.current = { index: currentId, y: currentId * itemRawH } // vibrateShort({ type: 'selection' }) onPickerVibrate() } } } }, [itemRawH, getIndex]) const offsetHeights = useMemo(() => calcHeightOffsets(itemRawH), [itemRawH]) const calcOffset = useCallback((y: number): number | false => { const baselineY = activeIndex.current * itemRawH + pickerH / 2 const diff = Math.abs(y - baselineY) const positive = y - baselineY > 0 ? 1 : -1 const [h1, h2, h3] = offsetHeights if (diff > h1 && diff < h3) { if (diff < h2) { return 1 * positive } else { return 2 * positive } } return false }, [offsetHeights]) /** * 和小程序表现对齐,点击(不滑动)非焦点选项自动滚动到对应位置 */ const onClickOnceItem = useCallback((e: GestureResponderEvent) => { const { locationY } = e.nativeEvent || {} const offsetIndex = calcOffset(locationY) if (dragging.current || !offsetIndex) { return } const targetIndex = activeIndex.current + offsetIndex if (targetIndex < 0 || targetIndex > maxIndex) { return } const y = targetIndex * itemRawH scrollViewRef.current?.scrollTo({ x: 0, y, animated: true }) if (isAndroid) { // Android scrollTo 不会自动触发 onMomentumScrollEnd,需要手动触发 clearTimerClickOnce() timerClickOnce.current = setTimeout(() => { onMomentumScrollEnd({ nativeEvent: { contentOffset: { y } } }) }, 250) } }, [itemRawH, maxIndex, calcOffset, onMomentumScrollEnd]) const renderInnerchild = () => columnData.map((item: React.ReactElement, index: number) => { return enableWheelAnimation ? () : () }) const renderScollView = () => { const innerProps = extendObject({}, layoutProps, { ref: scrollViewRef, bounces: true, horizontal: false, nestedScrollEnabled: true, removeClippedSubviews: false, showsVerticalScrollIndicator: false, showsHorizontalScrollIndicator: false, scrollEventThrottle: 16, style: styles.scrollView, decelerationRate: 'fast', snapToOffsets: snapToOffsets, onTouchEnd: onClickOnceItem, onScroll, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollBegin, onMomentumScrollEnd, contentContainerStyle, contentOffset: { x: 0, y: initialContentOffsetY } }) as React.ComponentProps return createElement( PickerViewColumnAnimationContext.Provider, { value: offsetYShared }, createElement( Reanimated.ScrollView, innerProps, renderInnerchild() ) ) } const renderIndicator = () => ( ) const renderMask = () => ( ) return ( {renderScollView()} {renderMask()} {renderIndicator()} ) }) const styles = StyleSheet.create({ wrapper: { display: 'flex', flex: 1 }, scrollView: { width: '100%' } }) _PickerViewColumn.displayName = 'MpxPickerViewColumn' export default _PickerViewColumn