panelui-native 0.82.0 → 0.83.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.
@@ -25,6 +25,15 @@ export const DOT_LIT_RADIUS = 1.9;
25
25
  /** How visible the resting grid is, under everything. */
26
26
  export const DOT_REST_OPACITY = 0.16;
27
27
 
28
+ /**
29
+ * How visible the lit dots are, together.
30
+ *
31
+ * "Together" is the operative word: they are drawn as two layers fading into
32
+ * one another, and this is what the pair composites to, not what either one
33
+ * carries. {@link crossfadeAlphas} is what holds that true.
34
+ */
35
+ export const LIT_OPACITY = 0.9;
36
+
28
37
  /** How many still frames one loop is drawn as. */
29
38
  export const FRAMES = 24;
30
39
 
@@ -118,11 +127,20 @@ export function influenceAt(distance: number, radius: number): number {
118
127
  /**
119
128
  * Where the light is at a phase of the loop, and how wide it reaches.
120
129
  *
121
- * `drift` wanders around the middle — two periods that do not divide into each
122
- * other, so the path never closes into a loop the eye can learn, and small
123
- * amplitudes because a light that reaches the corners stops reading as one
124
- * source. `pulse` is a ring leaving the centre. `scan` crosses as a band, which
125
- * is the same maths with the light infinitely tall.
130
+ * `drift` wanders around the middle on a figure-eight: one horizontal pass to
131
+ * two vertical, so the path arrives back where it started and the loop has no
132
+ * seam in it. The amplitudes are small because a light that reaches the corners
133
+ * stops reading as one source.
134
+ *
135
+ * The two frequencies used not to divide into each other, on the reasoning that
136
+ * a path which never closes is one the eye cannot learn. It closes anyway —
137
+ * every animation here restarts at the end of its period — so all that bought
138
+ * was a jump of four normal steps, once a pass. A figure-eight is not a shape
139
+ * anybody follows over four seconds of soft light on a dot grid.
140
+ *
141
+ * `pulse` is a ring leaving the centre, and has expanded past the last dot
142
+ * before it restarts, so it fades out rather than snapping back. `scan` crosses
143
+ * as a band, which is the same maths with the light infinitely tall.
126
144
  */
127
145
  function lightAt(
128
146
  animation: DotFieldAnimation,
@@ -157,7 +175,7 @@ function lightAt(
157
175
 
158
176
  return {
159
177
  x: width / 2 + Math.sin(turn) * width * 0.26,
160
- y: height / 2 + Math.cos(turn * 1.37) * height * 0.22,
178
+ y: height / 2 + Math.cos(turn * 2) * height * 0.22,
161
179
  radius: short * 0.42,
162
180
  ring: 0,
163
181
  };
@@ -169,19 +187,25 @@ function lightAt(
169
187
  * Only the dots the light actually reaches are in it — the rest are already
170
188
  * drawn by the resting grid underneath, so this is a fraction of the field
171
189
  * rather than all of it.
190
+ *
191
+ * `points` is the same grid {@link anchors} would build, passed in by a caller
192
+ * that is about to ask for every frame of a loop. Building it here instead cost
193
+ * a fresh array of several hundred pairs per frame, to arrive at the identical
194
+ * grid twenty-four times over.
172
195
  */
173
196
  export function litPath(
174
197
  width: number,
175
198
  height: number,
176
199
  phase: number,
177
- animation: DotFieldAnimation = 'drift'
200
+ animation: DotFieldAnimation = 'drift',
201
+ points?: [number, number][]
178
202
  ): string {
179
203
  if (width <= 0 || height <= 0) return '';
180
204
 
181
205
  const light = lightAt(animation, phase, width, height);
182
206
  let path = '';
183
207
 
184
- for (const [x, y] of anchors(width, height)) {
208
+ for (const [x, y] of points ?? anchors(width, height)) {
185
209
  const deltaX = x - light.x;
186
210
  const deltaY = Number.isNaN(light.y) ? 0 : y - light.y;
187
211
  const distance = Math.abs(
@@ -206,8 +230,9 @@ export function litFrames(
206
230
  height: number,
207
231
  animation: DotFieldAnimation = 'drift'
208
232
  ): string[] {
233
+ const points = width > 0 && height > 0 ? anchors(width, height) : [];
209
234
  return Array.from({ length: FRAMES }, (_unused, index) =>
210
- litPath(width, height, index / FRAMES, animation)
235
+ litPath(width, height, index / FRAMES, animation, points)
211
236
  );
212
237
  }
213
238
 
@@ -224,3 +249,34 @@ export function frameAt(time: number, animation: DotFieldAnimation): number {
224
249
  const phase = (time % PERIOD[animation]) / PERIOD[animation];
225
250
  return Math.min(FRAMES - 1, Math.floor(phase * FRAMES));
226
251
  }
252
+
253
+ /**
254
+ * Where a moment falls in the loop, as a frame index with its fraction kept.
255
+ *
256
+ * {@link frameAt} rounds this down, and rounding it down is what made the field
257
+ * a flipbook: twenty-four pictures spread over the period, which on the slowest
258
+ * animation is a new one every 175ms however fast the screen refreshes. The
259
+ * fraction is what the two layers cross-fade on, so the light moves at the rate
260
+ * the display can draw rather than the rate the frames were built at.
261
+ */
262
+ export function framePhase(time: number, animation: DotFieldAnimation): number {
263
+ 'worklet';
264
+ return ((time % PERIOD[animation]) / PERIOD[animation]) * FRAMES;
265
+ }
266
+
267
+ /**
268
+ * What the outgoing and incoming layers are worth, a fraction `t` of the way
269
+ * from one frame to the next.
270
+ *
271
+ * Not `[1 - t, t]`. The layers are drawn over one another, so a dot lit in both
272
+ * of them composites to `1 - (1-a)(1-b)`, and two half-strength copies of it
273
+ * come to 0.70 rather than 0.90 — the field dips a fifth in the middle of every
274
+ * step, which six steps a second turns into a flicker. So only the outgoing
275
+ * layer ramps, and the incoming one is solved for: whatever leaves the pair at
276
+ * {@link LIT_OPACITY} the whole way across.
277
+ */
278
+ export function crossfadeAlphas(t: number): [number, number] {
279
+ 'worklet';
280
+ const out = LIT_OPACITY * (1 - t);
281
+ return [out, 1 - (1 - LIT_OPACITY) / (1 - out)];
282
+ }
@@ -15,14 +15,22 @@
15
15
  *
16
16
  * ## The dot field
17
17
  *
18
- * A grid of dots with a soft band of light travelling across it. Each column
19
- * is one animated view holding its own dots, so a field of five hundred dots
20
- * costs twenty-two worklets a frame rather than five hundred — the dots inside
21
- * a column never move independently of it, and at this scale nobody can tell.
18
+ * A grid of dots with a soft light moving through it, drawn as three layers:
19
+ * the field at rest, and two copies of the lit dots fading into one another.
22
20
  *
23
- * The per-dot brightness underneath the band is fixed, and comes from the
24
- * dot's own coordinates. Without it the field reads as clean vertical stripes
25
- * sweeping past; with it, it reads as the individual dots lighting up.
21
+ * Every picture the loop will ever draw is built when the box is measured, so
22
+ * running it hands over a string that already exists. That leaves twenty-four
23
+ * pictures to spread across the pass, which on its own is a flipbook — a new
24
+ * one every 175ms, however fast the screen refreshes. The two layers are what
25
+ * fills the gap: one holds the frame being left and one the frame being
26
+ * arrived at, and only their opacities move, which the compositor animates at
27
+ * the display's own rate without the drawing being touched.
28
+ *
29
+ * They are opacities on views rather than on the dots, and that is not a
30
+ * detail. A value that changes every frame sitting beside the path string
31
+ * pushes the path every frame too, and every push of a path is a re-parse of
32
+ * several hundred subpaths — which is the cost this whole arrangement exists
33
+ * to avoid. Kept apart, the path is pushed only when it actually changes.
26
34
  *
27
35
  * ## What arrives, and when
28
36
  *
@@ -34,11 +42,11 @@
34
42
  *
35
43
  * ## Reduced motion
36
44
  *
37
- * The band stops, and the field is drawn at one still frame of itself. A
45
+ * The light stops, and the field holds wherever the clock was left. A
38
46
  * placeholder that shows nothing is indistinguishable from a component that
39
47
  * failed to load, so this is a quieter picture rather than an empty one.
40
48
  */
41
- import { useEffect, useId, useMemo, useState, type ReactNode } from 'react';
49
+ import { useEffect, useMemo, useState, type ReactNode } from 'react';
42
50
  import {
43
51
  Pressable,
44
52
  StyleSheet,
@@ -61,13 +69,14 @@ import Animated, {
61
69
  } from 'react-native-reanimated';
62
70
  import { tv } from 'tailwind-variants';
63
71
  import { useCSSVariable } from 'uniwind';
64
- import { useDirectionSign } from '../../hooks/use-direction';
65
72
  import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from '../../icons';
66
73
  import { Text } from '../../primitives/text';
67
74
  import { cn } from '../../utils/cn';
68
75
  import {
69
76
  DOT_REST_OPACITY,
70
- frameAt,
77
+ FRAMES,
78
+ crossfadeAlphas,
79
+ framePhase,
71
80
  gridPath,
72
81
  litFrames,
73
82
  type DotFieldAnimation,
@@ -241,8 +250,21 @@ function ImageGenerationField({
241
250
  * light is somewhere legible rather than dead centre or absent, which is the
242
251
  * difference between "not animating" and "failed to load".
243
252
  */
244
- const animatedProps = useAnimatedProps(() => ({
245
- d: frames[frameAt(clock.value, animation)] ?? '',
253
+ const slotA = useAnimatedProps(() => ({
254
+ d: frames[Math.floor(framePhase(clock.value, animation))] ?? '',
255
+ }));
256
+ const slotB = useAnimatedProps(() => ({
257
+ d: frames[(Math.floor(framePhase(clock.value, animation)) + 1) % FRAMES] ?? '',
258
+ }));
259
+
260
+ // Only these move between one frame and the next, and a view's opacity is a
261
+ // layer alpha — so the drawing underneath is composited again rather than
262
+ // drawn again.
263
+ const fadeOut = useAnimatedStyle(() => ({
264
+ opacity: crossfadeAlphas(framePhase(clock.value, animation) % 1)[0],
265
+ }));
266
+ const fadeIn = useAnimatedStyle(() => ({
267
+ opacity: crossfadeAlphas(framePhase(clock.value, animation) % 1)[1],
246
268
  }));
247
269
 
248
270
  const onLayout = (event: LayoutChangeEvent) => {
@@ -261,14 +283,41 @@ function ImageGenerationField({
261
283
  {...props}
262
284
  >
263
285
  {grid ? (
264
- <Svg width={size.width} height={size.height}>
286
+ <>
265
287
  {/* The field at rest, under everything and never touched again. */}
266
- <Path d={grid} fill={color} fillOpacity={DOT_REST_OPACITY} />
288
+ <Svg
289
+ width={size.width}
290
+ height={size.height}
291
+ style={StyleSheet.absoluteFill}
292
+ >
293
+ <Path d={grid} fill={color} fillOpacity={DOT_REST_OPACITY} />
294
+ </Svg>
267
295
  {/* The dots the light has reached, drawn larger over their own
268
296
  resting copies rather than instead of them — so a dot brightens
269
- in place instead of appearing to move. */}
270
- <AnimatedPath animatedProps={animatedProps} fill={color} fillOpacity={0.9} />
271
- </Svg>
297
+ in place instead of appearing to move.
298
+
299
+ Two of them: the frame being left and the frame being arrived at.
300
+ Each carries its dots at full strength and the pair is brought to
301
+ LIT_OPACITY by the views, because what fades has to be the layer
302
+ and not the fill — see crossfadeAlphas for why the ramps are not
303
+ simply t and 1 - t. */}
304
+ <Animated.View
305
+ style={[StyleSheet.absoluteFill, fadeOut]}
306
+ pointerEvents="none"
307
+ >
308
+ <Svg width={size.width} height={size.height}>
309
+ <AnimatedPath animatedProps={slotA} fill={color} />
310
+ </Svg>
311
+ </Animated.View>
312
+ <Animated.View
313
+ style={[StyleSheet.absoluteFill, fadeIn]}
314
+ pointerEvents="none"
315
+ >
316
+ <Svg width={size.width} height={size.height}>
317
+ <AnimatedPath animatedProps={slotB} fill={color} />
318
+ </Svg>
319
+ </Animated.View>
320
+ </>
272
321
  ) : null}
273
322
  </View>
274
323
  );
@@ -145,6 +145,20 @@ interface SwiftUIModifiers {
145
145
  cornerRadius?: number
146
146
  ) => unknown;
147
147
  controlSize: (size: 'mini' | 'small' | 'regular' | 'large' | 'extraLarge') => unknown;
148
+ /**
149
+ * Paints a sheet's own surface, rather than a background behind its content.
150
+ *
151
+ * It is the only way out of the material a sheet is drawn in by default, and
152
+ * it reaches what an ordinary background cannot: the grabber's strip at the
153
+ * top and the safe-area inset at the bottom, which belong to the sheet's
154
+ * chrome and not to anything hosted inside it.
155
+ *
156
+ * **iOS 16.4 and up.** Below that it is inert and the sheet keeps its
157
+ * material — the same shape as glass being inert below iOS 26, and the same
158
+ * trap: indistinguishable from the prop not working. Check the OS before
159
+ * changing any code on a report of "the colour did nothing".
160
+ */
161
+ presentationBackground: (color: string) => unknown;
148
162
  }
149
163
 
150
164
  let modifiersResolved = false;
@@ -165,3 +179,34 @@ export function getSwiftUIModifiers(): SwiftUIModifiers | null {
165
179
 
166
180
  return modifiers;
167
181
  }
182
+
183
+ /**
184
+ * The Compose view modifiers — the other half of the same door.
185
+ *
186
+ * Android's toolkit has its own modifier system and its own vocabulary, so a
187
+ * modifier built for one platform is not a modifier the other can read. Where
188
+ * both are asked the same question the answers are written separately, here and
189
+ * above, rather than one being sent to both.
190
+ */
191
+ interface ComposeModifiers {
192
+ background: (color: string) => unknown;
193
+ }
194
+
195
+ let composeResolved = false;
196
+ let compose: ComposeModifiers | null = null;
197
+
198
+ export function getComposeModifiers(): ComposeModifiers | null {
199
+ if (composeResolved) return compose;
200
+ composeResolved = true;
201
+
202
+ if (Platform.OS !== 'android') return null;
203
+
204
+ try {
205
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
206
+ compose = require('@expo/ui/jetpack-compose/modifiers') as ComposeModifiers;
207
+ } catch {
208
+ compose = null;
209
+ }
210
+
211
+ return compose;
212
+ }