diff --git a/.changeset/spotty-bats-leave.md b/.changeset/spotty-bats-leave.md new file mode 100644 index 0000000000..daa87eea8f --- /dev/null +++ b/.changeset/spotty-bats-leave.md @@ -0,0 +1,5 @@ +--- +'@td-design/react-native-picker': major +--- + +feat: 使用reanimated和gesture-handler重写滚动选择组件 diff --git a/packages/react-native-picker/package.json b/packages/react-native-picker/package.json index 6505074ba2..0100b25586 100644 --- a/packages/react-native-picker/package.json +++ b/packages/react-native-picker/package.json @@ -25,8 +25,7 @@ "dependencies": { "array-tree-filter": "^2.1.0", "dayjs": "^1.11.9", - "lodash-es": "^4.17.21", - "react-native-redash": "^18.1.0" + "lodash-es": "^4.17.21" }, "devDependencies": { "@shopify/restyle": "2.4.2", @@ -36,6 +35,8 @@ "@types/react": "^18.2.15", "@types/react-native": "^0.72.2", "react-native-builder-bob": "^0.21.3", + "react-native-gesture-handler": "^2.12.0", + "react-native-reanimated": "^3.3.0", "typescript": "^5.1.6" }, "react-native-builder-bob": { diff --git a/packages/react-native-picker/src/components/DatePicker/index.tsx b/packages/react-native-picker/src/components/DatePicker/index.tsx index 4620df9328..683ec8a453 100644 --- a/packages/react-native-picker/src/components/DatePicker/index.tsx +++ b/packages/react-native-picker/src/components/DatePicker/index.tsx @@ -32,8 +32,7 @@ const DatePicker: FC< {...restProps} data={col} value={values[index]} - index={index} - onChange={onValueChange} + onChange={value => onValueChange(value, index)} /> ); })} diff --git a/packages/react-native-picker/src/components/DatePicker/useDatePicker.ts b/packages/react-native-picker/src/components/DatePicker/useDatePicker.ts index 31bada2a67..96a17f56f8 100644 --- a/packages/react-native-picker/src/components/DatePicker/useDatePicker.ts +++ b/packages/react-native-picker/src/components/DatePicker/useDatePicker.ts @@ -3,10 +3,10 @@ import { useMemo } from 'react'; import { useMemoizedFn } from '@td-design/rn-hooks'; import dayjs, { Dayjs } from 'dayjs'; -import { ItemValue } from '../WheelPicker/type'; +import { PickerData } from '../WheelPicker/type'; import { CascadePickerItemProps, DatePickerPropsBase } from './type'; -export default function useDatePicker({ +export default function useDatePicker({ mode, labelUnit, format, @@ -81,10 +81,10 @@ export default function useDatePicker({ const minDateDay = getMinDay(); const maxDateDay = getMaxDay(); - const years: CascadePickerItemProps[] = []; + const years: CascadePickerItemProps[] = []; for (let i = minDateYear; i <= maxDateYear; i++) { years.push({ - value: i + '', + value: (i + '') as T, label: i + labelUnit.year, }); } @@ -92,7 +92,7 @@ export default function useDatePicker({ return [years]; } - const months: CascadePickerItemProps[] = []; + const months: CascadePickerItemProps[] = []; let minMonth = 0; let maxMonth = 11; if (minDateYear === selYear) { @@ -104,7 +104,7 @@ export default function useDatePicker({ for (let i = minMonth; i <= maxMonth; i++) { months.push({ - value: i + '', + value: (i + '') as T, label: i + 1 + labelUnit.month, }); } @@ -112,7 +112,7 @@ export default function useDatePicker({ return [years, months]; } - const days: CascadePickerItemProps[] = []; + const days: CascadePickerItemProps[] = []; let minDay = 1; let maxDay = getDaysInMonth(date.toDate()); @@ -124,7 +124,7 @@ export default function useDatePicker({ } for (let i = minDay; i <= maxDay; i++) { days.push({ - value: i + '', + value: (i + '') as T, label: i + labelUnit.day, }); } @@ -137,24 +137,24 @@ export default function useDatePicker({ let minMinute = 0; let maxMinute = 59; - const hours: CascadePickerItemProps[] = []; + const hours: CascadePickerItemProps[] = []; for (let i = minHour; i <= maxHour; i++) { hours.push({ - value: i + '', + value: (i + '') as T, label: labelUnit.hour ? i + labelUnit.hour + '' : pad(i), }); } - const minutes: CascadePickerItemProps[] = []; + const minutes: CascadePickerItemProps[] = []; const selMinute = date.get('minute'); for (let i = minMinute; i <= maxMinute; i += 1) { minutes.push({ - value: i + '', + value: (i + '') as T, label: labelUnit.minute ? i + labelUnit.minute + '' : pad(i), }); if (selMinute > i && selMinute < i + 1) { minutes.push({ - value: selMinute + '', + value: (selMinute + '') as T, label: labelUnit.minute ? selMinute + labelUnit.minute + '' : pad(selMinute), }); } @@ -166,7 +166,7 @@ export default function useDatePicker({ const getValueCols = () => { const date = getDate(); - let cols: CascadePickerItemProps[][] = []; + let cols: CascadePickerItemProps[][] = []; let values: string[] = []; if (mode === 'year') { @@ -247,8 +247,8 @@ export default function useDatePicker({ return clipDate(newValue!.toDate()); }; - const onValueChange = (value: ItemValue, index: number) => { - const newDate = getNewDate(parseInt(value + '', 10), index); + const onValueChange = (data: PickerData, index: number) => { + const newDate = getNewDate(parseInt(data.value + '', 10), index); onChange?.(newDate.toDate(), newDate.format(format)); }; diff --git a/packages/react-native-picker/src/components/WheelPicker/WheelPickerItem.tsx b/packages/react-native-picker/src/components/WheelPicker/WheelPickerItem.tsx deleted file mode 100644 index ed904caeff..0000000000 --- a/packages/react-native-picker/src/components/WheelPicker/WheelPickerItem.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { memo } from 'react'; -import { Animated, StyleSheet } from 'react-native'; - -import { WheelPickerItemProps } from './type'; - -const opacityFunction = (val: number) => 1 / (1 + Math.abs(val)); -const scaleFunction = (val: number) => 1 - 0.1 * Math.abs(val); -const rotationFunction = (val: number) => 20 * val; - -function WheelPickerItem({ textStyle, style, visibleRest, height, option, index, currentIndex }: WheelPickerItemProps) { - const relativeScrollIndex = Animated.subtract(index, currentIndex); - - const inputRange = [0]; - for (let i = 1; i <= visibleRest + 1; i++) { - inputRange.unshift(-i); - inputRange.push(i); - } - - const opacityOutputRange = [1]; - for (let x = 1; x <= visibleRest + 1; x++) { - const y = opacityFunction(x); - opacityOutputRange.unshift(y); - opacityOutputRange.push(y); - } - - const scaleOutputRange = [1.1]; - for (let x = 1; x <= visibleRest + 1; x++) { - const y = scaleFunction(x); - scaleOutputRange.unshift(y); - scaleOutputRange.push(y); - } - - const rotateXOutputRange = ['0deg']; - for (let x = 1; x <= visibleRest + 1; x++) { - const y = rotationFunction(x); - rotateXOutputRange.unshift(`${y}deg`); - rotateXOutputRange.push(`${y}deg`); - } - - const opacity = relativeScrollIndex.interpolate({ inputRange, outputRange: opacityOutputRange }); - const scale = relativeScrollIndex.interpolate({ inputRange, outputRange: scaleOutputRange }); - const rotateX = relativeScrollIndex.interpolate({ inputRange, outputRange: rotateXOutputRange }); - - const styles = StyleSheet.create({ - option: { - alignItems: 'center', - justifyContent: 'center', - zIndex: 100, - height, - }, - }); - - return ( - - - {option?.label} - - - ); -} - -export default memo(WheelPickerItem); diff --git a/packages/react-native-picker/src/components/WheelPicker/index.tsx b/packages/react-native-picker/src/components/WheelPicker/index.tsx index 8a0f94fda7..b3b2b0704c 100644 --- a/packages/react-native-picker/src/components/WheelPicker/index.tsx +++ b/packages/react-native-picker/src/components/WheelPicker/index.tsx @@ -1,160 +1,175 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import { Animated, FlatList, NativeScrollEvent, NativeSyntheticEvent, StyleSheet, View } from 'react-native'; - -import { Theme, useTheme } from '@td-design/react-native'; -import { useMemoizedFn } from '@td-design/rn-hooks'; - -import { WheelPickerProps } from './type'; -import WheelPickerItem from './WheelPickerItem'; - -export default function WheelPicker({ +import React, { useEffect, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { PanGestureHandler } from 'react-native-gesture-handler'; +import Animated, { + Easing, + Extrapolate, + interpolate, + runOnJS, + SharedValue, + useAnimatedGestureHandler, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { CascadePickerItemProps, WheelPickerProps } from './type'; + +export default function WheelPicker({ + itemHeight = 40, data, + visibleRest = 5, + textStyle, + contentContainerStyle, + indicatorBgColor = 'rgba(0, 0, 0, 0.3)', value, - indicatorBackgroundColor, - containerStyle, - itemStyle, - itemTextStyle, - itemHeight = 40, - index, onChange, -}: WheelPickerProps) { - const theme = useTheme(); - const flatListRef = useRef(null); - const flag = useRef(false); - - const scrollY = useRef(new Animated.Value(0)).current; + ...props +}: WheelPickerProps) { + const translateY = useSharedValue(0); - const containerHeight = 5 * itemHeight; + const initialIndex = useMemo(() => (value ? data.findIndex(item => item.value === value) : 0), [value, data]); - const { paddedOptions, offsets } = useMemo(() => { - const array = [...data]; - for (let i = 0; i < 2; i++) { - array.unshift(undefined); - array.push(undefined); - } - return { - paddedOptions: array, - offsets: array.map((_, i) => i * itemHeight), - }; - }, [data, itemHeight]); - - let selectedIndex = data.findIndex(item => item?.value === value); - if (selectedIndex === -1) { - selectedIndex = 0; - } - - const currentScrollIndex = Animated.add(Animated.divide(scrollY, itemHeight), 2); - - /** - * 惯性滚动结束时触发 - */ - const handleMomentumScrollEnd = useMemoizedFn((event: NativeSyntheticEvent) => { - handleScrollEnd(event.nativeEvent.contentOffset.y); - flag.current = false; - }); - - /** - * 拖动结束时触发,实测下来, handleDragEnd 一定会触发,但是 handleMomentumScrollEnd 不一定会触发 - * 所以使用 setTimeout 来延迟执行 handleScrollEnd,确保在 handleMomentumScrollEnd 之后执行 - */ - const handleDragEnd = useMemoizedFn((event: NativeSyntheticEvent) => { - event.persist(); - - setTimeout(() => { - if (!flag.current) { - handleScrollEnd(event.nativeEvent.contentOffset.y); - } - }, 10); - }); + useEffect(() => { + translateY.value = -itemHeight * initialIndex; + }, [itemHeight, initialIndex]); - const handleScrollEnd = useMemoizedFn((y: number) => { - // Due to list bounciness when scrolling to the start or the end of the list - // the offset might be negative or over the last item. - // We therefore clamp the offset to the supported range. - const offsetY = Math.min(itemHeight * (data.length - 1), Math.max(y, 0)); + const snapPoints = new Array(data.length).fill(0).map((_, index) => -itemHeight * index); - let _index = Math.floor(Math.floor(offsetY) / itemHeight); - const last = Math.floor(offsetY % itemHeight); - if (last > itemHeight / 2) { - _index += 1; - } + const timingConfig = { + duration: 1000, + easing: Easing.bezier(0.35, 1, 0.35, 1), + }; - const currentItem = data[_index]; - if (currentItem) { - onChange(currentItem.value, index); - } - }); + const wrapper = (index: number) => { + onChange?.(data[index], index); + }; - const styles = StyleSheet.create({ - container: { - position: 'relative', - flex: 1, - height: containerHeight, + const onGestureEvent = useAnimatedGestureHandler({ + onStart(_, ctx: any) { + ctx.y = translateY.value; }, - selectedIndicator: { - position: 'absolute', - width: '100%', - top: '50%', - transform: [{ translateY: -itemHeight / 2 }], - height: itemHeight, - backgroundColor: indicatorBackgroundColor ?? theme.colors.gray50, + onActive(event, ctx) { + translateY.value = ctx.y + event.translationY; }, - scrollView: { - overflow: 'hidden', - flex: 1, + onEnd(event) { + const snapPointsY = snapPoint(translateY.value, event.velocityY, snapPoints); + const index = Math.abs(snapPointsY / itemHeight); + translateY.value = withTiming(snapPointsY, timingConfig); + runOnJS(wrapper)(index); }, }); - useEffect(() => { - setTimeout(() => { - flatListRef.current?.scrollToIndex({ - index: selectedIndex, - animated: false, - }); - }, 100); - }, [selectedIndex]); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); return ( - - - { - flag.current = true; + + + + {data.map((data, index) => ( + + ))} + + + ({ - length: itemHeight, - offset: itemHeight * index, - index, - })} - bounces={false} - data={paddedOptions} - keyExtractor={(_, index) => index.toString()} - renderItem={({ item: option, index }) => ( - - )} - maxToRenderPerBatch={3} - initialNumToRender={2} + pointerEvents="none" /> ); } + +function PickerItem({ + translateY, + index, + data, + itemHeight, + visibleRest, + textStyle, +}: { + translateY: SharedValue; + index: number; + data: CascadePickerItemProps; +} & Required, 'itemHeight' | 'visibleRest'>> & + Pick, 'textStyle'>) { + const y = useDerivedValue(() => + interpolate( + translateY.value / -itemHeight, + [index - visibleRest / 2, index, index + visibleRest / 2], + [-1, 0, 1], + Extrapolate.CLAMP + ) + ); + + const textAnimation = useAnimatedStyle(() => ({ + opacity: 1 / (1 + Math.abs(y.value)), + transform: [ + { + scale: 1 - Math.abs(y.value) * 0.35, + }, + { + perspective: 500, + }, + { + rotateX: `${y.value * 65}deg`, + }, + ], + })); + + return ( + + {data.label} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + overflow: 'hidden', + justifyContent: 'center', + position: 'relative', + }, + item: { + justifyContent: 'center', + alignItems: 'center', + }, +}); + +/** + * @summary Select a point where the animation should snap to given the value of the gesture and it's velocity. + * @worklet + */ +const snapPoint = (value: number, velocity: number, points: ReadonlyArray): number => { + 'worklet'; + const point = value + 0.2 * velocity; + const deltas = points.map(p => Math.abs(point - p)); + const minDelta = Math.min.apply(null, deltas); + return points.filter(p => Math.abs(point - p) === minDelta)[0]; +}; diff --git a/packages/react-native-picker/src/components/WheelPicker/type.ts b/packages/react-native-picker/src/components/WheelPicker/type.ts index 7a5c058883..4ef0eb68ed 100644 --- a/packages/react-native-picker/src/components/WheelPicker/type.ts +++ b/packages/react-native-picker/src/components/WheelPicker/type.ts @@ -1,46 +1,38 @@ -import { Animated, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import { StyleProp, TextStyle, ViewProps, ViewStyle } from 'react-native'; +import { SharedValue } from 'react-native-reanimated'; -export type ItemValue = string | number; -export interface OptionItem { +export type PickerData = { label: string; - value: ItemValue; -} + value: T; +}; -export interface CascadePickerItemProps extends OptionItem { - children?: CascadePickerItemProps[]; +export interface CascadePickerItemProps extends PickerData { + children?: CascadePickerItemProps[]; } -export interface WheelPickerPropsBase { - /** 指示器背景色 */ - indicatorBackgroundColor?: string; - /** 数据行文字样式 */ - itemTextStyle?: StyleProp; - /** 数据行高度 */ +export type WheelPickerPropsBase = { itemHeight?: number; - /** 数据行样式 */ - itemStyle?: StyleProp; - /** 选择器容器样式 */ - containerStyle?: StyleProp; -} + visibleRest?: number; + textStyle?: StyleProp; + contentContainerStyle?: StyleProp; + indicatorBgColor?: string; +}; /** 滚轮选择器的属性 */ -export interface WheelPickerProps extends WheelPickerPropsBase { - index: number; - /** 数据行数组 */ - data: (CascadePickerItemProps | undefined)[]; - /** 当前选中的数据行下标 */ - value: ItemValue; - /** 选择数据行的处理函数 */ - onChange: (value: ItemValue, index: number) => void; -} +export type WheelPickerProps = ViewProps & + WheelPickerPropsBase & { + /** 数据行数组 */ + data: CascadePickerItemProps[]; + /** 当前选中的数据行下标 */ + value?: T; + /** 选择数据行的处理函数 */ + onChange?: (value: PickerData, index: number) => void; + }; /** 滚轮选择器子项的属性 */ -export interface WheelPickerItemProps { - textStyle: StyleProp; - style: StyleProp; - option: OptionItem | null; - height: number; +export type WheelPickerItemProps = { + translateY: SharedValue; index: number; - currentIndex: Animated.AnimatedAddition; - visibleRest: number; -} + data: PickerData; +} & Required, 'itemHeight' | 'visibleRest'>> & + Pick, 'textStyle'>; diff --git a/packages/react-native-picker/src/picker-input/index.tsx b/packages/react-native-picker/src/picker-input/index.tsx index 82b852a8b0..239c6f4d32 100644 --- a/packages/react-native-picker/src/picker-input/index.tsx +++ b/packages/react-native-picker/src/picker-input/index.tsx @@ -8,7 +8,7 @@ import { ModalPickerProps, PickerProps } from '../picker/type'; import { PickerRef } from '../type'; import usePicker from '../usePicker'; -interface PickerInputProps extends PickerProps, Omit { +interface PickerInputProps extends PickerProps, Omit { /** 标签文本 */ label?: ReactNode; /** 标签文本位置 */ @@ -34,127 +34,125 @@ interface PickerInputProps extends PickerProps, Omit( - ( - { - label, - labelPosition = 'left', - placeholder = '请选择', - required = false, - colon = false, - cascade, - value, - data, - onChange, - style, - brief, - allowClear = true, - disabled = false, - itemHeight, - hyphen = ',', - activeOpacity = 0.6, - ...restProps - }, - ref - ) => { - const theme = useTheme(); - const { state, currentText, visible, setFalse, handlePress, handleChange, handleInputClear } = usePicker({ - data, - cascade, - value, - onChange, - placeholder, - hyphen, - ref, - }); - const styles = StyleSheet.create({ - content: { - paddingVertical: theme.spacing.x2, - paddingHorizontal: theme.spacing.x1, - justifyContent: 'space-between', - alignItems: 'center', - flexDirection: 'row', - borderWidth: ONE_PIXEL, - borderColor: theme.colors.border, - borderRadius: theme.borderRadii.x1, - }, - top: {}, - left: { flex: 1 }, - icon: { alignItems: 'flex-end' }, - }); +function PickerInputInner( + { + label, + labelPosition = 'left', + placeholder = '请选择', + required = false, + colon = false, + cascade, + value, + data, + onChange, + style, + brief, + allowClear = true, + disabled = false, + itemHeight, + hyphen = ',', + activeOpacity = 0.6, + ...restProps + }: PickerInputProps, + ref: React.ForwardedRef +) { + const theme = useTheme(); + const { state, currentText, visible, setFalse, handlePress, handleChange, handleInputClear } = usePicker({ + data, + cascade, + value, + onChange, + placeholder, + hyphen, + ref, + }); - const BaseContent = ( - <> - - - {currentText} - - - - {!disabled && allowClear && !!currentText && currentText !== placeholder && ( - - - - )} - - - - ); + const styles = StyleSheet.create({ + content: { + paddingVertical: theme.spacing.x2, + paddingHorizontal: theme.spacing.x1, + justifyContent: 'space-between', + alignItems: 'center', + flexDirection: 'row', + borderWidth: ONE_PIXEL, + borderColor: theme.colors.border, + borderRadius: theme.borderRadii.x1, + }, + top: {}, + left: { flex: 1 }, + icon: { alignItems: 'flex-end' }, + }); - const Content = !disabled ? ( - - {BaseContent} - - ) : ( - - {BaseContent} + const BaseContent = ( + <> + + + {currentText} + - ); + + {!disabled && allowClear && !!currentText && currentText !== placeholder && ( + + + + )} + + + + ); + + const Content = !disabled ? ( + + {BaseContent} + + ) : ( + + {BaseContent} + + ); - return ( - <> - {labelPosition === 'top' ? ( - + return ( + <> + {labelPosition === 'top' ? ( + + + ) : ( + + - ) : ( - - - - - - )} - - - ); - } -); + + + + )} + + + ); +} + +const PickerInput = forwardRef(PickerInputInner); export default PickerInput; diff --git a/packages/react-native-picker/src/picker-item/index.tsx b/packages/react-native-picker/src/picker-item/index.tsx index b0cbbb7fb5..b55cec8d16 100644 --- a/packages/react-native-picker/src/picker-item/index.tsx +++ b/packages/react-native-picker/src/picker-item/index.tsx @@ -9,7 +9,7 @@ import { ModalPickerProps, PickerProps } from '../picker/type'; import { PickerRef } from '../type'; import usePicker from '../usePicker'; -interface PickerItemProps extends PickerProps, Omit { +interface PickerItemProps extends PickerProps, Omit { placeholder?: string; /** 是否允许清除 */ allowClear?: boolean; @@ -23,80 +23,80 @@ interface PickerItemProps extends PickerProps, Omit( - ( - { - placeholder = '请选择', - disabled = false, - cascade, - value, - data, - onChange, - style, - allowClear = true, - hyphen = ',', - activeOpacity = 0.6, - inForm, - ...restProps +function PickerItemInner( + { + placeholder = '请选择', + disabled = false, + cascade, + value, + data, + onChange, + style, + allowClear = true, + hyphen = ',', + activeOpacity = 0.6, + inForm, + ...restProps + }: PickerItemProps, + ref: React.ForwardedRef +) { + const theme = useTheme(); + const { currentText, visible, state, setFalse, handlePress, handleChange, handleInputClear } = usePicker({ + data, + cascade, + value, + onChange, + placeholder, + hyphen, + ref, + }); + + const styles = StyleSheet.create({ + content: { + flexGrow: 1, + justifyContent: 'flex-end', + alignItems: 'center', + flexDirection: 'row', + paddingHorizontal: theme.spacing[inForm ? 'x0' : 'x1'], }, - ref - ) => { - const theme = useTheme(); - const { currentText, visible, state, setFalse, handlePress, handleChange, handleInputClear } = usePicker({ - data, - cascade, - value, - onChange, - placeholder, - hyphen, - ref, - }); + icon: { alignItems: 'flex-end' }, + }); - const styles = StyleSheet.create({ - content: { - flexGrow: 1, - justifyContent: 'flex-end', - alignItems: 'center', - flexDirection: 'row', - paddingHorizontal: theme.spacing[inForm ? 'x0' : 'x1'], - }, - icon: { alignItems: 'flex-end' }, - }); + const Content = ( + <> + + {currentText} + + {!disabled && allowClear && !!currentText && currentText !== placeholder && ( + + + + )} + + ); - const Content = ( + if (!disabled) + return ( <> - - {currentText} - - {!disabled && allowClear && !!currentText && currentText !== placeholder && ( - - - - )} + + {Content} + + ); - if (!disabled) - return ( - <> - - {Content} - - - - ); + return {Content}; +} - return {Content}; - } -); +const PickerItem = forwardRef(PickerItemInner); export default PickerItem; diff --git a/packages/react-native-picker/src/picker/components/Cascade/index.tsx b/packages/react-native-picker/src/picker/components/Cascade/index.tsx index b9af603e8a..5a4cf01543 100644 --- a/packages/react-native-picker/src/picker/components/Cascade/index.tsx +++ b/packages/react-native-picker/src/picker/components/Cascade/index.tsx @@ -10,7 +10,7 @@ import useCascader from './useCascader'; const { ONE_PIXEL } = helpers; -const Cascader = ({ +function Cascader({ data, cols = 3, activeOpacity = 0.6, @@ -23,7 +23,7 @@ const Cascader = ({ onClose, onChange, ...restProps -}: CascaderProps) => { +}: CascaderProps) { const { childrenTree, stateValue, handleOk, handleValueChange } = useCascader({ data, cols, @@ -39,12 +39,12 @@ const Cascader = ({ return ( - {childrenTree.map((item: CascadePickerItemProps[] = [], index) => ( + {childrenTree.map((item: CascadePickerItemProps[] = [], index) => ( ({ ...el, value: `${el.value}` })), index, value: `${stateValue[index]}` }} - onChange={handleValueChange} + {...{ data: item.map(el => ({ ...el, value: el.value })), value: stateValue[index] }} + onChange={value => handleValueChange(value, index)} /> ))} @@ -82,7 +82,7 @@ const Cascader = ({ ); } return PickerComp; -}; +} const styles = StyleSheet.create({ cancel: { justifyContent: 'center', alignItems: 'flex-start' }, diff --git a/packages/react-native-picker/src/picker/components/Cascade/useCascader.ts b/packages/react-native-picker/src/picker/components/Cascade/useCascader.ts index 06ebc5243b..64bdab8341 100644 --- a/packages/react-native-picker/src/picker/components/Cascade/useCascader.ts +++ b/packages/react-native-picker/src/picker/components/Cascade/useCascader.ts @@ -4,18 +4,18 @@ import { BackHandler } from 'react-native'; import { useMemoizedFn, useSafeState } from '@td-design/rn-hooks'; import arrayTreeFilter from 'array-tree-filter'; -import { CascadePickerItemProps, ItemValue } from '../../../components/WheelPicker/type'; +import { CascadePickerItemProps, PickerData } from '../../../components/WheelPicker/type'; import { CascaderProps } from '../../type'; -export default function useCascader({ +export default function useCascader({ data, cols = 3, value, onChange, onClose, visible, -}: Pick) { - const [stateValue, setStateValue] = useSafeState([]); +}: Pick, 'data' | 'cols' | 'value' | 'onChange' | 'onClose' | 'visible'>) { + const [stateValue, setStateValue] = useSafeState([]); useEffect(() => { const listener = BackHandler.addEventListener('hardwareBackPress', () => visible); @@ -30,10 +30,10 @@ export default function useCascader({ setStateValue(nextValue); }, [data, value, cols]); - const handleValueChange = (value: ItemValue, index: number) => { + const handleValueChange = (value: PickerData, index: number) => { const newValue = [...stateValue]; // 修改当前的值,然后把后面的值都清掉 - newValue[index] = value + ''; + newValue[index] = value.value; newValue.length = index + 1; const nextValue = generateNextValue(data, newValue, cols); setStateValue(nextValue); @@ -46,7 +46,7 @@ export default function useCascader({ const childrenTree = useMemo(() => { const childrenTree = arrayTreeFilter(data, (c, level) => { - return c.value + '' === stateValue[level] + ''; + return c.value === stateValue[level]; }).map(c => c.children); // in case the users data is async get when select change @@ -71,24 +71,20 @@ export default function useCascader({ }; } -const generateNextValue = ( - data: CascadePickerItemProps[], - value: ItemValue[] | undefined, - cols: number -): ItemValue[] => { +function generateNextValue(data: CascadePickerItemProps[], value: T[] | undefined, cols: number) { let d = data; let level = 0; - const nextValue: ItemValue[] = []; + const nextValue: T[] = []; if (value && value.length) { do { - const index = d.findIndex(item => item.value + '' === value[level] + ''); + const index = d.findIndex(item => item.value === value[level]); if (index < 0) { break; } - nextValue[level] = value[level] + ''; + nextValue[level] = value[level]; level += 1; d = d[index].children || []; } while (d.length > 0); @@ -96,11 +92,11 @@ const generateNextValue = ( for (let i = level; i < cols; i++) { if (d && d.length) { - nextValue[i] = d[0].value! + ''; + nextValue[i] = d[0].value!; d = d[0].children || []; } else { break; } } return nextValue; -}; +} diff --git a/packages/react-native-picker/src/picker/components/Normal/index.tsx b/packages/react-native-picker/src/picker/components/Normal/index.tsx index b7147a14e0..aff103c8db 100644 --- a/packages/react-native-picker/src/picker/components/Normal/index.tsx +++ b/packages/react-native-picker/src/picker/components/Normal/index.tsx @@ -1,14 +1,14 @@ -import React, { FC, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet } from 'react-native'; -import { Flex, helpers, Modal, Pressable, Text } from '@td-design/react-native'; +import { Box, Flex, helpers, Modal, Pressable, Text } from '@td-design/react-native'; import WheelPicker from '../../../components/WheelPicker'; -import { ModalPickerProps, PickerProps } from '../../type'; +import { NormalPickerProps } from '../../type'; import useNormalPicker from './useNormalPicker'; const { ONE_PIXEL, px } = helpers; -const NormalPicker: FC = props => { +function NormalPicker(props: NormalPickerProps) { const { title, displayType = 'modal', @@ -23,9 +23,11 @@ const NormalPicker: FC = props => { ...restProps } = props; - const { pickerData, selectedValue, handleChange, handleOk, handleClose } = useNormalPicker({ - data, + const initialValue = data.length > 0 ? data[0].value : undefined; + + const { selectedValue, handleChange, handleOk, handleClose } = useNormalPicker({ value, + initialValue, onChange, onClose, visible, @@ -34,21 +36,14 @@ const NormalPicker: FC = props => { const PickerComp = useMemo(() => { if (!visible) return null; - if (pickerData.length === 0) return null; + if (data.length === 0) return null; return ( - - {pickerData.map((item, index) => ( - - ))} - + + + ); - }, [visible, pickerData, selectedValue, restProps]); + }, [visible, data, selectedValue, restProps]); if (displayType === 'modal') { return ( @@ -83,7 +78,7 @@ const NormalPicker: FC = props => { ); } return PickerComp; -}; +} export default NormalPicker; diff --git a/packages/react-native-picker/src/picker/components/Normal/useNormalPicker.ts b/packages/react-native-picker/src/picker/components/Normal/useNormalPicker.ts index 96bc50d9b9..c9145074cd 100644 --- a/packages/react-native-picker/src/picker/components/Normal/useNormalPicker.ts +++ b/packages/react-native-picker/src/picker/components/Normal/useNormalPicker.ts @@ -1,51 +1,23 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { BackHandler } from 'react-native'; import { useMemoizedFn, useSafeState } from '@td-design/rn-hooks'; -import { isNil } from 'lodash-es'; -import { CascadePickerItemProps, ItemValue } from '../../../components/WheelPicker/type'; -import { ModalPickerProps, PickerProps } from '../../type'; +import { PickerData } from '../../../components/WheelPicker/type'; +import { NormalPickerProps } from '../../type'; -const transform = (data: CascadePickerItemProps[] | Array) => { - const item = data[0]; - if (!Array.isArray(item)) { - return { - pickerData: [ - (data as CascadePickerItemProps[]).map(item => ({ - ...item, - value: String(item.value), - })), - ], - initialValue: !isNil(item?.value) ? [String(item.value)] : [], - }; - } - return { - pickerData: (data as Array).map(arr => - arr.map(item => ({ ...item, value: String(item.value) })) - ), - initialValue: (data as Array).map(ele => String(ele[0].value!)), - }; -}; - -function getValue(value?: ItemValue[], initialValue?: ItemValue[]) { - if (isNil(value) || value.length === 0) return initialValue; - return value; -} - -export default function useNormalPicker({ - data, +export default function useNormalPicker({ value, + initialValue, onChange, onClose, visible, displayType, -}: PickerProps & ModalPickerProps) { - const { pickerData, initialValue } = useMemo(() => transform(data), [data]); - const [selectedValue, selectValue] = useSafeState(undefined); +}: Omit, 'data'> & { initialValue?: T }) { + const [selectedValue, selectValue] = useSafeState(); useEffect(() => { - selectValue(getValue(value, initialValue)); + selectValue(value || initialValue); }, [value, initialValue]); /** 绑定物理返回键监听事件,如果当前picker是打开的,返回键作用是关闭picker,否则返回上一个界面 */ @@ -54,21 +26,16 @@ export default function useNormalPicker({ return () => sub.remove(); }, [visible]); - const handleChange = (val: ItemValue, index: number) => { - let draft = selectedValue ? [...selectedValue] : undefined; - if (!draft) { - draft = [val]; - } else { - draft[index] = val; - } + const handleChange = (val: PickerData) => { if (displayType === 'view') { - onChange?.(draft); + onChange?.(val.value); + } else { + selectValue(val.value); } - selectValue(draft); }; const handleClose = () => { - selectValue(getValue(value, initialValue)); + selectValue(value); onClose?.(); }; @@ -78,7 +45,6 @@ export default function useNormalPicker({ }; return { - pickerData, selectedValue, handleChange: useMemoizedFn(handleChange), handleOk: useMemoizedFn(handleOk), diff --git a/packages/react-native-picker/src/picker/index.tsx b/packages/react-native-picker/src/picker/index.tsx index 27de8f0c45..c11e7cd915 100644 --- a/packages/react-native-picker/src/picker/index.tsx +++ b/packages/react-native-picker/src/picker/index.tsx @@ -1,21 +1,36 @@ -import React, { FC } from 'react'; +import React from 'react'; -import { CascadePickerItemProps } from '../components/WheelPicker/type'; import Cascader from './components/Cascade'; import NormalPicker from './components/Normal'; import { ModalPickerProps, PickerProps } from './type'; -const Picker: FC = ({ +function Picker({ cascade = false, cols = 3, - data, activeOpacity = 0.6, + value, + onChange, ...restProps -}) => { +}: PickerProps & ModalPickerProps) { if (cascade) { - return ; + return ( + void} + /> + ); } - return ; -}; + return ( + void} + /> + ); +} export default Picker; diff --git a/packages/react-native-picker/src/picker/type.ts b/packages/react-native-picker/src/picker/type.ts index e22bde9556..191040147d 100644 --- a/packages/react-native-picker/src/picker/type.ts +++ b/packages/react-native-picker/src/picker/type.ts @@ -1,16 +1,14 @@ -import { CascadePickerItemProps, ItemValue, WheelPickerPropsBase } from '../components/WheelPicker/type'; +import { CascadePickerItemProps, WheelPickerPropsBase } from '../components/WheelPicker/type'; -export interface PickerProps extends WheelPickerPropsBase { +export interface PickerProps extends WheelPickerPropsBase { /** 选择项列表 */ - data: CascadePickerItemProps[] | Array; + data: CascadePickerItemProps[]; /** 是否级联 */ cascade?: boolean; /** 展示几列 */ cols?: number; - /** 当前值 */ - value?: ItemValue[]; - /** 修改事件 */ - onChange?: (value?: ItemValue[]) => void; + value?: T[] | T; + onChange?: ((value?: T) => void) | ((value?: T[]) => void); } /** 弹窗Picker的属性 */ @@ -31,10 +29,20 @@ export interface ModalPickerProps { activeOpacity?: number; } -export type PickerRefProps = { - getValue: () => { value: ItemValue[] }; +export type PickerRefProps = { + getValue: () => { value: T[] }; }; -export type CascaderProps = Omit & { - data: CascadePickerItemProps[]; +export type CascaderProps = Omit, 'cascade' | 'value' | 'onChange'> & { + /** 当前值 */ + value?: T[]; + /** 修改事件 */ + onChange?: (value?: T[]) => void; +} & ModalPickerProps; + +export type NormalPickerProps = Omit, 'cascade' | 'value' | 'onChange'> & { + /** 当前值 */ + value?: T; + /** 修改事件 */ + onChange?: (value?: T) => void; } & ModalPickerProps; diff --git a/packages/react-native-picker/src/usePicker.ts b/packages/react-native-picker/src/usePicker.ts index 7a0dba726f..7669f91d95 100644 --- a/packages/react-native-picker/src/usePicker.ts +++ b/packages/react-native-picker/src/usePicker.ts @@ -3,14 +3,14 @@ import { Keyboard } from 'react-native'; import { useBoolean, useMemoizedFn, useSafeState } from '@td-design/rn-hooks'; -import { CascadePickerItemProps, ItemValue } from './components/WheelPicker/type'; +import { CascadePickerItemProps } from './components/WheelPicker/type'; import { PickerProps } from './picker/type'; import { PickerRef } from './type'; import { transformValueToLabel } from './utils'; -function getText( - data: CascadePickerItemProps[] | CascadePickerItemProps[][], - value?: ItemValue[], +function getText( + data: CascadePickerItemProps[], + value?: T[] | T, cascade?: boolean, placeholder?: string, hyphen?: string @@ -21,7 +21,7 @@ function getText( return placeholder; } -export default function usePicker({ +export default function usePicker({ data, cascade = false, value, @@ -29,12 +29,12 @@ export default function usePicker({ placeholder = '请选择', hyphen, ref, -}: Pick & { +}: Pick, 'data' | 'cascade' | 'value' | 'onChange'> & { placeholder?: string; hyphen?: string; ref: ForwardedRef; }) { - const [state, setState] = useSafeState(value); + const [state, setState] = useSafeState(value); const [currentText, setCurrentText] = useSafeState(getText(data, value, cascade, placeholder, hyphen)); const [visible, { setTrue, setFalse }] = useBoolean(false); @@ -57,12 +57,16 @@ export default function usePicker({ setTrue(); }; - const handleChange = (value?: ItemValue[]) => { + const handleChange = (value?: T[] | T) => { const text = getText(data, value, cascade, placeholder, hyphen); setCurrentText(text); setState(value); - onChange?.(value); + if (cascade) { + (onChange as (value?: T[]) => void)?.(value as T[]); + } else { + (onChange as (value?: T) => void)?.(value as T); + } }; const handleInputClear = () => { diff --git a/packages/react-native-picker/src/utils.ts b/packages/react-native-picker/src/utils.ts index a1b59dd905..3eb3f532a2 100644 --- a/packages/react-native-picker/src/utils.ts +++ b/packages/react-native-picker/src/utils.ts @@ -1,4 +1,4 @@ -import { CascadePickerItemProps, ItemValue } from './components/WheelPicker/type'; +import { CascadePickerItemProps } from './components/WheelPicker/type'; /** * 根据value,返回对应的label @@ -7,27 +7,19 @@ import { CascadePickerItemProps, ItemValue } from './components/WheelPicker/type * @param cascade 是否级联 * @returns 值对应的文本 */ -export function transformValueToLabel( - data: CascadePickerItemProps[] | Array, - value?: ItemValue[], +export function transformValueToLabel( + data: CascadePickerItemProps[], + value?: T[] | T, cascade?: boolean, hyphen?: string ) { - if (!value || value.length === 0) return undefined; + if (!value) return undefined; + if (!cascade) { - if (Array.isArray(data[0])) { - let text = ''; - value.forEach((val, index) => { - const label = (data[index] as CascadePickerItemProps[]).find(item => item.value + '' === val + '')?.label; - if (label) { - text += label + hyphen; - } - }); - return text.substring(0, text.length - 1); - } - return (data as CascadePickerItemProps[]).find(item => item.value + '' === value[0] + '')?.label; + return data.find(item => item.value === value)?.label; } - return value.map(val => findByValue(data as CascadePickerItemProps[], val)?.label).join(hyphen); + + return (value as T[]).map(val => findByValue(data, val)?.label).join(hyphen); } /** @@ -36,13 +28,13 @@ export function transformValueToLabel( * @param value * @returns */ -function findByValue(data: CascadePickerItemProps[], value: ItemValue): CascadePickerItemProps | undefined { - let selectedItem: CascadePickerItemProps | undefined = undefined; +function findByValue(data: CascadePickerItemProps[], value: T): CascadePickerItemProps | undefined { + let selectedItem: CascadePickerItemProps | undefined = undefined; - function recurision(list: CascadePickerItemProps[], value: ItemValue) { + function recurision(list: CascadePickerItemProps[], value: T) { if (!list) return; for (let i = 0; i < list.length; i++) { - if (list[i].value + '' === value + '') { + if (list[i].value === value) { selectedItem = list[i]; break; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c215712675..f955cd9d12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,7 +276,7 @@ importers: packages/react-native: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/rn-hooks': ^2.7.4 + '@td-design/rn-hooks': ^2.8.0 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 rc-field-form: ^1.34.2 @@ -329,9 +329,9 @@ importers: packages/react-native-calendar: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/react-native': workspace:^5.8.2 - '@td-design/react-native-picker': workspace:^2.4.2 - '@td-design/rn-hooks': workspace:^2.7.4 + '@td-design/react-native': workspace:^5.8.11 + '@td-design/react-native-picker': workspace:^2.6.0 + '@td-design/rn-hooks': workspace:^2.8.0 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 dayjs: ^1.11.9 @@ -374,8 +374,8 @@ importers: packages/react-native-image-picker: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/react-native': workspace:^5.8.2 - '@td-design/rn-hooks': workspace:^2.7.4 + '@td-design/react-native': workspace:^5.8.11 + '@td-design/rn-hooks': workspace:^2.8.0 '@types/react': 17.0.43 '@types/react-native': 0.72.2 react-native-builder-bob: ^0.21.3 @@ -394,8 +394,8 @@ importers: packages/react-native-password: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/react-native': workspace:^5.8.2 - '@td-design/rn-hooks': workspace:^2.7.4 + '@td-design/react-native': workspace:^5.8.11 + '@td-design/rn-hooks': workspace:^2.8.0 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 react-native-builder-bob: ^0.21.3 @@ -412,8 +412,8 @@ importers: packages/react-native-picker: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/react-native': workspace:^5.8.2 - '@td-design/rn-hooks': workspace:^2.7.4 + '@td-design/react-native': workspace:^5.8.11 + '@td-design/rn-hooks': workspace:^2.8.0 '@types/lodash-es': ^4.17.8 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 @@ -421,13 +421,13 @@ importers: dayjs: ^1.11.9 lodash-es: ^4.17.21 react-native-builder-bob: ^0.21.3 - react-native-redash: ^18.1.0 + react-native-gesture-handler: ^2.12.0 + react-native-reanimated: ^3.3.0 typescript: ^5.1.6 dependencies: array-tree-filter: 2.1.0 dayjs: 1.11.9 lodash-es: 4.17.21 - react-native-redash: 18.1.0 devDependencies: '@shopify/restyle': 2.4.2 '@td-design/react-native': link:../react-native @@ -436,13 +436,15 @@ importers: '@types/react': 17.0.43 '@types/react-native': 0.72.2 react-native-builder-bob: 0.21.3 + react-native-gesture-handler: 2.12.0 + react-native-reanimated: 3.3.0 typescript: 5.1.6 packages/react-native-rating: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/react-native': workspace:^5.8.2 - '@td-design/rn-hooks': workspace:^2.7.4 + '@td-design/react-native': workspace:^5.8.11 + '@td-design/rn-hooks': workspace:^2.8.0 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 react-native-builder-bob: ^0.21.3 @@ -467,7 +469,7 @@ importers: packages/react-native-share: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/react-native': workspace:^5.8.2 + '@td-design/react-native': workspace:^5.8.11 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 react-native-builder-bob: ^0.21.3 @@ -485,8 +487,8 @@ importers: packages/react-native-skeleton: specifiers: '@shopify/restyle': 2.4.2 - '@td-design/react-native': workspace:^5.8.2 - '@td-design/rn-hooks': workspace:^2.7.4 + '@td-design/react-native': workspace:^5.8.11 + '@td-design/rn-hooks': workspace:^2.8.0 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 react-native-builder-bob: ^0.21.3 @@ -506,8 +508,8 @@ importers: packages/react-native-tabs: specifiers: - '@td-design/react-native': workspace:^5.8.2 - '@td-design/rn-hooks': workspace:^2.7.4 + '@td-design/react-native': workspace:^5.8.11 + '@td-design/rn-hooks': workspace:^2.8.0 '@types/react': 17.0.43 '@types/react-native': ^0.72.2 color: ^4.2.3 @@ -4441,6 +4443,7 @@ packages: /abs-svg-path/0.1.1: resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + dev: true /accepts/1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} @@ -13213,6 +13216,7 @@ packages: resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} dependencies: svg-arc-to-cubic-bezier: 3.2.0 + dev: true /normalize-url/1.9.1: resolution: {integrity: sha512-A48My/mtCklowHBlI8Fq2jFWK4tX4lJ5E6ytFsSOq1fzpvT0SQSgKhSg7lN5c2uYFOrUAOQp6zhhJnpp1eMloQ==} @@ -13794,6 +13798,7 @@ packages: /parse-svg-path/0.1.2: resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + dev: true /parse5/6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} @@ -15773,19 +15778,6 @@ packages: invariant: 2.2.4 dev: true - /react-native-redash/18.1.0: - resolution: {integrity: sha512-bdCFl/ZB7Rf2raIlU6SLV+Dc/rL6UXsQNjEVwTGBukHMeSKp1zs4zVtWaGimbN8P22N4qYvb9Jmw/K94ZWYG0Q==} - peerDependencies: - react: '*' - react-native: '*' - react-native-gesture-handler: '*' - react-native-reanimated: '>=2.0.0' - dependencies: - abs-svg-path: 0.1.1 - normalize-svg-path: 1.1.0 - parse-svg-path: 0.1.2 - dev: false - /react-native-redash/18.1.0_eds2siy2yq354nmpeqe6hp76te: resolution: {integrity: sha512-bdCFl/ZB7Rf2raIlU6SLV+Dc/rL6UXsQNjEVwTGBukHMeSKp1zs4zVtWaGimbN8P22N4qYvb9Jmw/K94ZWYG0Q==} peerDependencies: @@ -17705,6 +17697,7 @@ packages: /svg-arc-to-cubic-bezier/3.2.0: resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==} + dev: true /svg2ttf/4.3.0: resolution: {integrity: sha512-LZ0B7zzHWLWbzLzwaKGHQvPOuxCXLReIb3LSxFSGUy1gMw2Utk6KGNbTmbmRL6Rk1qDSmTixnDrQgnXaL9n0CA==}