@multiplatform.one/forms 7.35.0 → 7.37.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/dist/cjs/Wheel/{WheelEdgeFade.cjs → NativeWheel.cjs} +5 -5
- package/dist/cjs/Wheel/NativeWheel.native.js +408 -0
- package/dist/cjs/Wheel/NativeWheel.native.js.map +1 -0
- package/dist/cjs/Wheel/haptic.cjs +3 -4
- package/dist/cjs/Wheel/haptic.native.js +4 -5
- package/dist/cjs/Wheel/haptic.native.js.map +1 -1
- package/dist/cjs/Wheel/index.cjs +62 -218
- package/dist/cjs/Wheel/index.native.js +54 -228
- package/dist/cjs/Wheel/index.native.js.map +1 -1
- package/dist/cjs/Wheel/wheelShared.cjs +156 -0
- package/dist/cjs/Wheel/wheelShared.native.js +169 -0
- package/dist/cjs/Wheel/wheelShared.native.js.map +1 -0
- package/dist/cjs/shared/TimeColumn.cjs +0 -9
- package/dist/cjs/shared/TimeColumn.native.js +0 -9
- package/dist/cjs/shared/TimeColumn.native.js.map +1 -1
- package/dist/esm/Wheel/NativeWheel.mjs +5 -0
- package/dist/esm/Wheel/NativeWheel.mjs.map +1 -0
- package/dist/esm/Wheel/NativeWheel.native.js +380 -0
- package/dist/esm/Wheel/NativeWheel.native.js.map +1 -0
- package/dist/esm/Wheel/haptic.mjs +3 -4
- package/dist/esm/Wheel/haptic.mjs.map +1 -1
- package/dist/esm/Wheel/haptic.native.js +4 -5
- package/dist/esm/Wheel/haptic.native.js.map +1 -1
- package/dist/esm/Wheel/index.mjs +49 -205
- package/dist/esm/Wheel/index.mjs.map +1 -1
- package/dist/esm/Wheel/index.native.js +41 -215
- package/dist/esm/Wheel/index.native.js.map +1 -1
- package/dist/esm/Wheel/wheelShared.mjs +119 -0
- package/dist/esm/Wheel/wheelShared.mjs.map +1 -0
- package/dist/esm/Wheel/wheelShared.native.js +129 -0
- package/dist/esm/Wheel/wheelShared.native.js.map +1 -0
- package/dist/esm/shared/TimeColumn.mjs +0 -9
- package/dist/esm/shared/TimeColumn.mjs.map +1 -1
- package/dist/esm/shared/TimeColumn.native.js +0 -9
- package/dist/esm/shared/TimeColumn.native.js.map +1 -1
- package/dist/jsx/Wheel/NativeWheel.mjs +5 -0
- package/dist/jsx/Wheel/NativeWheel.mjs.map +1 -0
- package/dist/jsx/Wheel/NativeWheel.native.js +408 -0
- package/dist/jsx/Wheel/NativeWheel.native.js.map +1 -0
- package/dist/jsx/Wheel/haptic.mjs +3 -4
- package/dist/jsx/Wheel/haptic.mjs.map +1 -1
- package/dist/jsx/Wheel/haptic.native.js +4 -5
- package/dist/jsx/Wheel/haptic.native.js.map +1 -1
- package/dist/jsx/Wheel/index.mjs +49 -205
- package/dist/jsx/Wheel/index.mjs.map +1 -1
- package/dist/jsx/Wheel/index.native.js +54 -228
- package/dist/jsx/Wheel/index.native.js.map +1 -1
- package/dist/jsx/Wheel/wheelShared.mjs +119 -0
- package/dist/jsx/Wheel/wheelShared.mjs.map +1 -0
- package/dist/jsx/Wheel/wheelShared.native.js +169 -0
- package/dist/jsx/Wheel/wheelShared.native.js.map +1 -0
- package/dist/jsx/shared/TimeColumn.mjs +0 -9
- package/dist/jsx/shared/TimeColumn.mjs.map +1 -1
- package/dist/jsx/shared/TimeColumn.native.js +0 -9
- package/dist/jsx/shared/TimeColumn.native.js.map +1 -1
- package/package.json +8 -8
- package/src/Wheel/NativeWheel.native.spec.tsx +181 -0
- package/src/Wheel/NativeWheel.native.tsx +430 -0
- package/src/Wheel/NativeWheel.tsx +6 -0
- package/src/Wheel/haptic.ts +9 -8
- package/src/Wheel/index.tsx +121 -363
- package/src/Wheel/wheelShared.spec.ts +53 -0
- package/src/Wheel/wheelShared.ts +222 -0
- package/src/shared/TimeColumn.tsx +0 -7
- package/types/Wheel/NativeWheel.d.ts +4 -0
- package/types/Wheel/NativeWheel.d.ts.map +1 -0
- package/types/Wheel/NativeWheel.native.d.ts +9 -0
- package/types/Wheel/NativeWheel.native.d.ts.map +1 -0
- package/types/Wheel/haptic.d.ts +4 -2
- package/types/Wheel/haptic.d.ts.map +1 -1
- package/types/Wheel/index.d.ts +4 -49
- package/types/Wheel/index.d.ts.map +1 -1
- package/types/Wheel/wheelShared.d.ts +87 -0
- package/types/Wheel/wheelShared.d.ts.map +1 -0
- package/types/shared/TimeColumn.d.ts.map +1 -1
- package/dist/cjs/Wheel/WheelEdgeFade.native.js +0 -116
- package/dist/cjs/Wheel/WheelEdgeFade.native.js.map +0 -1
- package/dist/esm/Wheel/WheelEdgeFade.mjs +0 -5
- package/dist/esm/Wheel/WheelEdgeFade.mjs.map +0 -1
- package/dist/esm/Wheel/WheelEdgeFade.native.js +0 -88
- package/dist/esm/Wheel/WheelEdgeFade.native.js.map +0 -1
- package/dist/jsx/Wheel/WheelEdgeFade.mjs +0 -5
- package/dist/jsx/Wheel/WheelEdgeFade.mjs.map +0 -1
- package/dist/jsx/Wheel/WheelEdgeFade.native.js +0 -116
- package/dist/jsx/Wheel/WheelEdgeFade.native.js.map +0 -1
- package/src/Wheel/WheelEdgeFade.native.tsx +0 -67
- package/src/Wheel/WheelEdgeFade.spec.tsx +0 -141
- package/src/Wheel/WheelEdgeFade.tsx +0 -7
- package/types/Wheel/WheelEdgeFade.d.ts +0 -8
- package/types/Wheel/WheelEdgeFade.d.ts.map +0 -1
- package/types/Wheel/WheelEdgeFade.native.d.ts +0 -8
- package/types/Wheel/WheelEdgeFade.native.d.ts.map +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { act, cleanup } from "@testing-library/react";
|
|
2
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { NativeWheel } from "./NativeWheel.native";
|
|
6
|
+
import { fontLineHeightPx, tokenPx } from "./wheelShared";
|
|
7
|
+
|
|
8
|
+
type ScrollHandler = (...args: unknown[]) => void;
|
|
9
|
+
type ScrollProps = { children?: ReactNode } & Partial<Record<string, ScrollHandler | ReactNode>>;
|
|
10
|
+
|
|
11
|
+
const scroll = vi.hoisted(() => ({
|
|
12
|
+
props: null as ScrollProps | null,
|
|
13
|
+
scrollTo: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("tamagui", async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<typeof import("tamagui")>()),
|
|
18
|
+
isWeb: false,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// The vitest config rewrites `from "react-native"` to react-native-web.
|
|
22
|
+
vi.mock("react-native-web", async (importOriginal) => {
|
|
23
|
+
const rn = await importOriginal<typeof import("react-native")>();
|
|
24
|
+
const { forwardRef, useImperativeHandle } = await import("react");
|
|
25
|
+
const ScrollView = forwardRef<unknown, ScrollProps>(function ScrollView(props, ref) {
|
|
26
|
+
scroll.props = props;
|
|
27
|
+
useImperativeHandle(ref, () => ({ scrollTo: scroll.scrollTo }));
|
|
28
|
+
return <div data-testid="scroll">{props.children as ReactNode}</div>;
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
...rn,
|
|
32
|
+
Animated: {
|
|
33
|
+
...rn.Animated,
|
|
34
|
+
ScrollView,
|
|
35
|
+
event: (_mapping: unknown, config: { listener: (e: unknown) => void }) => config.listener,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
cleanup();
|
|
42
|
+
scroll.props = null;
|
|
43
|
+
scroll.scrollTo.mockReset();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const hours = Array.from({ length: 24 }, (_, h) => ({
|
|
47
|
+
value: h,
|
|
48
|
+
label: String(h).padStart(2, "0"),
|
|
49
|
+
}));
|
|
50
|
+
const fire = (name: string, arg?: unknown) =>
|
|
51
|
+
(scroll.props?.[name] as ScrollHandler | undefined)?.(arg);
|
|
52
|
+
const at = (y: number) => ({ nativeEvent: { contentOffset: { x: 0, y } } });
|
|
53
|
+
const rowHeight = () => (scroll.props as unknown as { snapToInterval: number }).snapToInterval;
|
|
54
|
+
|
|
55
|
+
describe("native Wheel on a UIScrollView", () => {
|
|
56
|
+
it("snaps to whole rows with the platform's own momentum", () => {
|
|
57
|
+
renderWithProviders(<NativeWheel items={hours} value={9} />);
|
|
58
|
+
const props = scroll.props as unknown as Record<string, unknown>;
|
|
59
|
+
expect(props.snapToInterval).toBeGreaterThan(0);
|
|
60
|
+
expect(props.decelerationRate).toBe("normal");
|
|
61
|
+
expect(props.contentOffset).toEqual({ x: 0, y: 9 * rowHeight() });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("sizes rows from the font's line height plus the $1 space token", () => {
|
|
65
|
+
renderWithProviders(<NativeWheel items={hours} value={9} size="$8" />);
|
|
66
|
+
expect(rowHeight()).toBe(Math.ceil(fontLineHeightPx("$8", "$body") + tokenPx("$1", "space")));
|
|
67
|
+
expect(rowHeight()).toBeGreaterThan(fontLineHeightPx("$8", "$body"));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("reports exactly the row centred when a fling comes to rest", () => {
|
|
71
|
+
const onChange = vi.fn();
|
|
72
|
+
renderWithProviders(<NativeWheel items={hours} value={9} onChange={onChange} />);
|
|
73
|
+
const h = rowHeight();
|
|
74
|
+
act(() => {
|
|
75
|
+
fire("onScrollBeginDrag");
|
|
76
|
+
fire("onScroll", at(11.4 * h));
|
|
77
|
+
fire("onScrollEndDrag");
|
|
78
|
+
fire("onMomentumScrollBegin");
|
|
79
|
+
fire("onScroll", at(14 * h));
|
|
80
|
+
fire("onMomentumScrollEnd", at(14 * h));
|
|
81
|
+
});
|
|
82
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(14);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("seats a row the snap left between two rows before reporting it", () => {
|
|
86
|
+
const onChange = vi.fn();
|
|
87
|
+
renderWithProviders(<NativeWheel items={hours} value={9} onChange={onChange} />);
|
|
88
|
+
const h = rowHeight();
|
|
89
|
+
act(() => {
|
|
90
|
+
fire("onMomentumScrollEnd", at(5.6 * h));
|
|
91
|
+
});
|
|
92
|
+
expect(scroll.scrollTo).toHaveBeenCalledWith({ y: 6 * h, animated: expect.any(Boolean) });
|
|
93
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(6);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("fires onItemCross for each row that crosses the band", () => {
|
|
97
|
+
const onItemCross = vi.fn();
|
|
98
|
+
renderWithProviders(<NativeWheel items={hours} value={0} onItemCross={onItemCross} />);
|
|
99
|
+
const h = rowHeight();
|
|
100
|
+
act(() => {
|
|
101
|
+
fire("onScroll", at(0.6 * h));
|
|
102
|
+
fire("onScroll", at(1.2 * h));
|
|
103
|
+
fire("onScroll", at(1.6 * h));
|
|
104
|
+
});
|
|
105
|
+
expect(onItemCross.mock.calls).toEqual([
|
|
106
|
+
[1, 1],
|
|
107
|
+
[2, 2],
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("loops: a looping column lays out copies and reports the wrapped value", () => {
|
|
112
|
+
const onChange = vi.fn();
|
|
113
|
+
renderWithProviders(<NativeWheel items={hours} value={23} loop onChange={onChange} />);
|
|
114
|
+
const h = rowHeight();
|
|
115
|
+
const start = (scroll.props as unknown as { contentOffset: { y: number } }).contentOffset.y;
|
|
116
|
+
act(() => {
|
|
117
|
+
fire("onMomentumScrollEnd", at(start + h));
|
|
118
|
+
});
|
|
119
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("is one adjustable element named for the column", () => {
|
|
123
|
+
const { getByTestId } = renderWithProviders(
|
|
124
|
+
<NativeWheel items={hours} value={9} aria-label="Hour" />,
|
|
125
|
+
);
|
|
126
|
+
const wheel = getByTestId("wheel");
|
|
127
|
+
expect(wheel.getAttribute("role")).toBe("slider");
|
|
128
|
+
expect(wheel.getAttribute("aria-label")).toBe("Hour");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("taps a row on the drum to bring it to the band", () => {
|
|
132
|
+
const onChange = vi.fn();
|
|
133
|
+
renderWithProviders(<NativeWheel items={hours} value={0} onChange={onChange} />);
|
|
134
|
+
const h = rowHeight();
|
|
135
|
+
const frame = h * 5;
|
|
136
|
+
const radius = frame / 2 / Math.sin((80 * Math.PI) / 180);
|
|
137
|
+
const twoBelow = frame / 2 + radius * Math.sin((2 * h) / radius);
|
|
138
|
+
act(() => {
|
|
139
|
+
fire("onTouchStart", { nativeEvent: { pageY: 500, locationY: twoBelow } });
|
|
140
|
+
fire("onTouchEnd", { nativeEvent: { pageY: 501, locationY: twoBelow } });
|
|
141
|
+
});
|
|
142
|
+
expect(scroll.scrollTo).toHaveBeenCalledWith({ y: 2 * h, animated: expect.any(Boolean) });
|
|
143
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("seats and reports the row when a touch stops a fling (no momentum end on iOS)", () => {
|
|
147
|
+
vi.useFakeTimers();
|
|
148
|
+
try {
|
|
149
|
+
const onChange = vi.fn();
|
|
150
|
+
renderWithProviders(<NativeWheel items={hours} value={9} onChange={onChange} />);
|
|
151
|
+
const h = rowHeight();
|
|
152
|
+
act(() => {
|
|
153
|
+
fire("onScrollBeginDrag");
|
|
154
|
+
fire("onScrollEndDrag");
|
|
155
|
+
fire("onMomentumScrollBegin");
|
|
156
|
+
fire("onScroll", at(15.3 * h));
|
|
157
|
+
fire("onTouchStart", { nativeEvent: { pageY: 400, locationY: 0 } });
|
|
158
|
+
fire("onTouchEnd", { nativeEvent: { pageY: 400, locationY: 0 } });
|
|
159
|
+
vi.advanceTimersByTime(500);
|
|
160
|
+
});
|
|
161
|
+
expect(scroll.scrollTo).toHaveBeenCalledWith({ y: 15 * h, animated: expect.any(Boolean) });
|
|
162
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(15);
|
|
163
|
+
} finally {
|
|
164
|
+
vi.useRealTimers();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("reseats a value-less column when its items shrink under it", () => {
|
|
169
|
+
const onChange = vi.fn();
|
|
170
|
+
const { rerender } = renderWithProviders(
|
|
171
|
+
<NativeWheel items={hours} value={20} onChange={onChange} />,
|
|
172
|
+
);
|
|
173
|
+
rerender(<NativeWheel items={hours.slice(0, 10)} value={20} onChange={onChange} />);
|
|
174
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(9);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does not scroll while disabled", () => {
|
|
178
|
+
renderWithProviders(<NativeWheel items={hours} value={9} disabled />);
|
|
179
|
+
expect((scroll.props as unknown as { scrollEnabled: boolean }).scrollEnabled).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Animated,
|
|
5
|
+
type AccessibilityActionEvent,
|
|
6
|
+
type GestureResponderEvent,
|
|
7
|
+
type NativeScrollEvent,
|
|
8
|
+
type NativeSyntheticEvent,
|
|
9
|
+
View as RNView,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
import { Text, View } from "tamagui";
|
|
12
|
+
import { t } from "../shared/t";
|
|
13
|
+
import { fireSelectionHaptic } from "./haptic";
|
|
14
|
+
import {
|
|
15
|
+
defaultColumnWidth,
|
|
16
|
+
drumPose,
|
|
17
|
+
drumRadius,
|
|
18
|
+
drumReachRows,
|
|
19
|
+
getItemLabel,
|
|
20
|
+
getItemValue,
|
|
21
|
+
modIndex,
|
|
22
|
+
nearestRowForDataIndex,
|
|
23
|
+
useWheelLayout,
|
|
24
|
+
type WheelProps,
|
|
25
|
+
} from "./wheelShared";
|
|
26
|
+
|
|
27
|
+
/** Rows a looping wheel lays out, so a fling never reaches the ends before it recentres. */
|
|
28
|
+
const loopRowBudget = 1200;
|
|
29
|
+
const tapSlopPx = 8;
|
|
30
|
+
const tapMaxMs = 300;
|
|
31
|
+
const settleFallbackMs = 120;
|
|
32
|
+
const poseStep = 0.25;
|
|
33
|
+
|
|
34
|
+
interface DrumRowProps {
|
|
35
|
+
row: number;
|
|
36
|
+
scrollY: Animated.Value;
|
|
37
|
+
itemHeight: number;
|
|
38
|
+
frameHeight: number;
|
|
39
|
+
padTop: number;
|
|
40
|
+
reach: number;
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One row on the drum. Its pose is a native-driver interpolation of the
|
|
46
|
+
* scroll offset, so rotateX, scale and opacity track the finger on the UI
|
|
47
|
+
* thread with no JS frame in between.
|
|
48
|
+
*/
|
|
49
|
+
const DrumRow = memo(function DrumRow({
|
|
50
|
+
row,
|
|
51
|
+
scrollY,
|
|
52
|
+
itemHeight,
|
|
53
|
+
frameHeight,
|
|
54
|
+
padTop,
|
|
55
|
+
reach,
|
|
56
|
+
children,
|
|
57
|
+
}: DrumRowProps) {
|
|
58
|
+
const style = useMemo(() => {
|
|
59
|
+
const span = 2 * (Math.ceil(reach) + 1);
|
|
60
|
+
const input: number[] = [];
|
|
61
|
+
const translate: number[] = [];
|
|
62
|
+
const rotate: string[] = [];
|
|
63
|
+
const scale: number[] = [];
|
|
64
|
+
const opacity: number[] = [];
|
|
65
|
+
for (let d = span; d >= -span - 1e-9; d -= poseStep) {
|
|
66
|
+
const pose = drumPose(d, itemHeight, frameHeight);
|
|
67
|
+
input.push((row - d) * itemHeight);
|
|
68
|
+
translate.push(pose.offset - d * itemHeight);
|
|
69
|
+
rotate.push(`${pose.rotateX}deg`);
|
|
70
|
+
scale.push(pose.scale);
|
|
71
|
+
opacity.push(pose.opacity);
|
|
72
|
+
}
|
|
73
|
+
const range = (output: number[] | string[]) =>
|
|
74
|
+
scrollY.interpolate({ inputRange: input, outputRange: output, extrapolate: "clamp" });
|
|
75
|
+
return {
|
|
76
|
+
position: "absolute" as const,
|
|
77
|
+
left: 0,
|
|
78
|
+
right: 0,
|
|
79
|
+
top: padTop + row * itemHeight,
|
|
80
|
+
height: itemHeight,
|
|
81
|
+
alignItems: "center" as const,
|
|
82
|
+
justifyContent: "center" as const,
|
|
83
|
+
opacity: range(opacity),
|
|
84
|
+
transform: [
|
|
85
|
+
{ perspective: frameHeight * 4 },
|
|
86
|
+
{ translateY: range(translate) },
|
|
87
|
+
{ rotateX: range(rotate) },
|
|
88
|
+
{ scale: range(scale) },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}, [row, scrollY, itemHeight, frameHeight, padTop, reach]);
|
|
92
|
+
return (
|
|
93
|
+
<Animated.View pointerEvents="none" style={style}>
|
|
94
|
+
{children}
|
|
95
|
+
</Animated.View>
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* UIPickerView on a real UIScrollView: the platform's own momentum, rubber
|
|
101
|
+
* band and paging (snapToInterval) drive the wheel, and nested inside a
|
|
102
|
+
* screen's ScrollView the inner one owns the drag, so the column never drags
|
|
103
|
+
* its page along with it.
|
|
104
|
+
*/
|
|
105
|
+
export function NativeWheel<T = string | number>(props: WheelProps<T>) {
|
|
106
|
+
const {
|
|
107
|
+
items,
|
|
108
|
+
value,
|
|
109
|
+
onChange,
|
|
110
|
+
width: widthProp,
|
|
111
|
+
renderItem,
|
|
112
|
+
disabled = false,
|
|
113
|
+
loop = false,
|
|
114
|
+
flex,
|
|
115
|
+
onItemCross,
|
|
116
|
+
"aria-label": ariaLabel,
|
|
117
|
+
} = props;
|
|
118
|
+
const layout = useWheelLayout(props);
|
|
119
|
+
const { itemHeight, frameHeight, size, knobProps, snapDurationMs } = layout;
|
|
120
|
+
const n = items.length;
|
|
121
|
+
const loopEnabled = Boolean(loop && n > 1);
|
|
122
|
+
const copies = loopEnabled ? Math.max(3, Math.ceil(loopRowBudget / n)) | 1 : 1;
|
|
123
|
+
const totalRows = n * copies;
|
|
124
|
+
const middleBase = loopEnabled ? Math.floor(copies / 2) * n : 0;
|
|
125
|
+
const padTop = (frameHeight - itemHeight) / 2;
|
|
126
|
+
const reach = drumReachRows(itemHeight, frameHeight);
|
|
127
|
+
const columnWidth = widthProp ?? defaultColumnWidth;
|
|
128
|
+
|
|
129
|
+
const indexOfValue = useCallback(
|
|
130
|
+
(v: T | undefined) => (v === undefined ? -1 : items.findIndex((it) => getItemValue(it) === v)),
|
|
131
|
+
[items],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const initialIndex = Math.max(0, indexOfValue(value));
|
|
135
|
+
const initialRow = middleBase + initialIndex;
|
|
136
|
+
const scrollY = useRef(new Animated.Value(initialRow * itemHeight)).current;
|
|
137
|
+
// iOS shifts the live offset by any change to the contentOffset prop, which
|
|
138
|
+
// would add to an in-flight scrollTo, so the prop is fixed at mount.
|
|
139
|
+
const [initialOffset] = useState(() => ({ x: 0, y: initialRow * itemHeight }));
|
|
140
|
+
const scrollRef = useRef<{ scrollTo: (o: { y: number; animated?: boolean }) => void } | null>(
|
|
141
|
+
null,
|
|
142
|
+
);
|
|
143
|
+
const [centerRow, setCenterRow] = useState(initialRow);
|
|
144
|
+
const centerRowRef = useRef(initialRow);
|
|
145
|
+
const yRef = useRef(initialRow * itemHeight);
|
|
146
|
+
const committedIndexRef = useRef(initialIndex);
|
|
147
|
+
const [committedIndex, setCommittedIndex] = useState(initialIndex);
|
|
148
|
+
const programmaticRef = useRef(false);
|
|
149
|
+
const draggingRef = useRef(false);
|
|
150
|
+
const momentumRef = useRef(false);
|
|
151
|
+
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
152
|
+
const touchRef = useRef<{ pageY: number; time: number; stoppedMotion: boolean } | null>(null);
|
|
153
|
+
const dragBeganRef = useRef(false);
|
|
154
|
+
const onChangeRef = useRef(onChange);
|
|
155
|
+
onChangeRef.current = onChange;
|
|
156
|
+
const onItemCrossRef = useRef(onItemCross);
|
|
157
|
+
onItemCrossRef.current = onItemCross;
|
|
158
|
+
|
|
159
|
+
const scrollToRow = useCallback(
|
|
160
|
+
(row: number, animated: boolean) => {
|
|
161
|
+
const y = row * itemHeight;
|
|
162
|
+
programmaticRef.current = animated && Math.abs(y - yRef.current) > 0.5;
|
|
163
|
+
if (!animated) {
|
|
164
|
+
yRef.current = y;
|
|
165
|
+
scrollY.setValue(y);
|
|
166
|
+
centerRowRef.current = row;
|
|
167
|
+
setCenterRow(row);
|
|
168
|
+
}
|
|
169
|
+
scrollRef.current?.scrollTo({ y, animated });
|
|
170
|
+
},
|
|
171
|
+
[itemHeight, scrollY],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const commit = useCallback(
|
|
175
|
+
(dataIndex: number) => {
|
|
176
|
+
if (n === 0 || dataIndex === committedIndexRef.current) return;
|
|
177
|
+
committedIndexRef.current = dataIndex;
|
|
178
|
+
setCommittedIndex(dataIndex);
|
|
179
|
+
const item = items[dataIndex];
|
|
180
|
+
if (item !== undefined) onChangeRef.current?.(getItemValue(item));
|
|
181
|
+
},
|
|
182
|
+
[items, n],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
/** Called once motion stops: seat the nearest row exactly, then report it. */
|
|
186
|
+
const settle = useCallback(() => {
|
|
187
|
+
if (n === 0 || draggingRef.current) return;
|
|
188
|
+
programmaticRef.current = false;
|
|
189
|
+
const y = yRef.current;
|
|
190
|
+
const row = Math.max(0, Math.min(totalRows - 1, Math.round(y / itemHeight)));
|
|
191
|
+
const dataIndex = modIndex(row, n);
|
|
192
|
+
if (Math.abs(y - row * itemHeight) > 0.5) {
|
|
193
|
+
scrollToRow(row, snapDurationMs > 0);
|
|
194
|
+
} else if (loopEnabled && Math.abs(row - (middleBase + dataIndex)) >= n) {
|
|
195
|
+
scrollToRow(middleBase + dataIndex, false);
|
|
196
|
+
}
|
|
197
|
+
commit(dataIndex);
|
|
198
|
+
}, [n, totalRows, itemHeight, scrollToRow, snapDurationMs, loopEnabled, middleBase, commit]);
|
|
199
|
+
|
|
200
|
+
const clearSettleTimer = () => {
|
|
201
|
+
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
|
|
202
|
+
settleTimerRef.current = null;
|
|
203
|
+
};
|
|
204
|
+
useEffect(() => clearSettleTimer, []);
|
|
205
|
+
|
|
206
|
+
const onScroll = useMemo(
|
|
207
|
+
() =>
|
|
208
|
+
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
|
|
209
|
+
useNativeDriver: true,
|
|
210
|
+
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
211
|
+
const y = e.nativeEvent.contentOffset.y;
|
|
212
|
+
yRef.current = y;
|
|
213
|
+
const row = Math.round(y / itemHeight);
|
|
214
|
+
if (row === centerRowRef.current) return;
|
|
215
|
+
centerRowRef.current = row;
|
|
216
|
+
setCenterRow(row);
|
|
217
|
+
if (row < 0 || row >= totalRows || n === 0 || programmaticRef.current) return;
|
|
218
|
+
const dataIndex = modIndex(row, n);
|
|
219
|
+
const item = items[dataIndex];
|
|
220
|
+
if (item === undefined) return;
|
|
221
|
+
onItemCrossRef.current?.(getItemValue(item), dataIndex);
|
|
222
|
+
fireSelectionHaptic();
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
[scrollY, itemHeight, totalRows, n, items],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const index = indexOfValue(value);
|
|
230
|
+
if (index < 0 || draggingRef.current || momentumRef.current) return;
|
|
231
|
+
if (index === committedIndexRef.current && modIndex(centerRowRef.current, n) === index) return;
|
|
232
|
+
committedIndexRef.current = index;
|
|
233
|
+
setCommittedIndex(index);
|
|
234
|
+
const row = loopEnabled ? nearestRowForDataIndex(centerRowRef.current, index, n) : index;
|
|
235
|
+
scrollToRow(row, snapDurationMs > 0);
|
|
236
|
+
}, [value, indexOfValue, loopEnabled, n, scrollToRow, snapDurationMs]);
|
|
237
|
+
|
|
238
|
+
const seatedHeightRef = useRef(itemHeight);
|
|
239
|
+
useLayoutEffect(() => {
|
|
240
|
+
if (seatedHeightRef.current === itemHeight) return;
|
|
241
|
+
seatedHeightRef.current = itemHeight;
|
|
242
|
+
scrollToRow(centerRowRef.current, false);
|
|
243
|
+
}, [itemHeight, scrollToRow]);
|
|
244
|
+
|
|
245
|
+
const seatedCountRef = useRef(n);
|
|
246
|
+
useLayoutEffect(() => {
|
|
247
|
+
if (seatedCountRef.current === n || n === 0) return;
|
|
248
|
+
seatedCountRef.current = n;
|
|
249
|
+
const index = indexOfValue(value);
|
|
250
|
+
const dataIndex = index >= 0 ? index : Math.min(committedIndexRef.current, n - 1);
|
|
251
|
+
scrollToRow(middleBase + dataIndex, false);
|
|
252
|
+
commit(dataIndex);
|
|
253
|
+
}, [n, value, indexOfValue, middleBase, scrollToRow, commit]);
|
|
254
|
+
|
|
255
|
+
const step = useCallback(
|
|
256
|
+
(delta: number) => {
|
|
257
|
+
if (n === 0 || disabled) return;
|
|
258
|
+
const current = committedIndexRef.current;
|
|
259
|
+
const next = loopEnabled
|
|
260
|
+
? modIndex(current + delta, n)
|
|
261
|
+
: Math.max(0, Math.min(n - 1, current + delta));
|
|
262
|
+
if (next === current) return;
|
|
263
|
+
const row = loopEnabled ? nearestRowForDataIndex(centerRowRef.current, next, n) : next;
|
|
264
|
+
scrollToRow(row, snapDurationMs > 0);
|
|
265
|
+
commit(next);
|
|
266
|
+
},
|
|
267
|
+
[n, disabled, loopEnabled, scrollToRow, snapDurationMs, commit],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const onAccessibilityAction = useCallback(
|
|
271
|
+
(e: AccessibilityActionEvent) => {
|
|
272
|
+
if (e.nativeEvent.actionName === "increment") step(1);
|
|
273
|
+
if (e.nativeEvent.actionName === "decrement") step(-1);
|
|
274
|
+
},
|
|
275
|
+
[step],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const onTouchStart = useCallback((e: GestureResponderEvent) => {
|
|
279
|
+
// A touch that stops a fling never gets onMomentumScrollEnd on iOS.
|
|
280
|
+
const stoppedMotion = momentumRef.current || programmaticRef.current;
|
|
281
|
+
momentumRef.current = false;
|
|
282
|
+
dragBeganRef.current = false;
|
|
283
|
+
clearSettleTimer();
|
|
284
|
+
touchRef.current = { pageY: e.nativeEvent.pageY, time: Date.now(), stoppedMotion };
|
|
285
|
+
}, []);
|
|
286
|
+
|
|
287
|
+
const onTouchEnd = useCallback(
|
|
288
|
+
(e: GestureResponderEvent) => {
|
|
289
|
+
const touch = touchRef.current;
|
|
290
|
+
touchRef.current = null;
|
|
291
|
+
if (!touch || disabled || n === 0 || dragBeganRef.current) return;
|
|
292
|
+
const isTap =
|
|
293
|
+
Math.abs(e.nativeEvent.pageY - touch.pageY) <= tapSlopPx &&
|
|
294
|
+
Date.now() - touch.time <= tapMaxMs;
|
|
295
|
+
if (touch.stoppedMotion || !isTap) {
|
|
296
|
+
settleTimerRef.current = setTimeout(() => {
|
|
297
|
+
if (!momentumRef.current && !draggingRef.current) settle();
|
|
298
|
+
}, settleFallbackMs);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const radius = drumRadius(frameHeight);
|
|
302
|
+
// Rows ignore touches, so locationY is in the content view's space.
|
|
303
|
+
const fromCenter = e.nativeEvent.locationY - yRef.current - frameHeight / 2;
|
|
304
|
+
const theta = Math.asin(Math.max(-1, Math.min(1, fromCenter / radius)));
|
|
305
|
+
const rows = Math.round((theta * radius) / itemHeight);
|
|
306
|
+
if (rows === 0) return;
|
|
307
|
+
const row = Math.max(0, Math.min(totalRows - 1, centerRowRef.current + rows));
|
|
308
|
+
scrollToRow(row, snapDurationMs > 0);
|
|
309
|
+
commit(modIndex(row, n));
|
|
310
|
+
},
|
|
311
|
+
[disabled, n, frameHeight, itemHeight, totalRows, scrollToRow, snapDurationMs, commit, settle],
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const span = Math.ceil(reach) + 1;
|
|
315
|
+
const rows: number[] = [];
|
|
316
|
+
for (let r = centerRow - span; r <= centerRow + span; r++) {
|
|
317
|
+
if (r >= 0 && r < totalRows) rows.push(r);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const committedItem = items[committedIndex];
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<RNView
|
|
324
|
+
style={{
|
|
325
|
+
height: frameHeight,
|
|
326
|
+
width: flex ? "100%" : columnWidth,
|
|
327
|
+
minWidth: flex ? 0 : columnWidth,
|
|
328
|
+
flex,
|
|
329
|
+
position: "relative",
|
|
330
|
+
overflow: "hidden",
|
|
331
|
+
}}
|
|
332
|
+
testID="wheel"
|
|
333
|
+
accessible
|
|
334
|
+
accessibilityRole="adjustable"
|
|
335
|
+
accessibilityLabel={ariaLabel ?? t("Value picker")}
|
|
336
|
+
accessibilityState={{ disabled }}
|
|
337
|
+
accessibilityValue={{
|
|
338
|
+
text: committedItem !== undefined ? getItemLabel(committedItem) : undefined,
|
|
339
|
+
}}
|
|
340
|
+
accessibilityActions={disabled ? [] : [{ name: "increment" }, { name: "decrement" }]}
|
|
341
|
+
onAccessibilityAction={onAccessibilityAction}
|
|
342
|
+
>
|
|
343
|
+
<View
|
|
344
|
+
className={layout.highlightClassName}
|
|
345
|
+
position="absolute"
|
|
346
|
+
left={0}
|
|
347
|
+
right={0}
|
|
348
|
+
top={padTop}
|
|
349
|
+
height={itemHeight}
|
|
350
|
+
backgroundColor="$color4"
|
|
351
|
+
pointerEvents="none"
|
|
352
|
+
style={layout.highlightStyle as never}
|
|
353
|
+
data-testid="wheel-selection"
|
|
354
|
+
/>
|
|
355
|
+
<Animated.ScrollView
|
|
356
|
+
ref={scrollRef as never}
|
|
357
|
+
testID="wheel-wrapper"
|
|
358
|
+
style={{ flex: 1 }}
|
|
359
|
+
contentOffset={initialOffset}
|
|
360
|
+
showsVerticalScrollIndicator={false}
|
|
361
|
+
scrollEnabled={!disabled && n > 0}
|
|
362
|
+
nestedScrollEnabled
|
|
363
|
+
snapToInterval={itemHeight}
|
|
364
|
+
decelerationRate="normal"
|
|
365
|
+
scrollEventThrottle={16}
|
|
366
|
+
onScroll={onScroll}
|
|
367
|
+
onTouchStart={onTouchStart}
|
|
368
|
+
onTouchEnd={onTouchEnd}
|
|
369
|
+
onScrollBeginDrag={() => {
|
|
370
|
+
clearSettleTimer();
|
|
371
|
+
draggingRef.current = true;
|
|
372
|
+
dragBeganRef.current = true;
|
|
373
|
+
programmaticRef.current = false;
|
|
374
|
+
}}
|
|
375
|
+
onScrollEndDrag={() => {
|
|
376
|
+
draggingRef.current = false;
|
|
377
|
+
clearSettleTimer();
|
|
378
|
+
settleTimerRef.current = setTimeout(() => {
|
|
379
|
+
if (!momentumRef.current) settle();
|
|
380
|
+
}, settleFallbackMs);
|
|
381
|
+
}}
|
|
382
|
+
onMomentumScrollBegin={() => {
|
|
383
|
+
clearSettleTimer();
|
|
384
|
+
momentumRef.current = true;
|
|
385
|
+
}}
|
|
386
|
+
onMomentumScrollEnd={(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
387
|
+
momentumRef.current = false;
|
|
388
|
+
yRef.current = e.nativeEvent.contentOffset.y;
|
|
389
|
+
settle();
|
|
390
|
+
}}
|
|
391
|
+
>
|
|
392
|
+
<RNView style={{ height: totalRows * itemHeight + padTop * 2 }}>
|
|
393
|
+
{rows.map((r) => {
|
|
394
|
+
const dataIndex = modIndex(r, n);
|
|
395
|
+
const item = items[dataIndex];
|
|
396
|
+
const isSelected = r === centerRow;
|
|
397
|
+
return (
|
|
398
|
+
<DrumRow
|
|
399
|
+
key={r}
|
|
400
|
+
row={r}
|
|
401
|
+
scrollY={scrollY}
|
|
402
|
+
itemHeight={itemHeight}
|
|
403
|
+
frameHeight={frameHeight}
|
|
404
|
+
padTop={padTop}
|
|
405
|
+
reach={reach}
|
|
406
|
+
>
|
|
407
|
+
{renderItem ? (
|
|
408
|
+
renderItem(getItemValue(item), dataIndex, isSelected)
|
|
409
|
+
) : (
|
|
410
|
+
<Text
|
|
411
|
+
{...knobProps.body}
|
|
412
|
+
fontSize={size}
|
|
413
|
+
color={disabled ? "$color10" : isSelected ? "$color12" : "$color11"}
|
|
414
|
+
numberOfLines={1}
|
|
415
|
+
paddingHorizontal="$2"
|
|
416
|
+
data-testid="wheel-item"
|
|
417
|
+
data-index={dataIndex}
|
|
418
|
+
data-selected={isSelected ? true : undefined}
|
|
419
|
+
>
|
|
420
|
+
{getItemLabel(item)}
|
|
421
|
+
</Text>
|
|
422
|
+
)}
|
|
423
|
+
</DrumRow>
|
|
424
|
+
);
|
|
425
|
+
})}
|
|
426
|
+
</RNView>
|
|
427
|
+
</Animated.ScrollView>
|
|
428
|
+
</RNView>
|
|
429
|
+
);
|
|
430
|
+
}
|
package/src/Wheel/haptic.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { isWeb } from "tamagui";
|
|
2
2
|
|
|
3
|
+
type HapticsModule = { selectionAsync?: () => Promise<void> };
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
|
-
* iOS UIPickerView
|
|
5
|
-
*
|
|
6
|
+
* iOS UIPickerView ticks with UISelectionFeedbackGenerator. The native module
|
|
7
|
+
* is looked up in the Expo registry instead of importing expo-haptics, so a
|
|
8
|
+
* binary built without it (the Kitchen Sink) stays silent and the wheel still
|
|
9
|
+
* ships over the air.
|
|
6
10
|
*/
|
|
7
11
|
export function fireSelectionHaptic(): void {
|
|
8
12
|
if (isWeb) return;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} catch {
|
|
13
|
-
// optional peer
|
|
14
|
-
}
|
|
13
|
+
const registry = (globalThis as { expo?: { modules?: Record<string, unknown> } }).expo?.modules;
|
|
14
|
+
const haptics = registry?.ExpoHaptics as HapticsModule | undefined;
|
|
15
|
+
void haptics?.selectionAsync?.()?.catch(() => {});
|
|
15
16
|
}
|