@klyftig/mobile-ui 1.5.0 → 1.6.0

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.5.0",
3
+ "version": "1.6.0",
4
4
  "scripts": {
5
5
  "sync": "bash scripts/sync-to-kaelo.sh",
6
6
  "pack-install": "npm pack && mv klyftig-mobile-ui-*.tgz /tmp/klyftig-mobile-ui-local.tgz && cd ../kaelo/mobile && npm install /tmp/klyftig-mobile-ui-local.tgz"
@@ -13,12 +13,23 @@ import { colors } from '../theme/colors';
13
13
  export interface WheelPickerProps {
14
14
  values: number[];
15
15
  selected: number;
16
- onChange: (value: number) => void;
16
+ /**
17
+ * `wraps` (only with `loop`): how many times this scroll went past the end
18
+ * of the list — +1 for 59 → 00 going down, -1 for 00 → 59 going up. A
19
+ * caller can carry it, e.g. bump the hour when the minutes wrap.
20
+ */
21
+ onChange: (value: number, info: { wraps: number }) => void;
17
22
  formatLabel?: (value: number) => string;
18
23
  itemHeight?: number;
19
24
  visibleCount?: number;
20
25
  width?: number;
21
26
  flex?: number;
27
+ /**
28
+ * Wrap around (59 → 00 → 01, and back from 00 to 59), like a clock. The
29
+ * values are laid out LOOP_COPIES times and the wheel recentres on the
30
+ * middle copy after every scroll, so it never reaches an end in practice.
31
+ */
32
+ loop?: boolean;
22
33
  }
23
34
 
24
35
  const DEFAULT_ITEM_HEIGHT = 48;
@@ -32,6 +43,9 @@ const DEFAULT_VISIBLE_COUNT = 3;
32
43
  // and momentum behave normally, but we don't mount 600 <Text> nodes on first
33
44
  // paint of MeasurementsStep.
34
45
  const WINDOW_OVERSCAN = 8;
46
+ // Odd, so there is a middle copy to start in and snap back to. 21 copies of
47
+ // 60 minutes is 1260 rows — only the overscan window is mounted.
48
+ const LOOP_COPIES = 21;
35
49
 
36
50
  export function WheelPicker({
37
51
  values,
@@ -42,6 +56,7 @@ export function WheelPicker({
42
56
  visibleCount = DEFAULT_VISIBLE_COUNT,
43
57
  width,
44
58
  flex,
59
+ loop = false,
45
60
  }: WheelPickerProps) {
46
61
  const { colors } = useTheme();
47
62
  const scrollRef = useRef<ScrollView>(null);
@@ -49,10 +64,18 @@ export function WheelPicker({
49
64
  const padding = (containerHeight - itemHeight) / 2;
50
65
  const lastIndexRef = useRef<number>(-1);
51
66
 
52
- const selectedIndex = useMemo(() => {
67
+ // Rows are virtual indices; with `loop` they span LOOP_COPIES copies of
68
+ // `values` and row i shows values[i mod n].
69
+ const n = values.length;
70
+ const rowCount = loop ? n * LOOP_COPIES : n;
71
+ const middleStart = loop ? Math.floor(LOOP_COPIES / 2) * n : 0;
72
+ const valueIndexOf = useCallback((row: number) => ((row % n) + n) % n, [n]);
73
+
74
+ const valueIndex = useMemo(() => {
53
75
  const idx = values.indexOf(selected);
54
76
  return idx >= 0 ? idx : 0;
55
77
  }, [values, selected]);
78
+ const selectedIndex = middleStart + valueIndex;
56
79
 
57
80
  // Track the row currently centered under the selection overlay during scroll
58
81
  // so the rendered window follows the user's drag, not just the committed
@@ -73,38 +96,49 @@ export function WheelPicker({
73
96
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
74
97
  const offsetY = e.nativeEvent.contentOffset.y;
75
98
  const index = Math.round(offsetY / itemHeight);
76
- const clamped = Math.max(0, Math.min(values.length - 1, index));
99
+ const clamped = Math.max(0, Math.min(rowCount - 1, index));
77
100
  setWindowCenter((prev) => (Math.abs(prev - clamped) >= 2 ? clamped : prev));
78
101
  },
79
- [itemHeight, values.length],
102
+ [itemHeight, rowCount],
80
103
  );
81
104
 
82
105
  const handleMomentumEnd = useCallback(
83
106
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
84
107
  const offsetY = e.nativeEvent.contentOffset.y;
85
108
  const index = Math.round(offsetY / itemHeight);
86
- const clamped = Math.max(0, Math.min(values.length - 1, index));
87
- setWindowCenter(clamped);
88
- if (values[clamped] !== selected) {
89
- onChange(values[clamped]);
109
+ const clamped = Math.max(0, Math.min(rowCount - 1, index));
110
+ const value = values[valueIndexOf(clamped)];
111
+ // Every scroll starts in the middle copy, so copies crossed = wraps.
112
+ const wraps = loop ? Math.floor(clamped / n) - Math.floor(LOOP_COPIES / 2) : 0;
113
+ let row = clamped;
114
+ if (loop) {
115
+ // Jump back to the same value in the middle copy (invisible: the rows
116
+ // look identical) so the next fling has room either way.
117
+ row = middleStart + valueIndexOf(clamped);
118
+ if (row !== clamped) scrollRef.current?.scrollTo({ y: row * itemHeight, animated: false });
119
+ lastIndexRef.current = row;
120
+ }
121
+ setWindowCenter(row);
122
+ if (value !== selected || wraps !== 0) {
123
+ onChange(value, { wraps });
90
124
  }
91
125
  },
92
- [itemHeight, values, selected, onChange],
126
+ [itemHeight, n, rowCount, values, valueIndexOf, loop, middleStart, selected, onChange],
93
127
  );
94
128
 
95
129
  const windowStart = Math.max(0, windowCenter - WINDOW_OVERSCAN);
96
- const windowEnd = Math.min(values.length - 1, windowCenter + WINDOW_OVERSCAN);
130
+ const windowEnd = Math.min(rowCount - 1, windowCenter + WINDOW_OVERSCAN);
97
131
 
98
132
  const dimColor = colors.text.tertiary;
99
133
  const activeColor = colors.text.primary;
100
134
 
101
135
  const items = [];
102
136
  for (let i = windowStart; i <= windowEnd; i++) {
103
- const value = values[i];
137
+ const value = values[valueIndexOf(i)];
104
138
  const isSelected = i === selectedIndex;
105
139
  items.push(
106
140
  <View
107
- key={value}
141
+ key={i}
108
142
  style={[
109
143
  styles.item,
110
144
  {
@@ -128,7 +162,7 @@ export function WheelPicker({
128
162
  );
129
163
  }
130
164
 
131
- const totalContentHeight = values.length * itemHeight + padding * 2;
165
+ const totalContentHeight = rowCount * itemHeight + padding * 2;
132
166
 
133
167
  return (
134
168
  <View style={[styles.wrapper, { height: containerHeight, width, flex }]}>