@rocapine/react-native-onboarding-ui 1.34.1 → 1.36.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/UI/Pages/ComposableScreen/elements/ButtonElement.d.ts +10 -0
- package/dist/UI/Pages/ComposableScreen/elements/ButtonElement.d.ts.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/ButtonElement.js +3 -0
- package/dist/UI/Pages/ComposableScreen/elements/ButtonElement.js.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/CheckboxGroupElement.d.ts +10 -0
- package/dist/UI/Pages/ComposableScreen/elements/CheckboxGroupElement.d.ts.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/CheckboxGroupElement.js +3 -0
- package/dist/UI/Pages/ComposableScreen/elements/CheckboxGroupElement.js.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/ImageElement.d.ts +3 -0
- package/dist/UI/Pages/ComposableScreen/elements/ImageElement.d.ts.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/ImageElement.js +6 -4
- package/dist/UI/Pages/ComposableScreen/elements/ImageElement.js.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/ProgressiveBlurImageElement.d.ts +327 -0
- package/dist/UI/Pages/ComposableScreen/elements/ProgressiveBlurImageElement.d.ts.map +1 -0
- package/dist/UI/Pages/ComposableScreen/elements/ProgressiveBlurImageElement.js +240 -0
- package/dist/UI/Pages/ComposableScreen/elements/ProgressiveBlurImageElement.js.map +1 -0
- package/dist/UI/Pages/ComposableScreen/elements/RadioGroupElement.d.ts +10 -0
- package/dist/UI/Pages/ComposableScreen/elements/RadioGroupElement.d.ts.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/RadioGroupElement.js +3 -0
- package/dist/UI/Pages/ComposableScreen/elements/RadioGroupElement.js.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/haptics.d.ts +3 -0
- package/dist/UI/Pages/ComposableScreen/elements/haptics.d.ts.map +1 -0
- package/dist/UI/Pages/ComposableScreen/elements/haptics.js +27 -0
- package/dist/UI/Pages/ComposableScreen/elements/haptics.js.map +1 -0
- package/dist/UI/Pages/ComposableScreen/elements/renderElement.d.ts.map +1 -1
- package/dist/UI/Pages/ComposableScreen/elements/renderElement.js +4 -0
- package/dist/UI/Pages/ComposableScreen/elements/renderElement.js.map +1 -1
- package/dist/UI/Pages/ComposableScreen/types.d.ts +8 -0
- package/dist/UI/Pages/ComposableScreen/types.d.ts.map +1 -1
- package/dist/UI/Pages/ComposableScreen/types.js +8 -0
- package/dist/UI/Pages/ComposableScreen/types.js.map +1 -1
- package/package.json +9 -1
- package/src/UI/Pages/ComposableScreen/elements/ButtonElement.tsx +4 -0
- package/src/UI/Pages/ComposableScreen/elements/CheckboxGroupElement.tsx +4 -0
- package/src/UI/Pages/ComposableScreen/elements/ImageElement.tsx +15 -6
- package/src/UI/Pages/ComposableScreen/elements/ProgressiveBlurImageElement.tsx +348 -0
- package/src/UI/Pages/ComposableScreen/elements/RadioGroupElement.tsx +4 -0
- package/src/UI/Pages/ComposableScreen/elements/haptics.ts +24 -0
- package/src/UI/Pages/ComposableScreen/elements/renderElement.tsx +5 -0
- package/src/UI/Pages/ComposableScreen/types.ts +25 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Image as RNImage, View, StyleSheet, UIManager } from "react-native";
|
|
4
|
+
import Svg, { Defs, Rect, RadialGradient, Stop } from "react-native-svg";
|
|
5
|
+
import { BaseBoxProps, BaseBoxPropsSchema, GradientEdge } from "./BaseBoxProps";
|
|
6
|
+
import type { GradientBackground } from "./BaseBoxProps";
|
|
7
|
+
import { UIElement } from "../types";
|
|
8
|
+
import { RenderContext, dim } from "./shared";
|
|
9
|
+
import { GradientBox } from "./GradientBox";
|
|
10
|
+
|
|
11
|
+
// expo-image (better webp/avif) — optional, falls back to RN Image.
|
|
12
|
+
let ExpoImage: React.ComponentType<any> | null = null;
|
|
13
|
+
try {
|
|
14
|
+
ExpoImage = require("expo-image").Image;
|
|
15
|
+
} catch {
|
|
16
|
+
// expo-image not installed — RN Image fallback below
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// @react-native-masked-view/masked-view — optional. Absent → no progressive
|
|
20
|
+
// mask, so we degrade to a flat gradient scrim rather than a full-screen blur.
|
|
21
|
+
let MaskedView: React.ComponentType<any> | null = null;
|
|
22
|
+
try {
|
|
23
|
+
MaskedView = require("@react-native-masked-view/masked-view").default;
|
|
24
|
+
} catch {
|
|
25
|
+
// masked-view not installed
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// expo-linear-gradient — optional. Used for the LINEAR mask + tint/scrim
|
|
29
|
+
// gradients. Absent → plain dark View scrim (radial masks use react-native-svg,
|
|
30
|
+
// a required dep, so they don't depend on this).
|
|
31
|
+
let LinearGradient: React.ComponentType<any> | null = null;
|
|
32
|
+
try {
|
|
33
|
+
LinearGradient = require("expo-linear-gradient").LinearGradient;
|
|
34
|
+
} catch {
|
|
35
|
+
// expo-linear-gradient not installed
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mirror of the headless schema (UI mirrors stay self-contained — they don't
|
|
39
|
+
// import headless internals). Keep in lockstep with
|
|
40
|
+
// packages/onboarding/src/steps/ComposableScreen/elements/ProgressiveBlurImageElement.ts.
|
|
41
|
+
export type BlurMaskStop = { position: number; opacity: number };
|
|
42
|
+
export type LinearBlurMask = { type?: "linear"; from: GradientEdge; to: GradientEdge; stops: BlurMaskStop[] };
|
|
43
|
+
export type RadialBlurMask = {
|
|
44
|
+
type: "radial";
|
|
45
|
+
center?: { x: number; y: number };
|
|
46
|
+
radius?: number;
|
|
47
|
+
stops: BlurMaskStop[];
|
|
48
|
+
};
|
|
49
|
+
export type BlurMask = LinearBlurMask | RadialBlurMask;
|
|
50
|
+
|
|
51
|
+
export type ProgressiveBlurImageElementProps = BaseBoxProps & {
|
|
52
|
+
url: string;
|
|
53
|
+
aspectRatio?: number;
|
|
54
|
+
resizeMode?: "cover" | "contain" | "stretch" | "center";
|
|
55
|
+
intensity: number;
|
|
56
|
+
tint?: "light" | "dark" | "default";
|
|
57
|
+
mask: BlurMask;
|
|
58
|
+
maxBlurOpacity?: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const BlurMaskStopSchema = z.object({
|
|
62
|
+
position: z.number().min(0).max(1),
|
|
63
|
+
opacity: z.number().min(0).max(1),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const EDGE_ENUM = z.enum(["top", "bottom", "left", "right", "topLeft", "topRight", "bottomLeft", "bottomRight"]);
|
|
67
|
+
|
|
68
|
+
const LinearBlurMaskSchema = z.object({
|
|
69
|
+
type: z.literal("linear").optional(),
|
|
70
|
+
from: EDGE_ENUM,
|
|
71
|
+
to: EDGE_ENUM,
|
|
72
|
+
stops: z.array(BlurMaskStopSchema).min(2, "blur mask requires at least 2 stops"),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const RadialBlurMaskSchema = z.object({
|
|
76
|
+
type: z.literal("radial"),
|
|
77
|
+
center: z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1) }).optional(),
|
|
78
|
+
radius: z.number().positive().optional(),
|
|
79
|
+
stops: z.array(BlurMaskStopSchema).min(2, "blur mask requires at least 2 stops"),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const ProgressiveBlurImageElementPropsSchema = BaseBoxPropsSchema.extend({
|
|
83
|
+
url: z.string().min(1, "url must not be empty"),
|
|
84
|
+
aspectRatio: z.number().optional(),
|
|
85
|
+
resizeMode: z.enum(["cover", "contain", "stretch", "center"]).optional(),
|
|
86
|
+
intensity: z.number().min(0).max(100),
|
|
87
|
+
tint: z.enum(["light", "dark", "default"]).optional(),
|
|
88
|
+
mask: z.union([LinearBlurMaskSchema, RadialBlurMaskSchema]),
|
|
89
|
+
maxBlurOpacity: z.number().min(0).max(1).optional(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
type ResizeMode = "cover" | "contain" | "stretch" | "center";
|
|
93
|
+
|
|
94
|
+
const CONTENT_FIT: Record<ResizeMode, "cover" | "contain" | "fill" | "none"> = {
|
|
95
|
+
cover: "cover",
|
|
96
|
+
contain: "contain",
|
|
97
|
+
stretch: "fill",
|
|
98
|
+
center: "none",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const EDGE_POINT: Record<string, { x: number; y: number }> = {
|
|
102
|
+
top: { x: 0.5, y: 0 },
|
|
103
|
+
bottom: { x: 0.5, y: 1 },
|
|
104
|
+
left: { x: 0, y: 0.5 },
|
|
105
|
+
right: { x: 1, y: 0.5 },
|
|
106
|
+
topLeft: { x: 0, y: 0 },
|
|
107
|
+
topRight: { x: 1, y: 0 },
|
|
108
|
+
bottomLeft: { x: 0, y: 1 },
|
|
109
|
+
bottomRight: { x: 1, y: 1 },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const renderRaster = (
|
|
113
|
+
url: string,
|
|
114
|
+
resizeMode: ResizeMode | undefined,
|
|
115
|
+
style: any,
|
|
116
|
+
blurRadius?: number
|
|
117
|
+
): React.ReactElement =>
|
|
118
|
+
ExpoImage ? (
|
|
119
|
+
<ExpoImage source={url} contentFit={CONTENT_FIT[resizeMode ?? "cover"]} blurRadius={blurRadius} style={style} />
|
|
120
|
+
) : (
|
|
121
|
+
<RNImage source={{ uri: url }} resizeMode={resizeMode} blurRadius={blurRadius} style={style} />
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// expo-blur's BlurView blurs the *backdrop behind it*, but a MaskedView renders
|
|
125
|
+
// its child into an isolated offscreen layer with no backdrop to sample — so a
|
|
126
|
+
// masked BlurView is transparent on iOS (no blur, no tint). Instead we mask a
|
|
127
|
+
// real blurred *copy* of the image, which composites reliably on both platforms.
|
|
128
|
+
// Map the 0–100 intensity onto an expo-image/RN blurRadius in px.
|
|
129
|
+
const intensityToBlurRadius = (intensity: number): number => Math.max(0, Math.round(intensity * 0.3));
|
|
130
|
+
|
|
131
|
+
const isRadialMask = (mask: BlurMask): mask is RadialBlurMask => mask.type === "radial";
|
|
132
|
+
|
|
133
|
+
type BlurUIElement = Extract<UIElement, { type: "ProgressiveBlurImage" }>;
|
|
134
|
+
|
|
135
|
+
type Props = {
|
|
136
|
+
element: BlurUIElement;
|
|
137
|
+
ctx: RenderContext;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// LINEAR helpers (expo-linear-gradient).
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
// Mask alpha (= blur strength) → black with that alpha. MaskedView keys off the
|
|
145
|
+
// alpha channel of its mask element, so a transparent→opaque black ramp reveals
|
|
146
|
+
// the blurred image copy only where the mask is opaque.
|
|
147
|
+
const linearMaskColors = (mask: LinearBlurMask, maxBlurOpacity: number): string[] =>
|
|
148
|
+
mask.stops.map((s) => `rgba(0,0,0,${(s.opacity * maxBlurOpacity).toFixed(3)})`);
|
|
149
|
+
|
|
150
|
+
// `tint` → an rgb triple for the darkening/lightening overlay. "default" = no tint.
|
|
151
|
+
const tintRgb = (tint?: "light" | "dark" | "default"): string | null =>
|
|
152
|
+
tint === "dark" ? "0,0,0" : tint === "light" ? "255,255,255" : null;
|
|
153
|
+
|
|
154
|
+
// A linear color gradient following the mask shape (tint overlay / fallback
|
|
155
|
+
// scrim). Tint alpha tracks the mask strength × maxBlurOpacity so a "dark" tint
|
|
156
|
+
// actually reads dark (no extra dampening).
|
|
157
|
+
const linearColorGradient = (
|
|
158
|
+
mask: LinearBlurMask,
|
|
159
|
+
maxBlurOpacity: number,
|
|
160
|
+
rgb: string
|
|
161
|
+
): GradientBackground => ({
|
|
162
|
+
type: "linear",
|
|
163
|
+
from: mask.from,
|
|
164
|
+
to: mask.to,
|
|
165
|
+
stops: mask.stops.map((s) => ({
|
|
166
|
+
color: `rgba(${rgb},${(s.opacity * maxBlurOpacity).toFixed(3)})`,
|
|
167
|
+
position: s.position,
|
|
168
|
+
})),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// RADIAL helpers (react-native-svg — a required dep, always available).
|
|
173
|
+
// A radial color/alpha gradient rect; `objectBoundingBox` units map cx/cy/r to
|
|
174
|
+
// 0..1 fractions of the box (so a non-square box yields the Figma ellipse).
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const RadialSvg = ({
|
|
178
|
+
mask,
|
|
179
|
+
id,
|
|
180
|
+
color,
|
|
181
|
+
opacityScale,
|
|
182
|
+
}: {
|
|
183
|
+
mask: RadialBlurMask;
|
|
184
|
+
id: string;
|
|
185
|
+
/** SVG stop color, e.g. "black" or "rgb(0,0,0)". */
|
|
186
|
+
color: string;
|
|
187
|
+
/** Multiplier applied to each stop's opacity. */
|
|
188
|
+
opacityScale: number;
|
|
189
|
+
}): React.ReactElement => {
|
|
190
|
+
const c = mask.center ?? { x: 0.5, y: 0.5 };
|
|
191
|
+
const r = mask.radius ?? 0.75;
|
|
192
|
+
return (
|
|
193
|
+
<Svg style={StyleSheet.absoluteFillObject} width="100%" height="100%">
|
|
194
|
+
<Defs>
|
|
195
|
+
<RadialGradient id={id} cx={String(c.x)} cy={String(c.y)} r={String(r)} gradientUnits="objectBoundingBox">
|
|
196
|
+
{mask.stops.map((s, i) => (
|
|
197
|
+
<Stop
|
|
198
|
+
key={i}
|
|
199
|
+
offset={String(s.position)}
|
|
200
|
+
stopColor={color}
|
|
201
|
+
stopOpacity={String(Math.min(1, s.opacity * opacityScale))}
|
|
202
|
+
/>
|
|
203
|
+
))}
|
|
204
|
+
</RadialGradient>
|
|
205
|
+
</Defs>
|
|
206
|
+
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${id})`} />
|
|
207
|
+
</Svg>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// The JS package may be present (hoisted in a monorepo / installed) while the
|
|
212
|
+
// NATIVE view manager is missing — e.g. a dev-client binary built before the
|
|
213
|
+
// dep was added. Then `require` succeeds but rendering `<MaskedView>` throws
|
|
214
|
+
// "View config not found for component RNCMaskedView". Probe the native registry
|
|
215
|
+
// when we can; `hasViewManagerConfig` is absent on some arch/versions, so a
|
|
216
|
+
// `false` only suppresses when we can actually tell (the boundary below is the
|
|
217
|
+
// reliable backstop in all other cases).
|
|
218
|
+
const nativeMaskedViewAvailable = (): boolean => {
|
|
219
|
+
const has = (UIManager as any)?.hasViewManagerConfig;
|
|
220
|
+
if (typeof has !== "function") return true; // can't determine → let the boundary guard it
|
|
221
|
+
return !!has("RNCMaskedView");
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Backstop: if the masked-blur subtree throws at render/commit (native view
|
|
225
|
+
// missing on the running binary), swap to the plain fallback instead of
|
|
226
|
+
// crashing the whole onboarding screen.
|
|
227
|
+
class ProgressiveBlurBoundary extends React.Component<
|
|
228
|
+
{ fallback: React.ReactNode; children: React.ReactNode },
|
|
229
|
+
{ failed: boolean }
|
|
230
|
+
> {
|
|
231
|
+
state = { failed: false };
|
|
232
|
+
static getDerivedStateFromError() {
|
|
233
|
+
return { failed: true };
|
|
234
|
+
}
|
|
235
|
+
componentDidCatch() {
|
|
236
|
+
// swallow — degradation is intentional, not an error to surface
|
|
237
|
+
}
|
|
238
|
+
render() {
|
|
239
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const ProgressiveBlurImageElementComponent = ({ element }: Props): React.ReactElement => {
|
|
244
|
+
const p = element.props;
|
|
245
|
+
const maxBlurOpacity = p.maxBlurOpacity ?? 1;
|
|
246
|
+
const radial = isRadialMask(p.mask);
|
|
247
|
+
const rgb = tintRgb(p.tint);
|
|
248
|
+
|
|
249
|
+
const containerStyle = {
|
|
250
|
+
flex: p.flex,
|
|
251
|
+
flexShrink: p.flexShrink,
|
|
252
|
+
flexGrow: p.flexGrow,
|
|
253
|
+
alignSelf: p.alignSelf,
|
|
254
|
+
aspectRatio: p.aspectRatio,
|
|
255
|
+
width: dim(p.width),
|
|
256
|
+
height: dim(p.height),
|
|
257
|
+
minWidth: p.minWidth,
|
|
258
|
+
maxWidth: p.maxWidth,
|
|
259
|
+
minHeight: p.minHeight,
|
|
260
|
+
maxHeight: p.maxHeight,
|
|
261
|
+
borderRadius: p.borderRadius,
|
|
262
|
+
borderWidth: p.borderWidth,
|
|
263
|
+
borderColor: p.borderColor,
|
|
264
|
+
opacity: p.opacity,
|
|
265
|
+
overflow: (p.overflow ?? "hidden") as any,
|
|
266
|
+
margin: p.margin,
|
|
267
|
+
marginHorizontal: p.marginHorizontal,
|
|
268
|
+
marginVertical: p.marginVertical,
|
|
269
|
+
backgroundColor: p.backgroundColor,
|
|
270
|
+
} as any;
|
|
271
|
+
|
|
272
|
+
const sharpImage = renderRaster(p.url, p.resizeMode, StyleSheet.absoluteFillObject);
|
|
273
|
+
|
|
274
|
+
// Dark scrim (degraded path + error-boundary fallback). Radial → SVG (always
|
|
275
|
+
// available), linear → GradientBox (plain View when expo-linear-gradient absent).
|
|
276
|
+
// `isRadialMask(p.mask)` narrows the union in each branch.
|
|
277
|
+
const scrim = isRadialMask(p.mask) ? (
|
|
278
|
+
<RadialSvg mask={p.mask} id={`pbi-fb-${element.id}`} color="black" opacityScale={maxBlurOpacity} />
|
|
279
|
+
) : (
|
|
280
|
+
<GradientBox
|
|
281
|
+
gradient={linearColorGradient(p.mask, maxBlurOpacity, "0,0,0")}
|
|
282
|
+
style={StyleSheet.absoluteFillObject as any}
|
|
283
|
+
/>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const fallback = (
|
|
287
|
+
<View style={containerStyle}>
|
|
288
|
+
{sharpImage}
|
|
289
|
+
{scrim}
|
|
290
|
+
</View>
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Full path needs masked-view (+ its native view) and, for a LINEAR mask, the
|
|
294
|
+
// expo-linear-gradient dep. A RADIAL mask renders via react-native-svg (always
|
|
295
|
+
// available). expo-blur is intentionally not used — a masked BlurView is
|
|
296
|
+
// transparent on iOS (see renderRaster note).
|
|
297
|
+
const gradientDepReady = radial || !!LinearGradient;
|
|
298
|
+
const canProgressiveBlur = MaskedView && nativeMaskedViewAvailable() && gradientDepReady;
|
|
299
|
+
|
|
300
|
+
if (!canProgressiveBlur) return fallback;
|
|
301
|
+
|
|
302
|
+
const Masked = MaskedView!;
|
|
303
|
+
const Gradient = LinearGradient as React.ComponentType<any>; // non-null for linear masks (gradientDepReady)
|
|
304
|
+
const blurredCopy = renderRaster(
|
|
305
|
+
p.url,
|
|
306
|
+
p.resizeMode,
|
|
307
|
+
StyleSheet.absoluteFillObject,
|
|
308
|
+
intensityToBlurRadius(p.intensity)
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Mask element: opaque where the blur should show.
|
|
312
|
+
const maskElement = isRadialMask(p.mask) ? (
|
|
313
|
+
<RadialSvg mask={p.mask} id={`pbi-mask-${element.id}`} color="black" opacityScale={maxBlurOpacity} />
|
|
314
|
+
) : (
|
|
315
|
+
<Gradient
|
|
316
|
+
colors={linearMaskColors(p.mask, maxBlurOpacity)}
|
|
317
|
+
start={EDGE_POINT[p.mask.from]}
|
|
318
|
+
end={EDGE_POINT[p.mask.to]}
|
|
319
|
+
locations={p.mask.stops.map((s) => s.position)}
|
|
320
|
+
style={StyleSheet.absoluteFillObject}
|
|
321
|
+
/>
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Tint overlay following the mask shape (the Figma dark tint).
|
|
325
|
+
const tintOverlay =
|
|
326
|
+
rgb == null ? null : isRadialMask(p.mask) ? (
|
|
327
|
+
<RadialSvg mask={p.mask} id={`pbi-tint-${element.id}`} color={`rgb(${rgb})`} opacityScale={maxBlurOpacity} />
|
|
328
|
+
) : (
|
|
329
|
+
<GradientBox
|
|
330
|
+
gradient={linearColorGradient(p.mask, maxBlurOpacity, rgb)}
|
|
331
|
+
style={StyleSheet.absoluteFillObject as any}
|
|
332
|
+
/>
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<ProgressiveBlurBoundary fallback={fallback}>
|
|
337
|
+
<View style={containerStyle}>
|
|
338
|
+
{/* Sharp base. */}
|
|
339
|
+
{sharpImage}
|
|
340
|
+
{/* Blurred copy, revealed only where the mask is opaque → progressive blur. */}
|
|
341
|
+
<Masked style={StyleSheet.absoluteFillObject} maskElement={maskElement}>
|
|
342
|
+
{blurredCopy}
|
|
343
|
+
</Masked>
|
|
344
|
+
{tintOverlay}
|
|
345
|
+
</View>
|
|
346
|
+
</ProgressiveBlurBoundary>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
@@ -5,10 +5,12 @@ import { BaseBoxProps, BaseBoxPropsSchema } from "./BaseBoxProps";
|
|
|
5
5
|
import { UIElement } from "../types";
|
|
6
6
|
import { RenderContext, dim } from "./shared";
|
|
7
7
|
import { GradientBox } from "./GradientBox";
|
|
8
|
+
import { triggerHaptic, type HapticStyle } from "./haptics";
|
|
8
9
|
|
|
9
10
|
export type RadioGroupElementProps = BaseBoxProps & {
|
|
10
11
|
variableName?: string;
|
|
11
12
|
defaultValue?: string;
|
|
13
|
+
haptic?: HapticStyle;
|
|
12
14
|
gap?: number;
|
|
13
15
|
direction?: "vertical" | "horizontal";
|
|
14
16
|
showTick?: boolean;
|
|
@@ -33,6 +35,7 @@ export type RadioGroupElementProps = BaseBoxProps & {
|
|
|
33
35
|
export const RadioGroupElementPropsSchema = BaseBoxPropsSchema.extend({
|
|
34
36
|
variableName: z.string().optional(),
|
|
35
37
|
defaultValue: z.string().optional(),
|
|
38
|
+
haptic: z.enum(["none", "light", "medium", "heavy", "soft", "rigid"]).optional(),
|
|
36
39
|
gap: z.number().optional(),
|
|
37
40
|
direction: z.enum(["vertical", "horizontal"]).optional(),
|
|
38
41
|
showTick: z.boolean().optional(),
|
|
@@ -83,6 +86,7 @@ export const RadioGroupComponent = ({ element, ctx }: Props): React.ReactElement
|
|
|
83
86
|
|
|
84
87
|
const handleSelect = (value: string, label: string) => {
|
|
85
88
|
if (element.props.variableName) {
|
|
89
|
+
triggerHaptic(element.props.haptic);
|
|
86
90
|
setVariable(element.props.variableName, { value, label });
|
|
87
91
|
}
|
|
88
92
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Optional peer dep: expo-haptics. Mirrors the dynamic-require pattern used by
|
|
2
|
+
// Ratings (expo-store-review) and GradientBox (expo-linear-gradient) — graceful,
|
|
3
|
+
// never throws. Not installed → no-op. "none"/undefined → no-op.
|
|
4
|
+
let Haptics: any;
|
|
5
|
+
try {
|
|
6
|
+
Haptics = require("expo-haptics");
|
|
7
|
+
} catch {
|
|
8
|
+
Haptics = null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type HapticStyle = "none" | "light" | "medium" | "heavy" | "soft" | "rigid";
|
|
12
|
+
|
|
13
|
+
export function triggerHaptic(style?: HapticStyle): void {
|
|
14
|
+
if (!style || style === "none" || !Haptics?.impactAsync) return;
|
|
15
|
+
const map: Record<Exclude<HapticStyle, "none">, any> = {
|
|
16
|
+
light: Haptics.ImpactFeedbackStyle.Light,
|
|
17
|
+
medium: Haptics.ImpactFeedbackStyle.Medium,
|
|
18
|
+
heavy: Haptics.ImpactFeedbackStyle.Heavy,
|
|
19
|
+
soft: Haptics.ImpactFeedbackStyle.Soft,
|
|
20
|
+
rigid: Haptics.ImpactFeedbackStyle.Rigid,
|
|
21
|
+
};
|
|
22
|
+
// Best-effort: swallow rejection (unsupported device, etc.).
|
|
23
|
+
Haptics.impactAsync(map[style]).catch(() => {});
|
|
24
|
+
}
|
|
@@ -7,6 +7,7 @@ import { StackElementComponent } from "./StackElement";
|
|
|
7
7
|
import { TextElementComponent } from "./TextElement";
|
|
8
8
|
import { RichTextElementComponent } from "./RichTextElement";
|
|
9
9
|
import { ImageElementComponent } from "./ImageElement";
|
|
10
|
+
import { ProgressiveBlurImageElementComponent } from "./ProgressiveBlurImageElement";
|
|
10
11
|
import { LottieElementComponent } from "./LottieElement";
|
|
11
12
|
import { RiveElementRenderer } from "./RiveElement";
|
|
12
13
|
import { IconElementComponent } from "./IconElement";
|
|
@@ -56,6 +57,10 @@ export const renderElement = (
|
|
|
56
57
|
return <ImageElementComponent key={element.id} element={element} ctx={ctx} />;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
if (element.type === "ProgressiveBlurImage") {
|
|
61
|
+
return <ProgressiveBlurImageElementComponent key={element.id} element={element} ctx={ctx} />;
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
if (element.type === "Lottie") {
|
|
60
65
|
return <LottieElementComponent key={element.id} element={element} ctx={ctx} />;
|
|
61
66
|
}
|
|
@@ -12,6 +12,10 @@ import { type StackElementProps, StackElementPropsSchema } from "./elements/Stac
|
|
|
12
12
|
import { type TextElementProps, TextElementPropsSchema } from "./elements/TextElement";
|
|
13
13
|
import { type RichTextElementProps, RichTextElementPropsSchema } from "./elements/RichTextElement";
|
|
14
14
|
import { type ImageElementProps, ImageElementPropsSchema } from "./elements/ImageElement";
|
|
15
|
+
import {
|
|
16
|
+
type ProgressiveBlurImageElementProps,
|
|
17
|
+
ProgressiveBlurImageElementPropsSchema,
|
|
18
|
+
} from "./elements/ProgressiveBlurImageElement";
|
|
15
19
|
import { type LottieElementProps, LottieElementPropsSchema } from "./elements/LottieElement";
|
|
16
20
|
import { type RiveElementProps, RiveElementPropsSchema } from "./elements/RiveElement";
|
|
17
21
|
import { type IconElementProps, IconElementPropsSchema } from "./elements/IconElement";
|
|
@@ -40,6 +44,13 @@ export type { StackElementProps } from "./elements/StackElement";
|
|
|
40
44
|
export type { TextElementProps } from "./elements/TextElement";
|
|
41
45
|
export type { RichTextElementProps } from "./elements/RichTextElement";
|
|
42
46
|
export type { ImageElementProps } from "./elements/ImageElement";
|
|
47
|
+
export type {
|
|
48
|
+
ProgressiveBlurImageElementProps,
|
|
49
|
+
BlurMask,
|
|
50
|
+
LinearBlurMask,
|
|
51
|
+
RadialBlurMask,
|
|
52
|
+
BlurMaskStop,
|
|
53
|
+
} from "./elements/ProgressiveBlurImageElement";
|
|
43
54
|
export type { LottieElementProps } from "./elements/LottieElement";
|
|
44
55
|
export type { RiveElementProps } from "./elements/RiveElement";
|
|
45
56
|
export type { IconElementProps } from "./elements/IconElement";
|
|
@@ -93,6 +104,13 @@ export type UIElement =
|
|
|
93
104
|
type: "Image";
|
|
94
105
|
props: ImageElementProps;
|
|
95
106
|
}
|
|
107
|
+
| {
|
|
108
|
+
id: string;
|
|
109
|
+
name?: string;
|
|
110
|
+
renderWhen?: LeafCondition | ConditionGroup;
|
|
111
|
+
type: "ProgressiveBlurImage";
|
|
112
|
+
props: ProgressiveBlurImageElementProps;
|
|
113
|
+
}
|
|
96
114
|
| {
|
|
97
115
|
id: string;
|
|
98
116
|
name?: string;
|
|
@@ -248,6 +266,13 @@ export const UIElementSchema: z.ZodType<UIElement> = z.lazy(() =>
|
|
|
248
266
|
type: z.literal("Image"),
|
|
249
267
|
props: ImageElementPropsSchema,
|
|
250
268
|
}),
|
|
269
|
+
z.object({
|
|
270
|
+
id: z.string(),
|
|
271
|
+
name: z.string().optional(),
|
|
272
|
+
renderWhen: z.union([LeafConditionSchema, ConditionGroupSchema]).optional(),
|
|
273
|
+
type: z.literal("ProgressiveBlurImage"),
|
|
274
|
+
props: ProgressiveBlurImageElementPropsSchema,
|
|
275
|
+
}),
|
|
251
276
|
z.object({
|
|
252
277
|
id: z.string(),
|
|
253
278
|
name: z.string().optional(),
|