@astratra/native-ui 0.1.1 → 1.0.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/README.md +137 -1
- package/package.json +6 -4
- package/src/components/AutoRatioImage.js +237 -0
- package/src/components/ImageShimmer.js +91 -0
- package/src/components/ImageViewer.js +609 -0
- package/src/index.d.ts +117 -1
- package/src/index.js +3 -0
- package/src/logic/index.d.ts +104 -0
- package/src/logic/index.js +2 -1
- package/src/logic/picture.js +369 -0
package/README.md
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Le kit d'interface mobile : **le verre liquide d'Apple sur iOS, une surface
|
|
4
4
|
visible sur Android**, des boutons en verre, des cartes pâles, des barres qui se
|
|
5
5
|
replient au défilement, une barre d'onglets façon Instagram, un en-tête
|
|
6
|
-
repliable,
|
|
6
|
+
repliable, le rendu propre des réponses d'une IA, et les images : à leurs
|
|
7
|
+
propres proportions, une place qui attend, une visionneuse plein écran.
|
|
7
8
|
|
|
8
9
|
Pour React Native / Expo. Pas d'étape de build, pas de JSX : le source est du
|
|
9
10
|
CommonJS écrit avec `createElement`, comme `@astratra/react`, et Metro le prend
|
|
@@ -350,6 +351,141 @@ Avec cette réserve, le défilement maximal vaut exactement la position de la
|
|
|
350
351
|
question, que la réponse s'allonge ou raccourcisse ; elle tombe à zéro dès que la
|
|
351
352
|
réponse remplit l'écran.
|
|
352
353
|
|
|
354
|
+
# Les images
|
|
355
|
+
|
|
356
|
+
Trois composants et leurs règles pures (`logic/picture.js`). Aucun ne construit
|
|
357
|
+
d'adresse ni n'ajoute d'en-tête : la source vient de l'application, en général
|
|
358
|
+
de `createPictureSources().resolve` de `@astratra/native`, **seul** à décider si
|
|
359
|
+
le jeton de la personne part avec (jamais vers une autre origine).
|
|
360
|
+
|
|
361
|
+
## AutoRatioImage
|
|
362
|
+
|
|
363
|
+
```js
|
|
364
|
+
import { Image } from 'expo-image';
|
|
365
|
+
import { AutoRatioImage } from '@astratra/native-ui';
|
|
366
|
+
|
|
367
|
+
h(AutoRatioImage, {
|
|
368
|
+
load: () => pictures.resolve(card.url), // ou `source` déjà prête
|
|
369
|
+
sourceKey: card.url,
|
|
370
|
+
ImageComponent: Image, // Image de React Native par défaut
|
|
371
|
+
imageProps: { cachePolicy: 'disk' },
|
|
372
|
+
accessibilityLabel: title,
|
|
373
|
+
accessibilityHint: t('viewer.openHint'),
|
|
374
|
+
loadingLabel: t('picture.loading'),
|
|
375
|
+
renderError: ({ retry }) => h(GlassButton, { onPress: retry, accessibilityLabel: t('retry') }, icon),
|
|
376
|
+
onPress: () => setViewing(0)
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Libre sur la page** : pas de cadre, coins arrondis, rien de coupé. Un carré
|
|
381
|
+
(ou `initialRatio`) tant que l'image n'est pas connue, puis **ses propres
|
|
382
|
+
proportions** — bornées entre 1/5 et 5 (`minRatio`, `maxRatio`) : une bande de
|
|
383
|
+
1 × 10 000 ne fait pas une vue de dix mille points de haut, elle est montrée
|
|
384
|
+
entière dans la boîte bornée.
|
|
385
|
+
|
|
386
|
+
**Elle monte doucement** une fois là — d'un rien plus petite et transparente
|
|
387
|
+
à sa place, 700 ms, sortie en douceur — au lieu d'apparaître d'un coup.
|
|
388
|
+
**Réduire les animations** : d'un coup. En attendant, la place tenue porte le
|
|
389
|
+
reflet d'`ImageShimmer` (`placeholder` pour en mettre une autre, `null` pour
|
|
390
|
+
aucune).
|
|
391
|
+
|
|
392
|
+
**Un échec n'est jamais un indicateur qui tourne pour toujours.** Une source
|
|
393
|
+
qui échoue ou ne résout rien, une image qui ne se décode pas : l'emplacement
|
|
394
|
+
d'erreur de l'application (`renderError({ retry })`). L'ancienne version
|
|
395
|
+
laissait une promesse rejetée sans suite — la roue tournait indéfiniment.
|
|
396
|
+
**Une nouvelle image repart de zéro** : l'ancienne gardait la forme et l'échec
|
|
397
|
+
de la précédente quand l'adresse changeait.
|
|
398
|
+
|
|
399
|
+
**Touchable** (`onPress`) : un bouton-image, désactivé tant que l'image n'est
|
|
400
|
+
pas là, qui s'enfonce sur un ressort comme `GlassButton` (Réduire les
|
|
401
|
+
animations : il s'assombrit seulement). Sans `onPress`, une simple image
|
|
402
|
+
annoncée comme telle. Les deux événements de chargement sont lus (expo-image
|
|
403
|
+
et React Native) ; `fit` devient `contentFit` ou `resizeMode` selon le
|
|
404
|
+
composant.
|
|
405
|
+
|
|
406
|
+
## ImageShimmer
|
|
407
|
+
|
|
408
|
+
La place d'une image qu'on prépare : la forme aux proportions attendues, plate,
|
|
409
|
+
sans contour, où passe une lumière douce de gauche à droite (1,6 s). Un seul
|
|
410
|
+
élément pour le lecteur d'écran, une barre de progression avec les mots de
|
|
411
|
+
l'application (`accessibilityLabel`), et une légende discrète facultative
|
|
412
|
+
(`caption`). **Réduire les animations** : la lumière ne bouge pas. Elle
|
|
413
|
+
s'arrête quand la place disparaît.
|
|
414
|
+
|
|
415
|
+
### Les images en route
|
|
416
|
+
|
|
417
|
+
```js
|
|
418
|
+
import { reducePicturesArriving, picturesArrivingCount, PICTURES_ARRIVING_EMPTY } from '@astratra/native-ui/logic';
|
|
419
|
+
|
|
420
|
+
const [arriving, dispatch] = useReducer(reducePicturesArriving, PICTURES_ARRIVING_EMPTY);
|
|
421
|
+
// step 'draw' commencé → dispatch({ type: 'started', id })
|
|
422
|
+
// step terminé → dispatch({ type: 'finished', id, ok })
|
|
423
|
+
// l'image est arrivée → dispatch({ type: 'arrived', id? })
|
|
424
|
+
// fin du flux → dispatch({ type: 'ended' })
|
|
425
|
+
Array.from({ length: picturesArrivingCount(arriving) }, (_, i) => h(ImageShimmer, { key: i, … }));
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
La première version comptait les étapes « dessiner » en cours : le compte
|
|
429
|
+
tombait à zéro dès la fin de l'étape, alors que l'image arrivait dans un
|
|
430
|
+
événement suivant — la place disparaissait, le texte remontait, puis l'image le
|
|
431
|
+
repoussait. Ici une tâche finie **garde sa place jusqu'à l'arrivée de l'image** ;
|
|
432
|
+
seuls un échec ou la fin du flux la rendent. Un événement qui ne change rien
|
|
433
|
+
rend **le même objet** (React ne redessine pas). `countRunningSteps(steps, tool)`
|
|
434
|
+
donne l'ancien compte pour une application qui garde des étapes.
|
|
435
|
+
|
|
436
|
+
## ImageViewer
|
|
437
|
+
|
|
438
|
+
```js
|
|
439
|
+
h(ImageViewer, {
|
|
440
|
+
pictures, // [{ key, title?, accessibilityLabel?, … }]
|
|
441
|
+
start: viewing, // null : fermée
|
|
442
|
+
onClose: () => setViewing(null),
|
|
443
|
+
resolveSource: (picture) => pictures.resolve(picture.url),
|
|
444
|
+
ImageComponent: Image,
|
|
445
|
+
labels: { close: t('close'), share: t('share'), details: t('details'),
|
|
446
|
+
counter: (n, total) => t('counter', { n, total }), zoomHint: t('zoomHint') },
|
|
447
|
+
icons: { close: xmark, share: shareIcon, details: info, detailsActive: infoFill, failed: photo, notice: check },
|
|
448
|
+
onShare: (picture) => sharePicture(picture),
|
|
449
|
+
actions: [{ key: 'save', label: t('save'), icon: download, onPress: async (p) => (await save(p), t('saved')) }],
|
|
450
|
+
onActionError: (error, key) => Alert.alert(t('failed')),
|
|
451
|
+
renderDetails: (picture) => h(Details, { picture }),
|
|
452
|
+
insets: useSafeAreaInsets(),
|
|
453
|
+
onHaptic: (kind) => haptics[kind]()
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Comme Photos : du noir autour, des commandes rondes en verre qu'un appui cache
|
|
458
|
+
et remontre (la barre d'état avec, et le lecteur d'écran ne les atteint plus
|
|
459
|
+
tant qu'elles sont cachées).
|
|
460
|
+
|
|
461
|
+
- **Glisser de côté** passe d'une image à l'autre ; le compteur (« 3 sur 12 »,
|
|
462
|
+
les mots de l'application) suit, `onIndexChange` est appelé.
|
|
463
|
+
- **Pincer ou toucher deux fois** zoome là où l'on touche, au plus ×4 ; deux
|
|
464
|
+
fois encore pour revenir. Zoomé, le doigt déplace l'image et les pages ne
|
|
465
|
+
tournent plus. Sur **iOS** le zoom est natif (ScrollView qui zoome, son rebond,
|
|
466
|
+
sa décélération). La ScrollView d'**Android** ne zoome pas — l'ancienne
|
|
467
|
+
version n'y avait tout simplement pas de pincement — : Android reçoit le sien,
|
|
468
|
+
deux doigts autour du point entre eux, un doigt pour se déplacer, et au
|
|
469
|
+
relâcher l'image revient dans ses bornes. Un toucher unique attend 260 ms
|
|
470
|
+
pour ne pas être le premier d'un double.
|
|
471
|
+
- **Tirer vers le bas** ferme : le noir s'efface sur une demi-hauteur, l'image
|
|
472
|
+
rétrécit jusqu'à 80 %. Au-delà de 120 points ou assez vite, elle part ;
|
|
473
|
+
sinon elle revient sur un ressort. Deux doigts ne tirent jamais.
|
|
474
|
+
- **En haut** : fermer, le compteur, les détails si l'application en donne
|
|
475
|
+
(`renderDetails`, qui remplace alors le titre). **En bas** : le titre, puis
|
|
476
|
+
partager (`onShare`) et les actions de l'application. Une action peut rendre
|
|
477
|
+
un court message (« Enregistrée ») montré un instant et annoncé au lecteur
|
|
478
|
+
d'écran ; un échec va à `onActionError` avec sa clé et l'image. Pendant
|
|
479
|
+
qu'une action tourne, les autres attendent.
|
|
480
|
+
- **Réduire les animations** : pas de fondu, pas de ressort, pas de
|
|
481
|
+
rétrécissement ; la visionneuse s'ouvre et se ferme d'un coup, le zoom par
|
|
482
|
+
double toucher n'est pas animé.
|
|
483
|
+
- Une **rotation** remet la liste sur la même image ; la dernière image retirée
|
|
484
|
+
ferme la visionneuse ; le retour Android et le geste d'échappement de
|
|
485
|
+
VoiceOver ferment aussi.
|
|
486
|
+
|
|
487
|
+
Aucun texte, aucune icône, aucun chargement en dur : tout vient des props.
|
|
488
|
+
|
|
353
489
|
---
|
|
354
490
|
|
|
355
491
|
# Tests
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astratra/native-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mobile UI kit for React Native / Expo: Apple's Liquid Glass on iOS and a calibrated visible surface on Android, glass buttons, pale cards, bars that fold on scroll, an Instagram-style tab bar, a collapsible header,
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Mobile UI kit for React Native / Expo: Apple's Liquid Glass on iOS and a calibrated visible surface on Android, glass buttons, pale cards, bars that fold on scroll, an Instagram-style tab bar, a collapsible header, a Markdown renderer for AI answers, and pictures: natural-ratio images revealed gently, a shimmer placeholder, and a full-screen viewer with pinch, double-tap and pull-to-close. Pure rules exported separately and tested in plain Node.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20"
|
|
7
7
|
},
|
|
@@ -38,14 +38,16 @@
|
|
|
38
38
|
"liquid-glass",
|
|
39
39
|
"ui",
|
|
40
40
|
"tab-bar",
|
|
41
|
-
"markdown"
|
|
41
|
+
"markdown",
|
|
42
|
+
"image",
|
|
43
|
+
"image-viewer"
|
|
42
44
|
],
|
|
43
45
|
"license": "MIT",
|
|
44
46
|
"publishConfig": {
|
|
45
47
|
"access": "public"
|
|
46
48
|
},
|
|
47
49
|
"dependencies": {
|
|
48
|
-
"@astratra/native": "^0.
|
|
50
|
+
"@astratra/native": "^1.0.0"
|
|
49
51
|
},
|
|
50
52
|
"peerDependencies": {
|
|
51
53
|
"expo-blur": ">=14",
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A picture free on the page, in its own proportions: no frame around it, its
|
|
3
|
+
* corners rounded, nothing cut off. Until it is known it holds a place (a
|
|
4
|
+
* square by default, or the shape the app expects) where a soft light passes;
|
|
5
|
+
* once there, it takes its own shape and comes up gently — from a hair smaller
|
|
6
|
+
* and transparent — rather than all at once. "Reduce Motion": at once.
|
|
7
|
+
*
|
|
8
|
+
* The source is given ready (`source`), or fetched (`load`, with a
|
|
9
|
+
* `sourceKey` naming the picture) — typically `@astratra/native`'s
|
|
10
|
+
* `createPictureSources().resolve`, which decides whether the person's token
|
|
11
|
+
* goes with it. This component never builds an address nor adds a header.
|
|
12
|
+
*
|
|
13
|
+
* A failure — a load that rejects, a picture that does not decode — leaves the
|
|
14
|
+
* place with the app's error slot (`renderError({ retry })`); nothing spins
|
|
15
|
+
* forever.
|
|
16
|
+
*
|
|
17
|
+
* The image component is injected: React Native's Image by default, or
|
|
18
|
+
* expo-image's (`ImageComponent={Image}` with its caching props in
|
|
19
|
+
* `imageProps`). Both load events are read.
|
|
20
|
+
*/
|
|
21
|
+
const { React, h, RN, Reanimated, AnimatedView, useReducedMotion, useScheme } = require('./runtime');
|
|
22
|
+
const { ImageShimmer } = require('./ImageShimmer');
|
|
23
|
+
const { PICTURE_REVEAL, naturalRatio, loadedSize, revealStyle } = require('../logic/picture');
|
|
24
|
+
const { PALE_CARD_RADIUS } = require('../logic/paleCard');
|
|
25
|
+
|
|
26
|
+
const { Easing, useAnimatedStyle, useSharedValue, withSpring, withTiming } = Reanimated;
|
|
27
|
+
|
|
28
|
+
/* The surface shown only while the picture is not there (loading, failed). */
|
|
29
|
+
const DEFAULT_SURFACE = { light: '#eef0f5', dark: '#1c1c1e' };
|
|
30
|
+
|
|
31
|
+
/* The press, like the kit's glass button: the finger sinks the picture on a
|
|
32
|
+
spring without bounce, the release brings it back with a hair of overshoot. */
|
|
33
|
+
const PRESSED_SCALE = 0.98;
|
|
34
|
+
const PRESS_IN = { stiffness: 600, damping: 2 * Math.sqrt(600), mass: 1 };
|
|
35
|
+
const RELEASE = { stiffness: 380, damping: 20, mass: 1 };
|
|
36
|
+
|
|
37
|
+
function AutoRatioImage({
|
|
38
|
+
source,
|
|
39
|
+
load,
|
|
40
|
+
sourceKey,
|
|
41
|
+
ImageComponent,
|
|
42
|
+
imageProps,
|
|
43
|
+
fit = 'contain',
|
|
44
|
+
initialRatio = 1,
|
|
45
|
+
minRatio,
|
|
46
|
+
maxRatio,
|
|
47
|
+
borderRadius = PALE_CARD_RADIUS,
|
|
48
|
+
surfaceColor,
|
|
49
|
+
placeholder,
|
|
50
|
+
loadingLabel,
|
|
51
|
+
renderError,
|
|
52
|
+
accessibilityLabel,
|
|
53
|
+
accessibilityHint,
|
|
54
|
+
onPress,
|
|
55
|
+
onLongPress,
|
|
56
|
+
onLoad,
|
|
57
|
+
onError,
|
|
58
|
+
onRatio,
|
|
59
|
+
revealMs = PICTURE_REVEAL.durationMs,
|
|
60
|
+
scheme: schemeOverride,
|
|
61
|
+
style,
|
|
62
|
+
testID
|
|
63
|
+
}) {
|
|
64
|
+
const scheme = useScheme(schemeOverride);
|
|
65
|
+
const reduceMotion = useReducedMotion();
|
|
66
|
+
const Picture = ImageComponent || RN.Image;
|
|
67
|
+
const limits = { min: minRatio, max: maxRatio };
|
|
68
|
+
const startRatio = naturalRatio(null, { fallback: initialRatio, ...limits });
|
|
69
|
+
const key = sourceKey !== undefined && sourceKey !== null ? String(sourceKey) : source && source.uri ? source.uri : null;
|
|
70
|
+
|
|
71
|
+
const [resolved, setResolved] = React.useState(null);
|
|
72
|
+
const [phase, setPhase] = React.useState('resolving');
|
|
73
|
+
const [ratio, setRatio] = React.useState(startRatio);
|
|
74
|
+
const [attempt, setAttempt] = React.useState(0);
|
|
75
|
+
const shown = useSharedValue(0);
|
|
76
|
+
const press = useSharedValue(0);
|
|
77
|
+
|
|
78
|
+
/* A new picture starts over: its own place, its own reveal. The first
|
|
79
|
+
version kept the previous picture's shape and failure. */
|
|
80
|
+
const latest = React.useRef({ load, source, onError });
|
|
81
|
+
latest.current = { load, source, onError };
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
let live = true;
|
|
84
|
+
shown.value = 0;
|
|
85
|
+
setRatio(startRatio);
|
|
86
|
+
setResolved(null);
|
|
87
|
+
const fail = (error) => {
|
|
88
|
+
if (!live) return;
|
|
89
|
+
setPhase('failed');
|
|
90
|
+
if (latest.current.onError) latest.current.onError(error);
|
|
91
|
+
};
|
|
92
|
+
if (typeof latest.current.load === 'function') {
|
|
93
|
+
setPhase('resolving');
|
|
94
|
+
Promise.resolve()
|
|
95
|
+
.then(() => latest.current.load())
|
|
96
|
+
.then((next) => {
|
|
97
|
+
if (!live) return;
|
|
98
|
+
if (next && typeof next.uri === 'string' && next.uri) {
|
|
99
|
+
setResolved(next);
|
|
100
|
+
setPhase('loading');
|
|
101
|
+
} else {
|
|
102
|
+
fail(null);
|
|
103
|
+
}
|
|
104
|
+
}, fail);
|
|
105
|
+
} else if (latest.current.source && latest.current.source.uri) {
|
|
106
|
+
setResolved(latest.current.source);
|
|
107
|
+
setPhase('loading');
|
|
108
|
+
} else {
|
|
109
|
+
setPhase('failed');
|
|
110
|
+
}
|
|
111
|
+
return () => {
|
|
112
|
+
live = false;
|
|
113
|
+
};
|
|
114
|
+
/* The picture is named by its key: a new object for the same key is the
|
|
115
|
+
same picture, and must not start the reveal over. */
|
|
116
|
+
}, [key, attempt]);
|
|
117
|
+
|
|
118
|
+
const retry = React.useCallback(() => setAttempt((count) => count + 1), []);
|
|
119
|
+
|
|
120
|
+
const handleLoad = (event) => {
|
|
121
|
+
const size = loadedSize(event);
|
|
122
|
+
if (size) {
|
|
123
|
+
const next = naturalRatio(size, { fallback: initialRatio, ...limits });
|
|
124
|
+
setRatio(next);
|
|
125
|
+
if (onRatio) onRatio(next);
|
|
126
|
+
}
|
|
127
|
+
shown.value = reduceMotion ? 1 : withTiming(1, { duration: revealMs, easing: Easing.out(Easing.cubic) });
|
|
128
|
+
setPhase('loaded');
|
|
129
|
+
if (onLoad) onLoad(size);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleError = (event) => {
|
|
133
|
+
setPhase('failed');
|
|
134
|
+
if (onError) onError(event && event.nativeEvent ? event.nativeEvent.error : event);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const reveal = useAnimatedStyle(() => revealStyle(shown.value));
|
|
138
|
+
const pressed = useAnimatedStyle(() => ({
|
|
139
|
+
transform: [{ scale: reduceMotion ? 1 : 1 - (1 - PRESSED_SCALE) * press.value }],
|
|
140
|
+
opacity: reduceMotion ? 1 - 0.25 * press.value : 1
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
const loaded = phase === 'loaded';
|
|
144
|
+
const failed = phase === 'failed';
|
|
145
|
+
const surface = surfaceColor || DEFAULT_SURFACE[scheme];
|
|
146
|
+
const fitProps = Picture === RN.Image ? { resizeMode: fit } : { contentFit: fit };
|
|
147
|
+
|
|
148
|
+
const waiting =
|
|
149
|
+
placeholder !== undefined
|
|
150
|
+
? placeholder
|
|
151
|
+
: h(ImageShimmer, {
|
|
152
|
+
ratio,
|
|
153
|
+
borderRadius: 0,
|
|
154
|
+
colors: { base: surface },
|
|
155
|
+
accessibilityLabel: loadingLabel,
|
|
156
|
+
scheme,
|
|
157
|
+
style: RN.StyleSheet.absoluteFill
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const content = failed
|
|
161
|
+
? h(
|
|
162
|
+
RN.View,
|
|
163
|
+
{ style: [RN.StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }] },
|
|
164
|
+
typeof renderError === 'function' ? renderError({ retry }) : null
|
|
165
|
+
)
|
|
166
|
+
: [
|
|
167
|
+
loaded ? null : h(React.Fragment, { key: 'waiting' }, waiting),
|
|
168
|
+
resolved
|
|
169
|
+
? h(
|
|
170
|
+
AnimatedView,
|
|
171
|
+
{ key: `picture-${attempt}`, pointerEvents: 'none', style: [RN.StyleSheet.absoluteFill, reveal] },
|
|
172
|
+
h(Picture, {
|
|
173
|
+
...fitProps,
|
|
174
|
+
...(imageProps || {}),
|
|
175
|
+
source: resolved,
|
|
176
|
+
onLoad: handleLoad,
|
|
177
|
+
onError: handleError,
|
|
178
|
+
accessibilityIgnoresInvertColors: true,
|
|
179
|
+
style: { width: '100%', height: '100%' }
|
|
180
|
+
})
|
|
181
|
+
)
|
|
182
|
+
: null
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const frame = {
|
|
186
|
+
aspectRatio: ratio,
|
|
187
|
+
borderRadius,
|
|
188
|
+
overflow: 'hidden',
|
|
189
|
+
backgroundColor: loaded ? 'transparent' : surface
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if (typeof onPress !== 'function' && typeof onLongPress !== 'function') {
|
|
193
|
+
return h(
|
|
194
|
+
RN.View,
|
|
195
|
+
{
|
|
196
|
+
testID,
|
|
197
|
+
/* Failed, the frame stops grouping: the app's retry button inside
|
|
198
|
+
must be reachable by the screen reader on its own. */
|
|
199
|
+
accessible: !failed,
|
|
200
|
+
accessibilityRole: 'image',
|
|
201
|
+
accessibilityLabel,
|
|
202
|
+
accessibilityState: { busy: !loaded && !failed },
|
|
203
|
+
style: [frame, style]
|
|
204
|
+
},
|
|
205
|
+
content
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return h(
|
|
210
|
+
AnimatedView,
|
|
211
|
+
{ style: [pressed, style] },
|
|
212
|
+
h(
|
|
213
|
+
RN.Pressable,
|
|
214
|
+
{
|
|
215
|
+
testID,
|
|
216
|
+
accessible: !failed,
|
|
217
|
+
accessibilityRole: 'imagebutton',
|
|
218
|
+
accessibilityLabel,
|
|
219
|
+
accessibilityHint,
|
|
220
|
+
accessibilityState: { disabled: !loaded, busy: !loaded && !failed },
|
|
221
|
+
disabled: !loaded,
|
|
222
|
+
onPress,
|
|
223
|
+
onLongPress,
|
|
224
|
+
onPressIn: () => {
|
|
225
|
+
press.value = withSpring(1, PRESS_IN);
|
|
226
|
+
},
|
|
227
|
+
onPressOut: () => {
|
|
228
|
+
press.value = withSpring(0, RELEASE);
|
|
229
|
+
},
|
|
230
|
+
style: frame
|
|
231
|
+
},
|
|
232
|
+
content
|
|
233
|
+
)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { AutoRatioImage };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The place a picture will take while it is being made or fetched: a rounded
|
|
3
|
+
* shape at the picture's proportions, where a soft light passes left to right,
|
|
4
|
+
* and an optional quiet caption beneath.
|
|
5
|
+
*
|
|
6
|
+
* The screen reader hears one element, a progress bar with the app's label
|
|
7
|
+
* ("Drawing the picture…"). "Reduce Motion": the light does not move — the
|
|
8
|
+
* shape stays, still.
|
|
9
|
+
*/
|
|
10
|
+
const { React, h, RN, LinearGradient, Reanimated, AnimatedView, useReducedMotion, useScheme } = require('./runtime');
|
|
11
|
+
const { SHIMMER_PASS_MS, shimmerTranslate } = require('../logic/picture');
|
|
12
|
+
const { PALE_CARD_RADIUS } = require('../logic/paleCard');
|
|
13
|
+
|
|
14
|
+
/* Destructured once: a worklet must capture what it calls itself. */
|
|
15
|
+
const { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } = Reanimated;
|
|
16
|
+
|
|
17
|
+
/* A flat ground with no outline, and a light only a little brighter: the
|
|
18
|
+
placeholder is a presence, not a signal. The app's own theme passes its
|
|
19
|
+
colours in `colors`. */
|
|
20
|
+
const DEFAULT_COLORS = {
|
|
21
|
+
light: { base: '#eef0f5', light: 'rgba(255,255,255,0.85)', caption: 'rgba(60,60,67,0.6)' },
|
|
22
|
+
dark: { base: '#1c1c1e', light: 'rgba(255,255,255,0.08)', caption: 'rgba(235,235,245,0.6)' }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function ImageShimmer({
|
|
26
|
+
ratio = 1,
|
|
27
|
+
borderRadius = PALE_CARD_RADIUS,
|
|
28
|
+
colors,
|
|
29
|
+
accessibilityLabel,
|
|
30
|
+
caption,
|
|
31
|
+
captionStyle,
|
|
32
|
+
passMs = SHIMMER_PASS_MS,
|
|
33
|
+
scheme: schemeOverride,
|
|
34
|
+
style,
|
|
35
|
+
testID
|
|
36
|
+
}) {
|
|
37
|
+
const scheme = useScheme(schemeOverride);
|
|
38
|
+
const palette = { ...DEFAULT_COLORS[scheme], ...(colors || {}) };
|
|
39
|
+
const reduceMotion = useReducedMotion();
|
|
40
|
+
const position = useSharedValue(0);
|
|
41
|
+
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
if (reduceMotion) {
|
|
44
|
+
cancelAnimation(position);
|
|
45
|
+
position.value = 0;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
position.value = withRepeat(withTiming(1, { duration: passMs, easing: Easing.inOut(Easing.quad) }), -1, false);
|
|
49
|
+
return () => cancelAnimation(position);
|
|
50
|
+
}, [reduceMotion, passMs, position]);
|
|
51
|
+
|
|
52
|
+
const light = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslate(position.value) }] }));
|
|
53
|
+
|
|
54
|
+
return h(
|
|
55
|
+
RN.View,
|
|
56
|
+
{
|
|
57
|
+
testID,
|
|
58
|
+
accessible: true,
|
|
59
|
+
accessibilityRole: 'progressbar',
|
|
60
|
+
accessibilityLabel,
|
|
61
|
+
accessibilityState: { busy: true },
|
|
62
|
+
style: [{ gap: 8 }, style]
|
|
63
|
+
},
|
|
64
|
+
h(
|
|
65
|
+
RN.View,
|
|
66
|
+
{
|
|
67
|
+
style: {
|
|
68
|
+
aspectRatio: ratio > 0 ? ratio : 1,
|
|
69
|
+
borderRadius,
|
|
70
|
+
overflow: 'hidden',
|
|
71
|
+
backgroundColor: palette.base
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
reduceMotion
|
|
75
|
+
? null
|
|
76
|
+
: h(
|
|
77
|
+
AnimatedView,
|
|
78
|
+
{ pointerEvents: 'none', style: [RN.StyleSheet.absoluteFill, light] },
|
|
79
|
+
h(LinearGradient, {
|
|
80
|
+
colors: ['transparent', palette.light, 'transparent'],
|
|
81
|
+
start: { x: 0, y: 0.35 },
|
|
82
|
+
end: { x: 1, y: 0.65 },
|
|
83
|
+
style: RN.StyleSheet.absoluteFill
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
),
|
|
87
|
+
caption ? h(RN.Text, { style: [{ fontSize: 13, color: palette.caption }, captionStyle] }, caption) : null
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { ImageShimmer };
|