vireglass 0.1.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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/android/build.gradle +15 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/glasslens/GlassBackdropView.kt +335 -0
- package/android/src/main/java/expo/modules/glasslens/GlassLensModule.kt +49 -0
- package/android/src/main/java/expo/modules/glasslens/GlassLensView.kt +463 -0
- package/android/src/main/java/expo/modules/glasslens/GlassProbeView.kt +76 -0
- package/dist/adaptation-BSbbdExK.d.cts +78 -0
- package/dist/adaptation-BSbbdExK.d.ts +78 -0
- package/dist/adapters-BPB0D5nr.d.cts +357 -0
- package/dist/adapters-BgSc-smX.d.ts +357 -0
- package/dist/chunk-HF2AWZEZ.js +1741 -0
- package/dist/chunk-HF2AWZEZ.js.map +1 -0
- package/dist/chunk-JNRZNMJJ.js +20 -0
- package/dist/chunk-JNRZNMJJ.js.map +1 -0
- package/dist/index.cjs +2159 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +294 -0
- package/dist/index.d.ts +294 -0
- package/dist/index.js +286 -0
- package/dist/index.js.map +1 -0
- package/dist/material-CkUHUBoy.d.cts +408 -0
- package/dist/material-CkUHUBoy.d.ts +408 -0
- package/dist/react.cjs +73 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +25 -0
- package/dist/react.d.ts +25 -0
- package/dist/react.js +59 -0
- package/dist/react.js.map +1 -0
- package/dist/web/index.cjs +1990 -0
- package/dist/web/index.cjs.map +1 -0
- package/dist/web/index.d.cts +148 -0
- package/dist/web/index.d.ts +148 -0
- package/dist/web/index.js +657 -0
- package/dist/web/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +127 -0
- package/src/accessibility.ts +51 -0
- package/src/adaptation.ts +85 -0
- package/src/adapters.ts +362 -0
- package/src/concentric.ts +25 -0
- package/src/geometry.ts +151 -0
- package/src/glass-scale.ts +37 -0
- package/src/group-model.ts +224 -0
- package/src/index.ts +17 -0
- package/src/lens-shader.ts +547 -0
- package/src/material.ts +553 -0
- package/src/native/environment.ts +70 -0
- package/src/native/glass-group.tsx +107 -0
- package/src/native/glass-ink.tsx +44 -0
- package/src/native/glass-surface.tsx +477 -0
- package/src/native/index.ts +18 -0
- package/src/native/lens.ts +106 -0
- package/src/native/provider.tsx +58 -0
- package/src/native/surface-registry.ts +51 -0
- package/src/native/system-accessibility.ts +90 -0
- package/src/optics.ts +177 -0
- package/src/react.ts +102 -0
- package/src/reference-scene.ts +222 -0
- package/src/scroll-edge.ts +30 -0
- package/src/sdf.ts +149 -0
- package/src/surface-shader.ts +287 -0
- package/src/targets/agsl.ts +8 -0
- package/src/targets/glsl.ts +140 -0
- package/src/touch-response.ts +195 -0
- package/src/web/gl.ts +119 -0
- package/src/web/index.ts +3 -0
- package/src/web/probe.ts +312 -0
- package/src/web/reference-draw.ts +142 -0
- package/src/web/renderer.ts +524 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { FADE_MS, type BackdropSample } from '../adaptation';
|
|
12
|
+
import { createGroupState, type GroupState } from '../group-model';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SURFACE GROUP — a block of glass that adapts as one.
|
|
16
|
+
*
|
|
17
|
+
* Each lens sees its own patch of backdrop, and by its own measurement one nav button can drop
|
|
18
|
+
* into shadow while its neighbor stays transparent; over a busy backdrop their ink polarity
|
|
19
|
+
* splits apart too. The block then stops reading as a block and falls apart into separate
|
|
20
|
+
* elements.
|
|
21
|
+
*
|
|
22
|
+
* The group collects members' measurements and hands everyone back ONE ink polarity.
|
|
23
|
+
*
|
|
24
|
+
* It no longer hands out an AMBIENT estimate: that now lives in the lens itself, as on the web —
|
|
25
|
+
* the shared one used to override each element's own probe, and under one material the nav bar
|
|
26
|
+
* and the feed screen ended up adapting to the backdrop differently. The pull bus went with it:
|
|
27
|
+
* what deforms is the field around the touch point, not the element (`createDeform` in the
|
|
28
|
+
* core), and there's nothing left to merge neighboring surfaces into one drop with.
|
|
29
|
+
*/
|
|
30
|
+
type GroupApi = {
|
|
31
|
+
report: (
|
|
32
|
+
id: string,
|
|
33
|
+
x: number,
|
|
34
|
+
y: number,
|
|
35
|
+
sample: BackdropSample,
|
|
36
|
+
legibility: number,
|
|
37
|
+
) => void;
|
|
38
|
+
/** Take a member off the books on unmount: otherwise it stays in the block's estimate forever. */
|
|
39
|
+
release: (id: string) => void;
|
|
40
|
+
ink: number | undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const GlassGroupContext = createContext<GroupApi | null>(null);
|
|
44
|
+
|
|
45
|
+
export function GlassGroup({ children }: { children: ReactNode }) {
|
|
46
|
+
const [ink, setInk] = useState(1);
|
|
47
|
+
|
|
48
|
+
const target = useRef(1);
|
|
49
|
+
const current = useRef(1);
|
|
50
|
+
const from = useRef(1);
|
|
51
|
+
const startedAt = useRef(0);
|
|
52
|
+
const raf = useRef<ReturnType<typeof requestAnimationFrame> | null>(null);
|
|
53
|
+
|
|
54
|
+
const animate = useCallback(() => {
|
|
55
|
+
const step = () => {
|
|
56
|
+
const t = Math.min((Date.now() - startedAt.current) / FADE_MS, 1);
|
|
57
|
+
const e = 0.5 - 0.5 * Math.cos(Math.PI * t);
|
|
58
|
+
current.current = from.current + (target.current - from.current) * e;
|
|
59
|
+
setInk(current.current);
|
|
60
|
+
raf.current = t < 1 ? requestAnimationFrame(step) : null;
|
|
61
|
+
};
|
|
62
|
+
if (raf.current !== null) cancelAnimationFrame(raf.current);
|
|
63
|
+
raf.current = requestAnimationFrame(step);
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
useEffect(() => () => {
|
|
67
|
+
if (raf.current !== null) cancelAnimationFrame(raf.current);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const flip = useCallback(
|
|
71
|
+
(polarity: number) => {
|
|
72
|
+
from.current = current.current;
|
|
73
|
+
target.current = polarity;
|
|
74
|
+
startedAt.current = Date.now();
|
|
75
|
+
animate();
|
|
76
|
+
},
|
|
77
|
+
[animate],
|
|
78
|
+
);
|
|
79
|
+
// The block's state is created once, while the callback is rebuilt on every render — so what
|
|
80
|
+
// goes into the state is a reference to it, not the callback itself.
|
|
81
|
+
const flipRef = useRef(flip);
|
|
82
|
+
flipRef.current = flip;
|
|
83
|
+
|
|
84
|
+
const state = useRef<GroupState | null>(null);
|
|
85
|
+
if (state.current === null) {
|
|
86
|
+
// The core still computes the block's lightness plane, but nothing reads it anymore: each
|
|
87
|
+
// lens estimates its own ambient now. What's left here is ink polarity.
|
|
88
|
+
state.current = createGroupState({
|
|
89
|
+
plane: () => {},
|
|
90
|
+
flip: (polarity) => flipRef.current(polarity),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const group = state.current;
|
|
94
|
+
|
|
95
|
+
const api = useMemo<GroupApi>(
|
|
96
|
+
() => ({ report: group.report, release: group.release, ink }),
|
|
97
|
+
[group, ink],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return <GlassGroupContext.Provider value={api}>{children}</GlassGroupContext.Provider>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Inside a group, a surface hands it a measurement and gets back the shared estimate. Outside a
|
|
104
|
+
* group — null. */
|
|
105
|
+
export function useGlassGroup(): GroupApi | null {
|
|
106
|
+
return useContext(GlassGroupContext);
|
|
107
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
2
|
+
import { INK_DARK, INK_LIGHT } from '../adaptation';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ink polarity for content ON TOP of the glass, handed down to the surface's children.
|
|
6
|
+
*
|
|
7
|
+
* The glass itself decides whether letters over it must be light or dark: it's the only thing
|
|
8
|
+
* that sees what's underneath (`useGlassAdaptation`). Children just ask for the color —
|
|
9
|
+
* otherwise every screen would grow its own logic and they'd drift apart from each other.
|
|
10
|
+
*
|
|
11
|
+
* The value is continuous: on a recolor it travels between the ends, and the color travels
|
|
12
|
+
* along with it.
|
|
13
|
+
*/
|
|
14
|
+
const GlassInkContext = createContext(1);
|
|
15
|
+
|
|
16
|
+
export function GlassInkProvider({ ink, children }: { ink: number; children: ReactNode }) {
|
|
17
|
+
return <GlassInkContext.Provider value={ink}>{children}</GlassInkContext.Provider>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Polarity: 1 — light ink, 0 — dark. */
|
|
21
|
+
export function useGlassInk(): number {
|
|
22
|
+
return useContext(GlassInkContext);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hex = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 255);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ink color for the current polarity. Between the ends it travels by lightness — the same
|
|
29
|
+
* quantity the glass uses for its own separation, so the text and the body don't drift apart
|
|
30
|
+
* mid-transition.
|
|
31
|
+
*/
|
|
32
|
+
export function inkColor(ink: number, light: string, dark: string): string {
|
|
33
|
+
if (ink >= 0.999) return light;
|
|
34
|
+
if (ink <= 0.001) return dark;
|
|
35
|
+
// In-between frames last a fraction of a second, and exact per-channel interpolation isn't
|
|
36
|
+
// needed here: what matters is that the transition doesn't read as a step.
|
|
37
|
+
const v = INK_DARK + (INK_LIGHT - INK_DARK) * ink;
|
|
38
|
+
const g = hex(v);
|
|
39
|
+
return `rgb(${g}, ${g}, ${g})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useInkColor(light: string, dark: string): string {
|
|
43
|
+
return inkColor(useGlassInk(), light, dark);
|
|
44
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
findNodeHandle,
|
|
4
|
+
PixelRatio,
|
|
5
|
+
Platform,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
View,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
type ViewStyle,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import {
|
|
12
|
+
Canvas,
|
|
13
|
+
ColorShader,
|
|
14
|
+
Fill,
|
|
15
|
+
ImageShader,
|
|
16
|
+
Shader,
|
|
17
|
+
Skia,
|
|
18
|
+
type SkImage,
|
|
19
|
+
} from '@shopify/react-native-skia';
|
|
20
|
+
import Animated, {
|
|
21
|
+
useAnimatedProps,
|
|
22
|
+
useAnimatedStyle,
|
|
23
|
+
useDerivedValue,
|
|
24
|
+
type SharedValue,
|
|
25
|
+
} from 'react-native-reanimated';
|
|
26
|
+
import { BlurView } from 'expo-blur';
|
|
27
|
+
import { GlassLens as GlassLensNative, isGlassLensSupported } from './lens';
|
|
28
|
+
import {
|
|
29
|
+
lensMagnify,
|
|
30
|
+
toLensProps,
|
|
31
|
+
toSurfaceUniforms,
|
|
32
|
+
type VireGlassMorph,
|
|
33
|
+
} from '../adapters';
|
|
34
|
+
import {
|
|
35
|
+
lensPadDp,
|
|
36
|
+
shadowOpacityFrom,
|
|
37
|
+
surfacePadDp,
|
|
38
|
+
type VireGlassGeometry,
|
|
39
|
+
} from '../geometry';
|
|
40
|
+
import {
|
|
41
|
+
ambientFrom,
|
|
42
|
+
type BackdropSample,
|
|
43
|
+
} from '../adaptation';
|
|
44
|
+
import type { DeformSample } from '../touch-response';
|
|
45
|
+
import type { VireGlassDebugMode, VireGlassOptics } from '../material';
|
|
46
|
+
import { LENS_SHADER } from '../lens-shader';
|
|
47
|
+
import { SURFACE_SHADER } from '../surface-shader';
|
|
48
|
+
import { applyAccessibility } from '../accessibility';
|
|
49
|
+
import { useAccessibilityModifiers, useBackdropEnabled } from './provider';
|
|
50
|
+
import { useGlassSurfaceRegistration } from './surface-registry';
|
|
51
|
+
|
|
52
|
+
function compile(src: string) {
|
|
53
|
+
const effect = Skia.RuntimeEffect.Make(src);
|
|
54
|
+
if (!effect) throw new Error('VireGlass: surface SKSL failed to compile');
|
|
55
|
+
return effect;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The fraction of activity that a touch alone raises, matching the same fraction the web
|
|
59
|
+
* implementation's own button logic uses. */
|
|
60
|
+
const ACTIVE_ON_TOUCH = 0.3;
|
|
61
|
+
|
|
62
|
+
const SURFACE = compile(SURFACE_SHADER);
|
|
63
|
+
|
|
64
|
+
/** The lens takes pull as an animated prop: it and the surface must bend in the SAME frame.
|
|
65
|
+
* Through an ordinary prop the value would travel from the JS thread while the surface travels
|
|
66
|
+
* from the UI thread, and the layers would drift apart exactly the way they once did with the
|
|
67
|
+
* transform. The wrapper is created once: `createAnimatedComponent` inside render would
|
|
68
|
+
* recreate the type and drop the view every frame. */
|
|
69
|
+
const AnimatedGlassLens = GlassLensNative
|
|
70
|
+
? Animated.createAnimatedComponent(GlassLensNative)
|
|
71
|
+
: null;
|
|
72
|
+
|
|
73
|
+
export type GlassDynamics = {
|
|
74
|
+
shiftX: SharedValue<number>;
|
|
75
|
+
shiftY: SharedValue<number>;
|
|
76
|
+
press: SharedValue<number>;
|
|
77
|
+
active: SharedValue<number>;
|
|
78
|
+
/** Key light direction in screen coordinates (unit vector). */
|
|
79
|
+
light: SharedValue<readonly number[]>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type GlassIcon = {
|
|
83
|
+
image: SkImage | null;
|
|
84
|
+
/** Mask scale: it's built in device pixels, and mushes on 3x screens at dp. */
|
|
85
|
+
scale: number;
|
|
86
|
+
inkIdle: number[];
|
|
87
|
+
inkActive: number[];
|
|
88
|
+
/** A colored layer ON the glass: the currently playing track's cover art, same idea as the web
|
|
89
|
+
* mini-player. The mask is single-channel and colored by polarity, while this layer carries
|
|
90
|
+
* its own color as-is. It shares the mask's box: the shader samples both at the same `inkUv`. */
|
|
91
|
+
overlay?: SkImage | null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type GlassBackdropRead = { ambient: [number, number, number]; shadow: number };
|
|
95
|
+
|
|
96
|
+
export function VireGlassSurface({
|
|
97
|
+
geometry,
|
|
98
|
+
optics,
|
|
99
|
+
dynamics,
|
|
100
|
+
debug = 'normal',
|
|
101
|
+
morph,
|
|
102
|
+
blurTarget,
|
|
103
|
+
backdrop = true,
|
|
104
|
+
shadow,
|
|
105
|
+
dragLimit = 0,
|
|
106
|
+
icon,
|
|
107
|
+
progress,
|
|
108
|
+
touch,
|
|
109
|
+
appear,
|
|
110
|
+
lift = 0,
|
|
111
|
+
dim = 0,
|
|
112
|
+
topLayer = false,
|
|
113
|
+
onBackdropSample,
|
|
114
|
+
style,
|
|
115
|
+
}: {
|
|
116
|
+
geometry: VireGlassGeometry;
|
|
117
|
+
optics: VireGlassOptics;
|
|
118
|
+
dynamics: GlassDynamics;
|
|
119
|
+
debug?: VireGlassDebugMode;
|
|
120
|
+
morph?: VireGlassMorph;
|
|
121
|
+
/** Target of the live blur — the current screen's own content view. */
|
|
122
|
+
blurTarget?: RefObject<View | null> | null;
|
|
123
|
+
/** Turning this off mounts the surface with no backdrop: a reference point for comparing
|
|
124
|
+
* against it in the lab. */
|
|
125
|
+
backdrop?: boolean;
|
|
126
|
+
/** Manual shadow density. Without it, the probe drives it from what's under the element. */
|
|
127
|
+
shadow?: number;
|
|
128
|
+
dragLimit?: number;
|
|
129
|
+
icon?: GlassIcon;
|
|
130
|
+
/** Played fraction, 0…1: to the left of the boundary the element is active, to the right it
|
|
131
|
+
* isn't. Travels as a shared value — through an ordinary prop it would rebuild the whole
|
|
132
|
+
* uniform channel and overwrite the pull drop the worklet had already substituted in, exactly
|
|
133
|
+
* as it once did with the static `uniformValues`. */
|
|
134
|
+
progress?: SharedValue<number>;
|
|
135
|
+
/** Response to a finger BY THE CORE MODEL (`createDeform`): touch point, pull around the spot,
|
|
136
|
+
* the dent, and the wave. This didn't exist here at all before — the shader got `NO_TOUCH`,
|
|
137
|
+
* i.e. zeros, and no deformation existed for any gesture. The web runs the exact same fields
|
|
138
|
+
* (`web/renderer.ts`); the adapter contract is shared and specified in dp. */
|
|
139
|
+
touch?: SharedValue<DeformSample>;
|
|
140
|
+
/** Fraction in which the element EXISTS, 0…1: the glass builds up rather than fading in
|
|
141
|
+
* through opacity (reference §12). Travels as a shared value for the same reason as
|
|
142
|
+
* `progress`. */
|
|
143
|
+
appear?: SharedValue<number>;
|
|
144
|
+
/** 0 — under the finger the element sinks in, 1 — it rises into glass and its shadow pulls
|
|
145
|
+
* back (reference §5). A property of the element itself, not of its motion, so an ordinary
|
|
146
|
+
* prop. */
|
|
147
|
+
lift?: number;
|
|
148
|
+
/** Lightness of the backdrop UNDER the glass, about every 200 ms. This is how the screen finds
|
|
149
|
+
* out the glass has hit its own limit and it's time to recolor the ink (`adaptation.ts`). */
|
|
150
|
+
onBackdropSample?: (e: { nativeEvent: BackdropSample }) => void;
|
|
151
|
+
/** Dimming of the lens under the screen's scrim: BlurView targets the content directly and
|
|
152
|
+
* can't see a darkening overlay drawn above it — without this the lens glows through the
|
|
153
|
+
* scrim like a hole. */
|
|
154
|
+
dim?: number;
|
|
155
|
+
/** Top-layer surface: not muted by the screen's own overlay. */
|
|
156
|
+
topLayer?: boolean;
|
|
157
|
+
style?: StyleProp<ViewStyle>;
|
|
158
|
+
}) {
|
|
159
|
+
const { width, height, cornerRadius } = geometry;
|
|
160
|
+
const pad = surfacePadDp(geometry, dragLimit, morph);
|
|
161
|
+
|
|
162
|
+
// The lens view must not change size on the fly: every change is a relayout plus a new
|
|
163
|
+
// `RenderEffect`, and to the eye it reads as a jolt. Morphing, though, moves the second shape
|
|
164
|
+
// every frame, meaning the required margin changes continuously. So the margin only GROWS: it
|
|
165
|
+
// reaches its maximum on the first pass, and stays fixed from then on. It only resets when the
|
|
166
|
+
// element's own size changes.
|
|
167
|
+
const padRef = useRef(0);
|
|
168
|
+
const geometryKey = `${width}x${height}x${cornerRadius}`;
|
|
169
|
+
const geometryRef = useRef(geometryKey);
|
|
170
|
+
if (geometryRef.current !== geometryKey) {
|
|
171
|
+
geometryRef.current = geometryKey;
|
|
172
|
+
padRef.current = 0;
|
|
173
|
+
}
|
|
174
|
+
// The probe already computes both the ambient color (it bleeds into the shadow, reference §7)
|
|
175
|
+
// and the variegation (shadow density follows it, 219 @11:47) — the only job left is not to
|
|
176
|
+
// lose them along the way. The subscription sits right where the measurement already happens:
|
|
177
|
+
// glass with a hand-pinned polarity has no probe, and its shadow doesn't adapt.
|
|
178
|
+
const [sampled, setSampled] = useState<GlassBackdropRead | undefined>(undefined);
|
|
179
|
+
const handleSample = useMemo(() => {
|
|
180
|
+
if (!onBackdropSample) return undefined;
|
|
181
|
+
return (event: { nativeEvent: BackdropSample }) => {
|
|
182
|
+
const ambient = ambientFrom(event.nativeEvent);
|
|
183
|
+
const shade = shadowOpacityFrom(event.nativeEvent);
|
|
184
|
+
setSampled((prev) =>
|
|
185
|
+
prev &&
|
|
186
|
+
prev.shadow === shade &&
|
|
187
|
+
prev.ambient[0] === ambient[0] &&
|
|
188
|
+
prev.ambient[1] === ambient[1] &&
|
|
189
|
+
prev.ambient[2] === ambient[2]
|
|
190
|
+
? prev
|
|
191
|
+
: { ambient, shadow: shade },
|
|
192
|
+
);
|
|
193
|
+
onBackdropSample(event);
|
|
194
|
+
};
|
|
195
|
+
}, [onBackdropSample]);
|
|
196
|
+
|
|
197
|
+
// System settings change the material's LAYERS, they don't override it (reference §9). Applied
|
|
198
|
+
// right here: every piece of the app's glass passes through this surface, and one place is
|
|
199
|
+
// enough.
|
|
200
|
+
const a11y = useAccessibilityModifiers();
|
|
201
|
+
const tuned = useMemo(() => applyAccessibility(optics, a11y), [optics, a11y]);
|
|
202
|
+
|
|
203
|
+
padRef.current = Math.max(padRef.current, lensPadDp(geometry, tuned, morph, dragLimit));
|
|
204
|
+
const lensPad = padRef.current;
|
|
205
|
+
|
|
206
|
+
// The blur target is a ref, and it's still empty on the first render: by itself it doesn't
|
|
207
|
+
// trigger a repaint. Without this effect the glass stays without a backdrop until the first
|
|
208
|
+
// unrelated re-render — forever, on a static screen.
|
|
209
|
+
const [hasTarget, setHasTarget] = useState(false);
|
|
210
|
+
// The native lens needs the target's TAG: it uses it to find its own capture inside it.
|
|
211
|
+
const [backdropId, setBackdropId] = useState<number | null>(null);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const node = blurTarget?.current ?? null;
|
|
214
|
+
setHasTarget(node != null);
|
|
215
|
+
setBackdropId(node ? findNodeHandle(node) : null);
|
|
216
|
+
}, [blurTarget]);
|
|
217
|
+
|
|
218
|
+
// The single point where whether the backdrop lives gets decided. ALL of the app's glass
|
|
219
|
+
// passes through it, so both the settings toggle and suppression under an open sheet sit here
|
|
220
|
+
// rather than being smeared across consumers. Suppression under a sheet is mandated by the kit
|
|
221
|
+
// itself: the layers below the scrim have nothing left to refract, and it's also what keeps
|
|
222
|
+
// the surface count within the green zone (`surface-registry.ts`).
|
|
223
|
+
const backdropAllowed = useBackdropEnabled(topLayer);
|
|
224
|
+
const liveBackdrop = backdrop && backdropAllowed;
|
|
225
|
+
const refracting = isGlassLensSupported && GlassLensNative !== null;
|
|
226
|
+
const backdropReady = liveBackdrop && hasTarget && blurTarget?.current != null;
|
|
227
|
+
// The view with the probe below is mounted by this same expression — the two conditions have
|
|
228
|
+
// nothing to diverge on. And there is something for them to diverge on: the lens goes away
|
|
229
|
+
// both under an open sheet and under reduced transparency, while the shadow always renders,
|
|
230
|
+
// and the color of a backdrop that's gone would otherwise freeze in it.
|
|
231
|
+
const measuring = backdropReady && refracting && AnimatedGlassLens !== null;
|
|
232
|
+
const read = measuring ? sampled : undefined;
|
|
233
|
+
const ambient = read?.ambient;
|
|
234
|
+
// Shadow density follows what's UNDER THE ELEMENT (219 @11:47), computed by the same probe as
|
|
235
|
+
// the color.
|
|
236
|
+
const shade = shadow ?? read?.shadow ?? 1;
|
|
237
|
+
|
|
238
|
+
// The lens draws the glass body when it's live: only there is the backdrop visible, and
|
|
239
|
+
// without a backdrop there's no such thing as point-wise adaptation. The surface is then left
|
|
240
|
+
// with the highlight, the shadow, and the icon.
|
|
241
|
+
const bodyInLens = isGlassLensSupported && GlassLensNative !== null && hasTarget;
|
|
242
|
+
const statics = useMemo(
|
|
243
|
+
() => toSurfaceUniforms(tuned, geometry, { debug, morph, dragLimit, shadow: shade, bodyInLens, ambient, lift }),
|
|
244
|
+
[tuned, geometry, debug, morph, dragLimit, shade, bodyInLens, ambient, lift],
|
|
245
|
+
);
|
|
246
|
+
// The shader source is part of the result, so it's in the dependency list. Formally it's a
|
|
247
|
+
// module constant, but hot reload changes it, and a memo with the old dependencies keeps
|
|
248
|
+
// handing back the OLD shader: an optics edit silently fails to arrive.
|
|
249
|
+
const lensProps = useMemo(
|
|
250
|
+
// The backdrop estimate is the lens's OWN probe, as on the web. A group estimate used to
|
|
251
|
+
// override it and was an Android-only invention: on the web there's no `groupProbe` at all —
|
|
252
|
+
// there, every element adapts by its own probe. Because of that override, the nav bar and
|
|
253
|
+
// the feed screen adapted to the surroundings differently under one and the same material.
|
|
254
|
+
() => toLensProps(tuned, geometry, PixelRatio.get(), { debug, morph }),
|
|
255
|
+
[tuned, geometry, debug, morph, LENS_SHADER],
|
|
256
|
+
);
|
|
257
|
+
const iconUniforms = useMemo(
|
|
258
|
+
() => ({
|
|
259
|
+
u_iconOn: icon?.image ? 1 : 0,
|
|
260
|
+
u_iconScale: icon?.scale ?? 1,
|
|
261
|
+
// The shader slot must always be occupied: Skia hands out child shaders in declaration
|
|
262
|
+
// order, and an empty slot would shift the ink mask.
|
|
263
|
+
u_overlayOn: icon?.overlay ? 1 : 0,
|
|
264
|
+
u_inkIdle: icon?.inkIdle ?? [1, 1, 1, 1],
|
|
265
|
+
u_inkActive: icon?.inkActive ?? [1, 1, 1, 1],
|
|
266
|
+
}),
|
|
267
|
+
[icon],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const { press, active, light } = dynamics;
|
|
271
|
+
|
|
272
|
+
const halfMin = Math.min(geometry.width, geometry.height) / 2;
|
|
273
|
+
/** A finger is an area, not a point. The radius is taken from the SMALLER half-size: otherwise
|
|
274
|
+
* on a wide plate the touch would spread across its whole length. Same fraction the web
|
|
275
|
+
* implementation uses for its own touch handling. */
|
|
276
|
+
const touchRadius = 0.72 * halfMin;
|
|
277
|
+
// The lens channel is in pixels, while the whole model's geometry is in dp. Read once:
|
|
278
|
+
// `PixelRatio` isn't available inside a worklet.
|
|
279
|
+
const density = PixelRatio.get();
|
|
280
|
+
|
|
281
|
+
// Where each value sits in the lens's flat uniform channel. Names and sizes are fixed, so the
|
|
282
|
+
// offsets are computed once, leaving the worklet to just plug in the numbers.
|
|
283
|
+
const slots = useMemo(() => {
|
|
284
|
+
const at: Record<string, number> = {};
|
|
285
|
+
let i = 0;
|
|
286
|
+
for (let k = 0; k < lensProps.uniformNames.length; k += 1) {
|
|
287
|
+
at[lensProps.uniformNames[k]] = i;
|
|
288
|
+
i += lensProps.uniformSizes[k];
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
progress: at.u_progress ?? -1,
|
|
292
|
+
touch: at.u_touch ?? -1,
|
|
293
|
+
pull: at.u_pull ?? -1,
|
|
294
|
+
touchPress: at.u_touchPress ?? -1,
|
|
295
|
+
touchRadius: at.u_touchRadius ?? -1,
|
|
296
|
+
wave: at.u_wave ?? -1,
|
|
297
|
+
light: at.u_light ?? -1,
|
|
298
|
+
};
|
|
299
|
+
}, [lensProps]);
|
|
300
|
+
|
|
301
|
+
// Uniform values travel ONLY through the animated prop. While they still also went through the
|
|
302
|
+
// ordinary one, with an active group (which repaints the block a couple dozen times a second)
|
|
303
|
+
// the ordinary prop kept overwriting the drop the worklet had substituted in, and on a given
|
|
304
|
+
// frame it simply wasn't there.
|
|
305
|
+
const { uniformValues: _values, ...lensStatic } = lensProps;
|
|
306
|
+
|
|
307
|
+
const lensAnimatedProps = useAnimatedProps<{ uniformValues: number[] }>(() => {
|
|
308
|
+
const values = lensProps.uniformValues.slice();
|
|
309
|
+
if (slots.progress >= 0 && progress) values[slots.progress] = progress.value;
|
|
310
|
+
// The lens computes the rim light, while device tilt arrives via the worklet — into the
|
|
311
|
+
// same slot.
|
|
312
|
+
if (slots.light >= 0) {
|
|
313
|
+
values[slots.light] = light.value[0];
|
|
314
|
+
values[slots.light + 1] = light.value[1];
|
|
315
|
+
}
|
|
316
|
+
// Response to a finger. The lens channel is in PIXELS while the model is in dp: geometric
|
|
317
|
+
// fields get multiplied by density, the wave phase and the dent are dimensionless. The web
|
|
318
|
+
// does the same conversion (`web/renderer.ts`); the adapter contract can't be touched — it's
|
|
319
|
+
// shared.
|
|
320
|
+
if (touch) {
|
|
321
|
+
const t = touch.value;
|
|
322
|
+
if (slots.touch >= 0) {
|
|
323
|
+
values[slots.touch] = t.touchX * density;
|
|
324
|
+
values[slots.touch + 1] = t.touchY * density;
|
|
325
|
+
}
|
|
326
|
+
if (slots.pull >= 0) {
|
|
327
|
+
values[slots.pull] = t.pullX * density;
|
|
328
|
+
values[slots.pull + 1] = t.pullY * density;
|
|
329
|
+
}
|
|
330
|
+
if (slots.touchPress >= 0) values[slots.touchPress] = t.press;
|
|
331
|
+
if (slots.touchRadius >= 0) values[slots.touchRadius] = touchRadius * density;
|
|
332
|
+
if (slots.wave >= 0) {
|
|
333
|
+
values[slots.wave] = t.waveAmp * density;
|
|
334
|
+
values[slots.wave + 1] = t.wavePhase;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { uniformValues: values };
|
|
338
|
+
}, [lensProps, slots, density, progress, touch, touchRadius, light]);
|
|
339
|
+
|
|
340
|
+
const uniforms = useDerivedValue(() => {
|
|
341
|
+
return {
|
|
342
|
+
...statics,
|
|
343
|
+
...iconUniforms,
|
|
344
|
+
// Response to a finger by the core model. Nothing used to arrive here at all, and the
|
|
345
|
+
// shader ran on `NO_TOUCH` — zeros: no touch point, no pull, no wave existed.
|
|
346
|
+
u_touch: touch ? [touch.value.touchX, touch.value.touchY] : statics.u_touch,
|
|
347
|
+
u_pull: touch ? [touch.value.pullX, touch.value.pullY] : statics.u_pull,
|
|
348
|
+
u_touchPress: touch ? touch.value.press : statics.u_touchPress,
|
|
349
|
+
u_touchRadius: touch ? touchRadius : statics.u_touchRadius,
|
|
350
|
+
u_wave: touch ? [touch.value.waveAmp, touch.value.wavePhase] : statics.u_wave,
|
|
351
|
+
u_press: touch ? touch.value.press : press.value,
|
|
352
|
+
// Touch and activity feed one uniform: the stronger of the two wins, otherwise pressing an
|
|
353
|
+
// already-active element would read as switching it off. Same fraction as the web
|
|
354
|
+
// implementation's own button logic: a touch raises activity by a third, not to the full
|
|
355
|
+
// value.
|
|
356
|
+
u_active: touch ? Math.max(touch.value.active * ACTIVE_ON_TOUCH, active.value) : active.value,
|
|
357
|
+
u_progress: progress ? progress.value : statics.u_progress,
|
|
358
|
+
// The element builds up as glass, not through opacity: zero means it doesn't exist at all.
|
|
359
|
+
u_appear: appear ? appear.value : statics.u_appear,
|
|
360
|
+
};
|
|
361
|
+
}, [statics, iconUniforms, progress, touch, touchRadius, appear]);
|
|
362
|
+
|
|
363
|
+
const magnify = lensMagnify(tuned);
|
|
364
|
+
|
|
365
|
+
// Guards the ACTUAL count of live surfaces; a consuming app's own test guards the declared
|
|
366
|
+
// model.
|
|
367
|
+
useGlassSurfaceRegistration(liveBackdrop && hasTarget);
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<View collapsable={false} style={[styles.host, style, { width, height }]}>
|
|
371
|
+
{/* The kit's "reduced" state: glass is off — the panel is opaque, blur is removed. */}
|
|
372
|
+
{!backdropAllowed && (
|
|
373
|
+
<View
|
|
374
|
+
style={[styles.opaque, { width, height, borderRadius: cornerRadius }]}
|
|
375
|
+
pointerEvents="none"
|
|
376
|
+
/>
|
|
377
|
+
)}
|
|
378
|
+
{/* A screenshot (makeImageFromView) is a non-starter for the backdrop in principle: about
|
|
379
|
+
1000 ms per frame, and any refraction against it would lag. BlurView with blurTarget
|
|
380
|
+
draws the screen's content natively, frame by frame, and is aligned with it by
|
|
381
|
+
construction. */}
|
|
382
|
+
{/* collapsable={false} is required on both: the static style has no transform, it only
|
|
383
|
+
arrives from the worklet, and Android RN treats such a node as redundant and collapses
|
|
384
|
+
it into its parent — the deformation then has simply nowhere to apply. */}
|
|
385
|
+
<View
|
|
386
|
+
style={[styles.moving, { width, height }]}
|
|
387
|
+
pointerEvents="none"
|
|
388
|
+
collapsable={false}
|
|
389
|
+
>
|
|
390
|
+
<View style={[styles.lens, { width, height }]}>
|
|
391
|
+
{backdropReady ? (
|
|
392
|
+
measuring && AnimatedGlassLens ? (
|
|
393
|
+
// The lens view is DELIBERATELY bigger than the glass — at the rim the sample runs
|
|
394
|
+
// past its bounds, and the shader itself carves out the shape.
|
|
395
|
+
<AnimatedGlassLens
|
|
396
|
+
{...lensStatic}
|
|
397
|
+
animatedProps={lensAnimatedProps}
|
|
398
|
+
backdropId={backdropId}
|
|
399
|
+
onBackdropSample={handleSample}
|
|
400
|
+
style={{
|
|
401
|
+
position: 'absolute',
|
|
402
|
+
width: width + lensPad * 2,
|
|
403
|
+
height: height + lensPad * 2,
|
|
404
|
+
}}
|
|
405
|
+
/>
|
|
406
|
+
) : (
|
|
407
|
+
// Fallback below Android 13: uniform magnification. This is a magnifying glass, not
|
|
408
|
+
// a lens — an affine transform can't express a radially varying shift.
|
|
409
|
+
<View style={[styles.clip, { width, height, borderRadius: cornerRadius }]}>
|
|
410
|
+
<View
|
|
411
|
+
style={{
|
|
412
|
+
position: 'absolute',
|
|
413
|
+
left: (width * (1 - 1 / magnify)) / 2,
|
|
414
|
+
top: (height * (1 - 1 / magnify)) / 2,
|
|
415
|
+
width: width / magnify,
|
|
416
|
+
height: height / magnify,
|
|
417
|
+
transform: [{ scale: magnify }],
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
{/* The fallback has no shader — there's nothing but BlurView left to blur with. */}
|
|
421
|
+
<BlurView
|
|
422
|
+
intensity={tuned.blur}
|
|
423
|
+
tint="dark"
|
|
424
|
+
blurMethod={Platform.OS === 'android' ? 'dimezisBlurView' : undefined}
|
|
425
|
+
blurTarget={blurTarget}
|
|
426
|
+
style={StyleSheet.absoluteFill}
|
|
427
|
+
/>
|
|
428
|
+
</View>
|
|
429
|
+
</View>
|
|
430
|
+
)
|
|
431
|
+
) : null}
|
|
432
|
+
{dim > 0 ? (
|
|
433
|
+
<View
|
|
434
|
+
style={[styles.scrim, { width, height, borderRadius: cornerRadius, opacity: dim }]}
|
|
435
|
+
/>
|
|
436
|
+
) : null}
|
|
437
|
+
</View>
|
|
438
|
+
<Canvas
|
|
439
|
+
style={[
|
|
440
|
+
styles.canvas,
|
|
441
|
+
{ width: width + pad * 2, height: height + pad * 2, left: -pad, top: -pad },
|
|
442
|
+
]}
|
|
443
|
+
>
|
|
444
|
+
{/* dither is OFF: Skia mixes it in while rasterizing to an 8-bit surface, and on dark
|
|
445
|
+
content that reads as grain. Measured in the lab: with the backdrop off, leaving only
|
|
446
|
+
this layer, grain inside comes to 3.5 against 0.04 outside. */}
|
|
447
|
+
<Fill dither={false}>
|
|
448
|
+
<Shader source={SURFACE} uniforms={uniforms}>
|
|
449
|
+
{icon?.image ? (
|
|
450
|
+
<ImageShader image={icon.image} tx="decal" ty="decal" />
|
|
451
|
+
) : (
|
|
452
|
+
<ColorShader color="#00000000" />
|
|
453
|
+
)}
|
|
454
|
+
{/* Second slot — the colored layer (u_overlay): the mini-player's cover art. */}
|
|
455
|
+
{icon?.overlay ? (
|
|
456
|
+
<ImageShader image={icon.overlay} tx="decal" ty="decal" />
|
|
457
|
+
) : (
|
|
458
|
+
<ColorShader color="#00000000" />
|
|
459
|
+
)}
|
|
460
|
+
</Shader>
|
|
461
|
+
</Fill>
|
|
462
|
+
</Canvas>
|
|
463
|
+
</View>
|
|
464
|
+
</View>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const styles = StyleSheet.create({
|
|
469
|
+
host: { overflow: 'visible' },
|
|
470
|
+
moving: { position: 'absolute' },
|
|
471
|
+
lens: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
|
|
472
|
+
clip: { position: 'absolute', overflow: 'hidden' },
|
|
473
|
+
canvas: { position: 'absolute' },
|
|
474
|
+
scrim: { position: 'absolute', backgroundColor: '#0d0b09' },
|
|
475
|
+
// Opaque backdrop for the "glass off" state — color from the kit (§02, the reduced state).
|
|
476
|
+
opaque: { position: 'absolute', backgroundColor: '#0b0908' },
|
|
477
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { VireGlassSurface } from './glass-surface';
|
|
2
|
+
export type { GlassDynamics, GlassIcon } from './glass-surface';
|
|
3
|
+
|
|
4
|
+
export { GlassGroup, useGlassGroup } from './glass-group';
|
|
5
|
+
|
|
6
|
+
export { GlassInkProvider, useGlassInk, useInkColor, inkColor } from './glass-ink';
|
|
7
|
+
|
|
8
|
+
export { VireGlassProvider, useAccessibilityModifiers, useBackdropEnabled } from './provider';
|
|
9
|
+
|
|
10
|
+
export { GlassLens, GlassBackdrop, GlassProbe, isGlassLensSupported } from './lens';
|
|
11
|
+
export type { GlassLensProps, GlassBackdropProps, GlassProbeProps } from './lens';
|
|
12
|
+
|
|
13
|
+
export { useEnvironmentLight } from './environment';
|
|
14
|
+
|
|
15
|
+
export { glassSurfaceStats, useGlassSurfaceRegistration, GLASS_GREEN_MAX } from './surface-registry';
|
|
16
|
+
|
|
17
|
+
export { useSystemAccessibility, mergeAccessibility } from './system-accessibility';
|
|
18
|
+
export type { SystemAccessibility } from './system-accessibility';
|