@klyftig/mobile-ui 1.3.2 → 1.3.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klyftig/mobile-ui",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "scripts": {
5
5
  "pack-install": "npm pack && mv klyftig-ui-*.tgz /tmp/klyftig-ui-local.tgz && cd ../../mobile && npm install /tmp/klyftig-ui-local.tgz"
6
6
  },
@@ -1,12 +1,11 @@
1
- import React, { useCallback, useEffect, useMemo, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
- FlatList,
3
+ ScrollView,
4
4
  View,
5
5
  Text,
6
6
  StyleSheet,
7
7
  NativeSyntheticEvent,
8
8
  NativeScrollEvent,
9
- ListRenderItem,
10
9
  } from 'react-native';
11
10
  import { useTheme } from '../hooks/useTheme';
12
11
 
@@ -23,6 +22,11 @@ export interface WheelPickerProps {
23
22
 
24
23
  const DEFAULT_ITEM_HEIGHT = 48;
25
24
  const DEFAULT_VISIBLE_COUNT = 5;
25
+ // Render this many rows above and below the active index. The remaining rows
26
+ // are virtual — the scroll container still spans the full height so snapping
27
+ // and momentum behave normally, but we don't mount 600 <Text> nodes on first
28
+ // paint of MeasurementsStep.
29
+ const WINDOW_OVERSCAN = 8;
26
30
 
27
31
  export function WheelPicker({
28
32
  values,
@@ -35,7 +39,7 @@ export function WheelPicker({
35
39
  flex,
36
40
  }: WheelPickerProps) {
37
41
  const { colors } = useTheme();
38
- const listRef = useRef<FlatList<number>>(null);
42
+ const scrollRef = useRef<ScrollView>(null);
39
43
  const containerHeight = itemHeight * visibleCount;
40
44
  const padding = (containerHeight - itemHeight) / 2;
41
45
  const lastIndexRef = useRef<number>(-1);
@@ -45,20 +49,37 @@ export function WheelPicker({
45
49
  return idx >= 0 ? idx : 0;
46
50
  }, [values, selected]);
47
51
 
52
+ // Track the row currently centered under the selection overlay during scroll
53
+ // so the rendered window follows the user's drag, not just the committed
54
+ // `selected` prop (which only updates on momentum end).
55
+ const [windowCenter, setWindowCenter] = useState(selectedIndex);
56
+
48
57
  useEffect(() => {
49
58
  if (selectedIndex === lastIndexRef.current) return;
50
59
  const prev = lastIndexRef.current;
51
60
  lastIndexRef.current = selectedIndex;
52
- // Skip the very first effect — initialScrollIndex already positioned us.
61
+ setWindowCenter(selectedIndex);
62
+ // Skip on the very first effect — contentOffset already positioned us.
53
63
  if (prev === -1) return;
54
- listRef.current?.scrollToOffset({ offset: selectedIndex * itemHeight, animated: false });
64
+ scrollRef.current?.scrollTo({ y: selectedIndex * itemHeight, animated: false });
55
65
  }, [selectedIndex, itemHeight]);
56
66
 
67
+ const handleScroll = useCallback(
68
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
69
+ const offsetY = e.nativeEvent.contentOffset.y;
70
+ const index = Math.round(offsetY / itemHeight);
71
+ const clamped = Math.max(0, Math.min(values.length - 1, index));
72
+ setWindowCenter((prev) => (Math.abs(prev - clamped) >= 2 ? clamped : prev));
73
+ },
74
+ [itemHeight, values.length],
75
+ );
76
+
57
77
  const handleMomentumEnd = useCallback(
58
78
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
59
79
  const offsetY = e.nativeEvent.contentOffset.y;
60
80
  const index = Math.round(offsetY / itemHeight);
61
81
  const clamped = Math.max(0, Math.min(values.length - 1, index));
82
+ setWindowCenter(clamped);
62
83
  if (values[clamped] !== selected) {
63
84
  onChange(values[clamped]);
64
85
  }
@@ -66,38 +87,43 @@ export function WheelPicker({
66
87
  [itemHeight, values, selected, onChange],
67
88
  );
68
89
 
69
- const getItemLayout = useCallback(
70
- (_: ArrayLike<number> | null | undefined, index: number) => ({
71
- length: itemHeight,
72
- offset: itemHeight * index,
73
- index,
74
- }),
75
- [itemHeight],
76
- );
77
-
78
- const keyExtractor = useCallback((item: number) => String(item), []);
90
+ const windowStart = Math.max(0, windowCenter - WINDOW_OVERSCAN);
91
+ const windowEnd = Math.min(values.length - 1, windowCenter + WINDOW_OVERSCAN);
79
92
 
80
93
  const dimColor = colors.text.tertiary;
81
94
  const activeColor = colors.text.primary;
82
95
 
83
- const renderItem: ListRenderItem<number> = useCallback(
84
- ({ item, index }) => {
85
- const isSelected = index === selectedIndex;
86
- return (
87
- <View style={[styles.item, { height: itemHeight }]}>
88
- <Text
89
- style={[
90
- styles.itemText,
91
- { color: isSelected ? activeColor : dimColor },
92
- ]}
93
- >
94
- {formatLabel ? formatLabel(item) : String(item)}
95
- </Text>
96
- </View>
97
- );
98
- },
99
- [selectedIndex, itemHeight, activeColor, dimColor, formatLabel],
100
- );
96
+ const items = [];
97
+ for (let i = windowStart; i <= windowEnd; i++) {
98
+ const value = values[i];
99
+ const isSelected = i === selectedIndex;
100
+ items.push(
101
+ <View
102
+ key={value}
103
+ style={[
104
+ styles.item,
105
+ {
106
+ position: 'absolute',
107
+ top: padding + i * itemHeight,
108
+ left: 0,
109
+ right: 0,
110
+ height: itemHeight,
111
+ },
112
+ ]}
113
+ >
114
+ <Text
115
+ style={[
116
+ styles.itemText,
117
+ { color: isSelected ? activeColor : dimColor },
118
+ ]}
119
+ >
120
+ {formatLabel ? formatLabel(value) : String(value)}
121
+ </Text>
122
+ </View>,
123
+ );
124
+ }
125
+
126
+ const totalContentHeight = values.length * itemHeight + padding * 2;
101
127
 
102
128
  return (
103
129
  <View style={[styles.wrapper, { height: containerHeight, width, flex }]}>
@@ -108,25 +134,19 @@ export function WheelPicker({
108
134
  { top: padding, height: itemHeight, backgroundColor: 'rgba(0,0,0,0.06)' },
109
135
  ]}
110
136
  />
111
- <FlatList
112
- ref={listRef}
113
- data={values}
114
- keyExtractor={keyExtractor}
115
- renderItem={renderItem}
116
- getItemLayout={getItemLayout}
117
- initialScrollIndex={selectedIndex}
118
- extraData={selectedIndex}
137
+ <ScrollView
138
+ ref={scrollRef}
119
139
  showsVerticalScrollIndicator={false}
120
140
  snapToInterval={itemHeight}
121
141
  decelerationRate="fast"
122
- contentContainerStyle={{ paddingTop: padding, paddingBottom: padding }}
142
+ onScroll={handleScroll}
143
+ scrollEventThrottle={32}
123
144
  onMomentumScrollEnd={handleMomentumEnd}
124
145
  nestedScrollEnabled
125
- initialNumToRender={visibleCount + 4}
126
- windowSize={3}
127
- maxToRenderPerBatch={visibleCount + 4}
128
- removeClippedSubviews
129
- />
146
+ contentOffset={{ x: 0, y: selectedIndex * itemHeight }}
147
+ >
148
+ <View style={{ height: totalContentHeight }}>{items}</View>
149
+ </ScrollView>
130
150
  </View>
131
151
  );
132
152
  }