@klyftig/mobile-ui 1.3.1 → 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.1",
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,4 +1,4 @@
1
- import React, { useEffect, useMemo, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  ScrollView,
4
4
  View,
@@ -22,6 +22,11 @@ export interface WheelPickerProps {
22
22
 
23
23
  const DEFAULT_ITEM_HEIGHT = 48;
24
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;
25
30
 
26
31
  export function WheelPicker({
27
32
  values,
@@ -44,23 +49,81 @@ export function WheelPicker({
44
49
  return idx >= 0 ? idx : 0;
45
50
  }, [values, selected]);
46
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
+
47
57
  useEffect(() => {
48
- if (selectedIndex !== lastIndexRef.current) {
49
- lastIndexRef.current = selectedIndex;
50
- requestAnimationFrame(() => {
51
- scrollRef.current?.scrollTo({ y: selectedIndex * itemHeight, animated: false });
52
- });
53
- }
58
+ if (selectedIndex === lastIndexRef.current) return;
59
+ const prev = lastIndexRef.current;
60
+ lastIndexRef.current = selectedIndex;
61
+ setWindowCenter(selectedIndex);
62
+ // Skip on the very first effect — contentOffset already positioned us.
63
+ if (prev === -1) return;
64
+ scrollRef.current?.scrollTo({ y: selectedIndex * itemHeight, animated: false });
54
65
  }, [selectedIndex, itemHeight]);
55
66
 
56
- const handleMomentumEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
57
- const offsetY = e.nativeEvent.contentOffset.y;
58
- const index = Math.round(offsetY / itemHeight);
59
- const clamped = Math.max(0, Math.min(values.length - 1, index));
60
- if (values[clamped] !== selected) {
61
- onChange(values[clamped]);
62
- }
63
- };
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
+
77
+ const handleMomentumEnd = useCallback(
78
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
79
+ const offsetY = e.nativeEvent.contentOffset.y;
80
+ const index = Math.round(offsetY / itemHeight);
81
+ const clamped = Math.max(0, Math.min(values.length - 1, index));
82
+ setWindowCenter(clamped);
83
+ if (values[clamped] !== selected) {
84
+ onChange(values[clamped]);
85
+ }
86
+ },
87
+ [itemHeight, values, selected, onChange],
88
+ );
89
+
90
+ const windowStart = Math.max(0, windowCenter - WINDOW_OVERSCAN);
91
+ const windowEnd = Math.min(values.length - 1, windowCenter + WINDOW_OVERSCAN);
92
+
93
+ const dimColor = colors.text.tertiary;
94
+ const activeColor = colors.text.primary;
95
+
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;
64
127
 
65
128
  return (
66
129
  <View style={[styles.wrapper, { height: containerHeight, width, flex }]}>
@@ -76,28 +139,13 @@ export function WheelPicker({
76
139
  showsVerticalScrollIndicator={false}
77
140
  snapToInterval={itemHeight}
78
141
  decelerationRate="fast"
79
- contentContainerStyle={{ paddingTop: padding, paddingBottom: padding }}
142
+ onScroll={handleScroll}
143
+ scrollEventThrottle={32}
80
144
  onMomentumScrollEnd={handleMomentumEnd}
81
145
  nestedScrollEnabled
82
146
  contentOffset={{ x: 0, y: selectedIndex * itemHeight }}
83
147
  >
84
- {values.map((item, index) => {
85
- const isSelected = index === selectedIndex;
86
- return (
87
- <View key={item} style={[styles.item, { height: itemHeight }]}>
88
- <Text
89
- style={[
90
- styles.itemText,
91
- {
92
- color: isSelected ? colors.text.primary : colors.text.tertiary,
93
- },
94
- ]}
95
- >
96
- {formatLabel ? formatLabel(item) : String(item)}
97
- </Text>
98
- </View>
99
- );
100
- })}
148
+ <View style={{ height: totalContentHeight }}>{items}</View>
101
149
  </ScrollView>
102
150
  </View>
103
151
  );