zkit-ui 2.0.9 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,263 @@
1
+ import * as React from 'react';
2
+ import { FlashList, type FlashListRef, type ListRenderItemInfo } from '@shopify/flash-list';
3
+ import {
4
+ StyleSheet,
5
+ Text,
6
+ View,
7
+ type ColorValue,
8
+ type NativeScrollEvent,
9
+ type NativeSyntheticEvent,
10
+ type StyleProp,
11
+ type TextStyle,
12
+ } from 'react-native';
13
+ import { getMaxFontSizeMultiplier, sp, wp } from 'zkit-tools';
14
+ import type { WheelColumnOption } from './index';
15
+
16
+ export type AndroidVirtualizedWheelHandle = {
17
+ scrollToIndex: (index: number, animated?: boolean) => void;
18
+ settleToNearest: (animated?: boolean) => number;
19
+ };
20
+
21
+ type AndroidVirtualizedWheelProps = {
22
+ options: WheelColumnOption[];
23
+ selectedIndex: number;
24
+ canInteract: boolean;
25
+ itemHeight: number;
26
+ visibleItems: number;
27
+ itemTextColor: ColorValue;
28
+ selectedTextColor: ColorValue;
29
+ disabledTextColor: ColorValue;
30
+ itemTextStyle?: StyleProp<TextStyle>;
31
+ selectedItemTextStyle?: StyleProp<TextStyle>;
32
+ disabledItemTextStyle?: StyleProp<TextStyle>;
33
+ numberOfLines: number;
34
+ resolveSelectableIndex: (index: number, direction?: number) => number;
35
+ onSelectIndex: (index: number) => void;
36
+ };
37
+
38
+ const DISABLED_MAINTAIN_VISIBLE_CONTENT_POSITION = { disabled: true } as const;
39
+
40
+ function clampNumber(value: number, min: number, max: number) {
41
+ return Math.max(min, Math.min(max, value));
42
+ }
43
+
44
+ function optionKey(option: WheelColumnOption, index: number) {
45
+ return `${String(option.key ?? option.value ?? index)}-${index}`;
46
+ }
47
+
48
+ export const AndroidVirtualizedWheel = React.forwardRef<AndroidVirtualizedWheelHandle, AndroidVirtualizedWheelProps>(
49
+ function AndroidVirtualizedWheel(
50
+ {
51
+ options,
52
+ selectedIndex,
53
+ canInteract,
54
+ itemHeight,
55
+ visibleItems,
56
+ itemTextColor,
57
+ selectedTextColor,
58
+ disabledTextColor,
59
+ itemTextStyle,
60
+ selectedItemTextStyle,
61
+ disabledItemTextStyle,
62
+ numberOfLines,
63
+ resolveSelectableIndex,
64
+ onSelectIndex,
65
+ },
66
+ ref
67
+ ) {
68
+ const listRef = React.useRef<FlashListRef<WheelColumnOption>>(null);
69
+ const offsetRef = React.useRef(selectedIndex * itemHeight);
70
+ const selectedIndexRef = React.useRef(selectedIndex);
71
+ const userScrollingRef = React.useRef(false);
72
+ const momentumScrollingRef = React.useRef(false);
73
+ const settleFrameRef = React.useRef<number | null>(null);
74
+ const maxIndex = Math.max(0, options.length - 1);
75
+ const maxOffset = maxIndex * itemHeight;
76
+ const centerOffset = itemHeight * Math.floor(visibleItems / 2);
77
+
78
+ selectedIndexRef.current = selectedIndex;
79
+
80
+ const cancelPendingSettle = React.useCallback(() => {
81
+ if (settleFrameRef.current == null) return;
82
+ cancelAnimationFrame(settleFrameRef.current);
83
+ settleFrameRef.current = null;
84
+ }, []);
85
+
86
+ const scrollToIndex = React.useCallback(
87
+ (index: number, animated = false) => {
88
+ const nextIndex = clampNumber(Math.round(index), 0, maxIndex);
89
+ const nextOffset = nextIndex * itemHeight;
90
+ cancelPendingSettle();
91
+ offsetRef.current = nextOffset;
92
+ listRef.current?.scrollToOffset({
93
+ offset: nextOffset,
94
+ animated,
95
+ skipFirstItemOffset: true,
96
+ });
97
+ },
98
+ [cancelPendingSettle, itemHeight, maxIndex]
99
+ );
100
+
101
+ const settleToNearest = React.useCallback(
102
+ (animated = false) => {
103
+ const rawIndex = Math.round(clampNumber(offsetRef.current, 0, maxOffset) / itemHeight);
104
+ const nextIndex = resolveSelectableIndex(rawIndex, rawIndex - selectedIndexRef.current);
105
+ scrollToIndex(nextIndex, animated);
106
+ return nextIndex;
107
+ },
108
+ [itemHeight, maxOffset, resolveSelectableIndex, scrollToIndex]
109
+ );
110
+
111
+ React.useImperativeHandle(ref, () => ({ scrollToIndex, settleToNearest }), [scrollToIndex, settleToNearest]);
112
+
113
+ React.useEffect(() => {
114
+ scrollToIndex(selectedIndex, false);
115
+ }, [options.length, scrollToIndex, selectedIndex]);
116
+
117
+ React.useEffect(() => cancelPendingSettle, [cancelPendingSettle]);
118
+
119
+ const finishUserScroll = React.useCallback(
120
+ (offset: number) => {
121
+ if (!userScrollingRef.current) return;
122
+
123
+ userScrollingRef.current = false;
124
+ momentumScrollingRef.current = false;
125
+ offsetRef.current = clampNumber(offset, 0, maxOffset);
126
+ const rawIndex = Math.round(offsetRef.current / itemHeight);
127
+ const nextIndex = resolveSelectableIndex(rawIndex, rawIndex - selectedIndexRef.current);
128
+ scrollToIndex(nextIndex, nextIndex !== rawIndex);
129
+ onSelectIndex(nextIndex);
130
+ },
131
+ [itemHeight, maxOffset, onSelectIndex, resolveSelectableIndex, scrollToIndex]
132
+ );
133
+
134
+ const handleScroll = React.useCallback(
135
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
136
+ offsetRef.current = clampNumber(event.nativeEvent.contentOffset.y, 0, maxOffset);
137
+ },
138
+ [maxOffset]
139
+ );
140
+
141
+ const handleScrollBeginDrag = React.useCallback(() => {
142
+ cancelPendingSettle();
143
+ userScrollingRef.current = true;
144
+ momentumScrollingRef.current = false;
145
+ }, [cancelPendingSettle]);
146
+
147
+ const handleScrollEndDrag = React.useCallback(
148
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
149
+ const offset = event.nativeEvent.contentOffset.y;
150
+ offsetRef.current = clampNumber(offset, 0, maxOffset);
151
+ cancelPendingSettle();
152
+ settleFrameRef.current = requestAnimationFrame(() => {
153
+ settleFrameRef.current = null;
154
+ if (!momentumScrollingRef.current) {
155
+ finishUserScroll(offsetRef.current);
156
+ }
157
+ });
158
+ },
159
+ [cancelPendingSettle, finishUserScroll, maxOffset]
160
+ );
161
+
162
+ const handleMomentumScrollBegin = React.useCallback(() => {
163
+ momentumScrollingRef.current = true;
164
+ cancelPendingSettle();
165
+ }, [cancelPendingSettle]);
166
+
167
+ const handleMomentumScrollEnd = React.useCallback(
168
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
169
+ finishUserScroll(event.nativeEvent.contentOffset.y);
170
+ },
171
+ [finishUserScroll]
172
+ );
173
+
174
+ const contentContainerStyle = React.useMemo(() => ({ paddingVertical: centerOffset }), [centerOffset]);
175
+ const renderItem = React.useCallback(
176
+ ({ item, index }: ListRenderItemInfo<WheelColumnOption>) => (
177
+ <View style={[styles.itemContainer, { height: itemHeight }]}>
178
+ <Text
179
+ accessibilityLabel={item.accessibilityLabel ?? item.label}
180
+ maxFontSizeMultiplier={getMaxFontSizeMultiplier()}
181
+ numberOfLines={numberOfLines}
182
+ style={[
183
+ styles.itemText,
184
+ { color: itemTextColor },
185
+ itemTextStyle,
186
+ index === selectedIndex && [styles.itemTextSelected, { color: selectedTextColor }, selectedItemTextStyle],
187
+ item.disabled && [styles.itemTextDisabled, { color: disabledTextColor }, disabledItemTextStyle],
188
+ ]}
189
+ testID={item.testID}
190
+ >
191
+ {item.label}
192
+ </Text>
193
+ </View>
194
+ ),
195
+ [
196
+ disabledItemTextStyle,
197
+ disabledTextColor,
198
+ itemHeight,
199
+ itemTextColor,
200
+ itemTextStyle,
201
+ numberOfLines,
202
+ selectedIndex,
203
+ selectedItemTextStyle,
204
+ selectedTextColor,
205
+ ]
206
+ );
207
+ const handleLoad = React.useCallback(() => {
208
+ scrollToIndex(selectedIndex, false);
209
+ }, [scrollToIndex, selectedIndex]);
210
+
211
+ return (
212
+ <FlashList
213
+ ref={listRef}
214
+ data={options}
215
+ contentContainerStyle={contentContainerStyle}
216
+ decelerationRate="fast"
217
+ drawDistance={itemHeight * (visibleItems + 2)}
218
+ extraData={selectedIndex}
219
+ keyExtractor={optionKey}
220
+ keyboardShouldPersistTaps="handled"
221
+ maintainVisibleContentPosition={DISABLED_MAINTAIN_VISIBLE_CONTENT_POSITION}
222
+ nestedScrollEnabled
223
+ onMomentumScrollBegin={handleMomentumScrollBegin}
224
+ onMomentumScrollEnd={handleMomentumScrollEnd}
225
+ onLoad={handleLoad}
226
+ onScroll={handleScroll}
227
+ onScrollBeginDrag={handleScrollBeginDrag}
228
+ onScrollEndDrag={handleScrollEndDrag}
229
+ overScrollMode="never"
230
+ renderItem={renderItem}
231
+ scrollEnabled={canInteract}
232
+ scrollEventThrottle={16}
233
+ showsVerticalScrollIndicator={false}
234
+ snapToAlignment="start"
235
+ snapToInterval={itemHeight}
236
+ style={styles.list}
237
+ />
238
+ );
239
+ }
240
+ );
241
+
242
+ const styles = StyleSheet.create({
243
+ list: {
244
+ flex: 1,
245
+ width: '100%',
246
+ },
247
+ itemContainer: {
248
+ alignItems: 'center',
249
+ justifyContent: 'center',
250
+ paddingHorizontal: wp(8),
251
+ },
252
+ itemText: {
253
+ fontSize: sp(18),
254
+ fontWeight: '500',
255
+ textAlign: 'center',
256
+ },
257
+ itemTextSelected: {
258
+ opacity: 1,
259
+ },
260
+ itemTextDisabled: {
261
+ opacity: 0.62,
262
+ },
263
+ });
@@ -7,7 +7,7 @@
7
7
  ## 设计
8
8
 
9
9
  - iOS 使用原生 `UIPickerView`,保留系统惯性、停靠和无障碍行为。
10
- - Android 使用自绘滚轮路径,核心动画只维护整列一个 `translateY` shared value,减少多列同时滚动时的重渲染和 JS 抖动。
10
+ - Android 使用基于 FlashList 的原生虚拟化滚动与停靠,只挂载视口附近的选项;它可以直接响应原生 Sheet / Modal 内的纵向拖动,大范围日期选项也不需要一次性创建全部原生文本节点。
11
11
  - Web 复用自绘路径,至少保证拖拽、键盘无障碍动作和视觉停靠语义一致。
12
12
  - 禁用选项不会被用户滚动最终选中;如果外部受控值指向禁用项,组件会展示该值,但用户下一次交互会停靠到最近可用项。
13
13