panelui-native 0.97.0 → 0.98.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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The numbers a deck is made of, kept apart from the views that draw them so
3
+ * each rule can be checked on its own.
4
+ *
5
+ * Every function here is a worklet: they run inside the pan handler and the
6
+ * animated styles, on the UI thread, and none of them touches anything but
7
+ * its arguments.
8
+ */
9
+
10
+ /** Which way a card leaves the deck. */
11
+ export type StackCardDirection = 'left' | 'right' | 'up' | 'down';
12
+
13
+ /**
14
+ * iOS's scroll deceleration, and the constant behind `project`.
15
+ *
16
+ * Momentum is what lets a short fast flick count and a long slow drag not.
17
+ * Without it the only question a release can answer is how far the finger
18
+ * travelled, and a deck read that way feels heavy in the hand.
19
+ */
20
+ export const DECELERATION = 0.998;
21
+
22
+ /**
23
+ * Where a flick would come to rest if it kept decelerating, in points.
24
+ *
25
+ * The exponential-decay form the platform uses, not the `v² / 2a` from
26
+ * physics: the two disagree most at exactly the speeds a thumb produces.
27
+ */
28
+ export function project(velocity: number, deceleration?: number): number {
29
+ 'worklet';
30
+ /*
31
+ * The fallback is read here and not written as a default parameter.
32
+ *
33
+ * A worklet carries copies of the outside names it uses, and they are
34
+ * collected from its body — a default in the signature is not part of that
35
+ * body, so the constant never travels and the name is simply absent on the
36
+ * UI thread. That throws a ReferenceError inside a gesture handler, where
37
+ * nothing catches it: it leaves the worklet runtime as a C++ exception and
38
+ * aborts the process, with no JavaScript frames and no red screen.
39
+ */
40
+ const rate = deceleration ?? DECELERATION;
41
+ return ((velocity / 1000) * rate) / (1 - rate);
42
+ }
43
+
44
+ /**
45
+ * How far a finger gets on an axis the deck does not accept, given how far it
46
+ * pushed.
47
+ *
48
+ * Asymptotic rather than clamped. A card that stops dead at a limit reads as
49
+ * broken; one that keeps moving a little further for a lot more travel reads
50
+ * as held.
51
+ */
52
+ export function resist(travel: number, span: number, give: number): number {
53
+ 'worklet';
54
+ if (span <= 0) return 0;
55
+ return (travel * span * give) / (span + give * Math.abs(travel));
56
+ }
57
+
58
+ /** How far a card has to be taken for a release to send it away, in points. */
59
+ export function commitReach(span: number, threshold: number): number {
60
+ 'worklet';
61
+ return Math.max(span * threshold, 1);
62
+ }
63
+
64
+ /**
65
+ * How far a drag has carried a card toward leaving in one direction, 0 to 1.
66
+ *
67
+ * One number, and three things read it: the stamp for that direction fades in
68
+ * on it, the card behind climbs into place on it, and the haptic fires when it
69
+ * first reaches 1.
70
+ */
71
+ export function directionProgress(
72
+ direction: StackCardDirection,
73
+ x: number,
74
+ y: number,
75
+ width: number,
76
+ height: number,
77
+ threshold: number
78
+ ): number {
79
+ 'worklet';
80
+ const sideways = direction === 'left' || direction === 'right';
81
+ const reach = commitReach(sideways ? width : height, threshold);
82
+ const travel =
83
+ direction === 'left' ? -x : direction === 'right' ? x : direction === 'up' ? -y : y;
84
+ return Math.min(Math.max(travel / reach, 0), 1);
85
+ }
86
+
87
+ /** The furthest any accepted direction has been carried, 0 to 1. */
88
+ export function releaseProgress(
89
+ allowed: readonly StackCardDirection[],
90
+ x: number,
91
+ y: number,
92
+ width: number,
93
+ height: number,
94
+ threshold: number
95
+ ): number {
96
+ 'worklet';
97
+ let furthest = 0;
98
+ for (let index = 0; index < allowed.length; index += 1) {
99
+ const direction = allowed[index];
100
+ if (!direction) continue;
101
+ const progress = directionProgress(direction, x, y, width, height, threshold);
102
+ if (progress > furthest) furthest = progress;
103
+ }
104
+ return furthest;
105
+ }
106
+
107
+ /**
108
+ * Which way a lifted finger sent the card, or `null` to put it back.
109
+ *
110
+ * Distance and momentum are added before the comparison rather than tested one
111
+ * after the other, so there is a single rule: a card leaves when the finger
112
+ * was going to take it past the reach, whether it got there or not.
113
+ *
114
+ * Where two directions both qualify — a diagonal fling in a deck that takes
115
+ * all four — the one carried furthest wins, so the card goes where it was
116
+ * actually thrown.
117
+ */
118
+ export function releasedDirection(
119
+ allowed: readonly StackCardDirection[],
120
+ x: number,
121
+ y: number,
122
+ velocityX: number,
123
+ velocityY: number,
124
+ width: number,
125
+ height: number,
126
+ threshold: number
127
+ ): StackCardDirection | null {
128
+ 'worklet';
129
+ const projectedX = x + project(velocityX);
130
+ const projectedY = y + project(velocityY);
131
+
132
+ let sent: StackCardDirection | null = null;
133
+ // Starting at one is the threshold itself: a direction has to reach the
134
+ // whole reach before it is a candidate at all.
135
+ let furthest = 1;
136
+ for (let index = 0; index < allowed.length; index += 1) {
137
+ const direction = allowed[index];
138
+ if (!direction) continue;
139
+ const sideways = direction === 'left' || direction === 'right';
140
+ const reach = commitReach(sideways ? width : height, threshold);
141
+ const travel =
142
+ direction === 'left'
143
+ ? -projectedX
144
+ : direction === 'right'
145
+ ? projectedX
146
+ : direction === 'up'
147
+ ? -projectedY
148
+ : projectedY;
149
+ // Compared on the raw ratio rather than on `directionProgress`, which is
150
+ // capped at one: a straight fling and a nudge that barely qualifies would
151
+ // otherwise tie, and the nudge would win on order.
152
+ const ratio = travel / reach;
153
+ if (ratio >= furthest) {
154
+ furthest = ratio;
155
+ sent = direction;
156
+ }
157
+ }
158
+ return sent;
159
+ }
160
+
161
+ /** How far past its own edge a card is sent, as a multiple of the card. */
162
+ const EXIT_OVERSHOOT = 1.35;
163
+
164
+ /** How much of a diagonal a card keeps on its way out. */
165
+ const EXIT_CARRY = 1.4;
166
+
167
+ /**
168
+ * Where a card is animated to on its way off.
169
+ *
170
+ * The off-axis is carried rather than zeroed, so a card thrown up and to the
171
+ * right leaves up and to the right. Zeroing it straightens the throw out
172
+ * mid-flight, which is the one moment the animation is most obviously not the
173
+ * finger any more.
174
+ */
175
+ export function exitTarget(
176
+ direction: StackCardDirection,
177
+ width: number,
178
+ height: number,
179
+ x: number,
180
+ y: number
181
+ ): { x: number; y: number } {
182
+ 'worklet';
183
+ switch (direction) {
184
+ case 'left':
185
+ return { x: -width * EXIT_OVERSHOOT, y: y * EXIT_CARRY };
186
+ case 'right':
187
+ return { x: width * EXIT_OVERSHOOT, y: y * EXIT_CARRY };
188
+ case 'up':
189
+ return { x: x * EXIT_CARRY, y: -height * EXIT_OVERSHOOT };
190
+ default:
191
+ return { x: x * EXIT_CARRY, y: height * EXIT_OVERSHOOT };
192
+ }
193
+ }
194
+
195
+ /**
196
+ * How long a card takes to leave, in milliseconds.
197
+ *
198
+ * Long enough that the card starts out at the speed the finger let go of it.
199
+ * An ease-out starts `slope` times faster than its average, so covering
200
+ * `distance` from `speed` takes `slope × distance / speed`. Faster than that
201
+ * and the card leaps ahead of the finger; slower, and it drags as though it
202
+ * had caught on something.
203
+ *
204
+ * Clamped both ways. A hard flick would otherwise be gone in a frame or two,
205
+ * which reads as the card vanishing rather than being thrown; and a card
206
+ * released barely moving would drift off for a second. `speed` is only what
207
+ * was travelling toward the way out — none at all, for a card sent by a
208
+ * button, gives the longest.
209
+ */
210
+ export function exitDuration(
211
+ distance: number,
212
+ speed: number,
213
+ slope: number,
214
+ shortest: number,
215
+ longest: number
216
+ ): number {
217
+ 'worklet';
218
+ if (speed <= 0) return longest;
219
+ const matched = ((slope * Math.abs(distance)) / speed) * 1000;
220
+ return Math.min(Math.max(matched, shortest), longest);
221
+ }
222
+
223
+ /**
224
+ * Which way the card pivots, from where it was taken hold of.
225
+ *
226
+ * A card pulled by its top corner turns one way and one pulled by its bottom
227
+ * corner turns the other, because that is what a piece of card on a table
228
+ * does. Read once when the finger lands and held for the whole gesture: a
229
+ * lever that changed sign mid-drag would flip the card over in the hand.
230
+ */
231
+ export function lever(grabY: number, height: number): number {
232
+ 'worklet';
233
+ if (height <= 0) return 1;
234
+ return grabY <= height / 2 ? 1 : -1;
235
+ }
236
+
237
+ /**
238
+ * The card's tilt, in degrees.
239
+ *
240
+ * Measured against half the card rather than all of it: a card commits at
241
+ * roughly a third of its width, and scaled across the full width the tilt at
242
+ * the moment of release would be a couple of degrees — present in the numbers
243
+ * and invisible on the screen.
244
+ */
245
+ export function tiltAngle(x: number, width: number, maxTilt: number, pivot: number): number {
246
+ 'worklet';
247
+ if (width <= 0) return 0;
248
+ const raw = (x / (width * 0.5)) * maxTilt * pivot;
249
+ return Math.max(-maxTilt, Math.min(maxTilt, raw));
250
+ }
251
+
252
+ /**
253
+ * Where a card sits in the pile, counting the top one as 0, once the drag on
254
+ * the card in front of it is taken into account.
255
+ *
256
+ * This is what makes the pile continuous across a dismissal. By the time the
257
+ * top card has been carried far enough to leave, the one behind it has already
258
+ * arrived at the top position — so when it becomes the top card there is
259
+ * nothing left to move.
260
+ */
261
+ export function effectiveDepth(depth: number, release: number): number {
262
+ 'worklet';
263
+ return Math.max(0, depth - release);
264
+ }
265
+
266
+ /**
267
+ * How opaque a card at a given depth is drawn, where `visible` is how many the
268
+ * pile shows behind its top card.
269
+ *
270
+ * One more card is kept than the pile shows, and this is what fades it in: one
271
+ * place further back than the pile goes it is invisible, and it reaches full
272
+ * strength exactly as it takes the last visible place. Without it a card
273
+ * appears out of nothing at the bottom of the pile every time one leaves the
274
+ * top.
275
+ */
276
+ export function depthOpacity(depth: number, visible: number): number {
277
+ 'worklet';
278
+ return Math.min(1, Math.max(0, visible + 1 - depth));
279
+ }
package/src/index.ts CHANGED
@@ -1251,6 +1251,20 @@ export {
1251
1251
  type SplitViewHandleProps,
1252
1252
  type SplitViewVariant,
1253
1253
  } from './components/split-view';
1254
+ export {
1255
+ StackCard,
1256
+ useStackCard,
1257
+ type StackCardProps,
1258
+ type StackCardHandle,
1259
+ type StackCardCardProps,
1260
+ type StackCardStampProps,
1261
+ type StackCardEmptyProps,
1262
+ type StackCardActionsProps,
1263
+ type StackCardActionProps,
1264
+ type StackCardDirection,
1265
+ type StackCardLayout,
1266
+ type StackCardStampColor,
1267
+ } from './components/stack-card';
1254
1268
  export {
1255
1269
  Steps,
1256
1270
  type StepsProps,