Files
FlClash/lib/ui/liquid_glass.dart
chen08209 7c9627fffb feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 17:21:43 +08:00

1501 lines
42 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/shape.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
part 'liquid_glass_capture.dart';
const _shaderAsset = 'shaders/liquid_glass.frag';
const _glowIn = Duration(milliseconds: 120);
const _glowOut = Duration(milliseconds: 360);
const double _glowSpread = 3;
const _profileTurns = 64;
// kLine in shaders/liquid_glass.frag.
const double _hairlineWidth = 0.67;
// In corner radii from the corner of the bounds.
const _shineAround = [
(along: 1.53, down: 0.0, lit: 0.68),
(along: 1.0, down: 0.0, lit: 0.48),
(along: 0.3, down: 0.3, lit: 0.38),
(along: 0.0, down: 1.0, lit: 0.17),
(along: 0.0, down: 1.5, lit: 0.07),
(along: 0.0, down: 2.0, lit: 0.03),
];
const _shineBands = [
(depth: 0.0, width: 1 / 3, lit: 1.0),
(depth: 1 / 3, width: 1 / 3, lit: 0.7),
(depth: 2 / 3, width: 1 / 3, lit: 0.47),
(depth: 1.0, width: 2 / 3, lit: 0.18),
(depth: 5 / 3, width: 2 / 3, lit: 0.12),
(depth: 7 / 3, width: 2 / 3, lit: 0.085),
(depth: 3.0, width: 2 / 3, lit: 0.065),
(depth: 11 / 3, width: 2 / 3, lit: 0.045),
(depth: 13 / 3, width: 2 / 3, lit: 0.035),
];
// The size of uShineEnds in shaders/liquid_glass.frag.
const _shineSlots = 12;
final _noShine = List<double>.unmodifiable(
List<double>.filled(2 * (_shineAround.length + 2) + 2 * _shineSlots, 0),
);
final _flatProfile = List<double>.unmodifiable(
List<double>.filled(_profileTurns, 0),
);
/// How much of iOS's glass the panes draw (.agents/architecture.md).
enum LiquidGlassQuality { standard, ultra }
/// Whether the navigation dock and the buttons of a page's bar are glass.
class LiquidGlassScope extends InheritedWidget {
const LiquidGlassScope({
super.key,
required this.enabled,
this.quality = LiquidGlassQuality.ultra,
required super.child,
});
final bool enabled;
final LiquidGlassQuality quality;
static bool of(BuildContext context) => _of(context)?.enabled ?? false;
static LiquidGlassQuality qualityOf(BuildContext context) =>
_of(context)?.quality ?? LiquidGlassQuality.ultra;
static LiquidGlassScope? _of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<LiquidGlassScope>();
@override
bool updateShouldNotify(LiquidGlassScope oldWidget) =>
enabled != oldWidget.enabled || quality != oldWidget.quality;
}
/// Lengths are dp. The shader shrinks [bevel] and [refraction] to fit a pane
/// too small for them, so that a button bends in proportion to the dock.
@immutable
class LiquidGlassStyle {
const LiquidGlassStyle({
required this.tint,
this.contrast = 0,
this.blur = 2.5,
this.haze = 0,
this.hazeBlur = 0,
this.refraction = 60,
this.bevel = 20,
this.saturation = 1.5,
this.brightness = 1,
this.rim = 0.6,
this.rimWidth = 1.3,
this.rimTail = 0,
this.rimFocus = 0,
this.rimAdded = 0,
this.endShade = 0,
this.dispersion = 0,
this.rimBlur = 0,
this.hairline = 0,
this.hairlineSide,
});
factory LiquidGlassStyle.regular(ColorScheme colorScheme) {
final dark = colorScheme.brightness == Brightness.dark;
return dark
? LiquidGlassStyle(
tint: colorScheme.surfaceContainerHighest.withValues(alpha: 0.62),
contrast: -0.4,
saturation: 2.84,
rim: 0.28,
rimWidth: 1.2,
rimTail: 0.17,
rimFocus: 1,
rimAdded: 0.38,
hairlineSide: 0.75,
)
: const LiquidGlassStyle(
tint: Color(0x8AFDFDFD),
contrast: 0.4,
saturation: 2.08,
rim: 0.28,
rimWidth: 1.2,
rimTail: 0.17,
rimFocus: 1,
rimAdded: 0.38,
hairline: 0.25,
hairlineSide: 0.75,
);
}
factory LiquidGlassStyle.pill(ColorScheme colorScheme) {
final dark = colorScheme.brightness == Brightness.dark;
return dark
? LiquidGlassStyle(
tint: colorScheme.surfaceContainerHighest.withValues(alpha: 0.48),
contrast: -0.4,
blur: 0,
haze: 0.72,
hazeBlur: 15.6,
saturation: 1.925,
rim: 0.35,
)
: const LiquidGlassStyle(
tint: Color(0x93F4F4F4),
contrast: 0.4,
blur: 0,
haze: 0.72,
hazeBlur: 15.6,
saturation: 2.1,
hairline: 0.35,
);
}
/// Thicker than [LiquidGlassStyle.regular], for rows that read over anything.
/// It takes its rim from a [LiquidGlassRim].
factory LiquidGlassStyle.menu(ColorScheme colorScheme) {
final dark = colorScheme.brightness == Brightness.dark;
return dark
? LiquidGlassStyle(
tint: colorScheme.surfaceContainerHighest.withValues(alpha: 0.63),
contrast: -0.53,
blur: 12,
saturation: 1.75,
brightness: 1.8,
rim: 0,
)
: LiquidGlassStyle(
tint: colorScheme.surfaceBright.withValues(alpha: 0.55),
blur: 12,
saturation: 2,
brightness: 1.1,
rim: 0,
);
}
factory LiquidGlassStyle.alert(ColorScheme colorScheme) =>
LiquidGlassStyle.menu(colorScheme).copyWith(blur: 16);
factory LiquidGlassStyle.tinted(ColorScheme colorScheme, Color color) {
final dark = colorScheme.brightness == Brightness.dark;
return LiquidGlassStyle(
tint: color.withValues(alpha: 0.82),
saturation: 1.2,
rim: dark ? 0.14 : 0.2,
rimWidth: 1.2,
rimTail: 0.17,
rimFocus: 1,
rimAdded: 0.38,
hairline: dark ? 0 : 0.34,
hairlineSide: dark ? 0.73 : 0.5,
);
}
factory LiquidGlassStyle.lens(ColorScheme colorScheme) {
final dark = colorScheme.brightness == Brightness.dark;
return LiquidGlassStyle(
tint: Colors.transparent,
blur: 0,
refraction: -4.3,
bevel: 5.5,
saturation: 1,
brightness: dark ? 1.33 : 1,
rim: dark ? 0.3 : 0.6,
rimWidth: 1.1,
endShade: 0.045,
dispersion: 6.5,
rimBlur: 1.5,
hairline: 0.42,
);
}
factory LiquidGlassStyle.lensContent(ColorScheme colorScheme) {
final dark = colorScheme.brightness == Brightness.dark;
return LiquidGlassStyle(
tint: Colors.transparent,
blur: 0,
refraction: 14,
bevel: 11.2,
saturation: 1,
brightness: dark ? 1.33 : 1,
rim: 0,
dispersion: 3.6,
rimBlur: 2.5,
);
}
final Color tint;
/// How far the tint thickens toward opaque over content much darker, or if
/// negative much lighter, than it, so what sits on the glass stays legible.
final double contrast;
final double blur;
/// The share of the backdrop seen through [hazeBlur] rather than [blur].
final double haze;
final double hazeBlur;
/// Negative for a raised lens, which reads outward all around its rim.
final double refraction;
final double bevel;
final double saturation;
final double brightness;
final double rim;
final double rimWidth;
final double rimTail;
/// 0 dims [rim] round the ends as on iOS's tab bar, 1 as on its controls.
final double rimFocus;
/// The share of [rim] added as light rather than screened toward white.
final double rimAdded;
final double endShade;
final double dispersion;
final double rimBlur;
final double hairline;
final double? hairlineSide;
/// Eases in from bare air, as glass materializes by bending, not fading.
LiquidGlassStyle scale(double t) {
final amount = t.clamp(0.0, 1.0);
return LiquidGlassStyle(
tint: tint.withValues(alpha: tint.a * amount),
contrast: contrast * amount,
blur: blur * amount,
haze: haze * amount,
hazeBlur: hazeBlur,
refraction: refraction * amount,
bevel: bevel,
saturation: 1 + (saturation - 1) * amount,
brightness: 1 + (brightness - 1) * amount,
rim: rim * amount,
rimWidth: rimWidth,
rimTail: rimTail,
rimFocus: rimFocus,
rimAdded: rimAdded,
endShade: endShade * amount,
dispersion: dispersion * amount,
rimBlur: rimBlur * amount,
hairline: hairline * amount,
hairlineSide: hairlineSide == null ? null : hairlineSide! * amount,
);
}
LiquidGlassStyle copyWith({double? blur}) => LiquidGlassStyle(
tint: tint,
contrast: contrast,
blur: blur ?? this.blur,
haze: haze,
hazeBlur: hazeBlur,
refraction: refraction,
bevel: bevel,
saturation: saturation,
brightness: brightness,
rim: rim,
rimWidth: rimWidth,
rimTail: rimTail,
rimFocus: rimFocus,
rimAdded: rimAdded,
endShade: endShade,
dispersion: dispersion,
rimBlur: rimBlur,
hairline: hairline,
hairlineSide: hairlineSide,
);
static LiquidGlassStyle lerp(
LiquidGlassStyle a,
LiquidGlassStyle b,
double t,
) {
double between(double a, double b) => ui.lerpDouble(a, b, t)!;
return LiquidGlassStyle(
tint: Color.lerp(a.tint, b.tint, t)!,
contrast: between(a.contrast, b.contrast),
blur: between(a.blur, b.blur),
haze: between(a.haze, b.haze),
hazeBlur: between(a.hazeBlur, b.hazeBlur),
refraction: between(a.refraction, b.refraction),
bevel: between(a.bevel, b.bevel),
saturation: between(a.saturation, b.saturation),
brightness: between(a.brightness, b.brightness),
rim: between(a.rim, b.rim),
rimWidth: between(a.rimWidth, b.rimWidth),
rimTail: between(a.rimTail, b.rimTail),
rimFocus: between(a.rimFocus, b.rimFocus),
rimAdded: between(a.rimAdded, b.rimAdded),
endShade: between(a.endShade, b.endShade),
dispersion: between(a.dispersion, b.dispersion),
rimBlur: between(a.rimBlur, b.rimBlur),
hairline: between(a.hairline, b.hairline),
hairlineSide: between(a._hairlineSide, b._hairlineSide),
);
}
@override
bool operator ==(Object other) =>
other is LiquidGlassStyle &&
other.tint == tint &&
other.contrast == contrast &&
other.blur == blur &&
other.haze == haze &&
other.hazeBlur == hazeBlur &&
other.refraction == refraction &&
other.bevel == bevel &&
other.saturation == saturation &&
other.brightness == brightness &&
other.rim == rim &&
other.rimWidth == rimWidth &&
other.rimTail == rimTail &&
other.rimFocus == rimFocus &&
other.rimAdded == rimAdded &&
other.endShade == endShade &&
other.dispersion == dispersion &&
other.rimBlur == rimBlur &&
other.hairline == hairline &&
other.hairlineSide == hairlineSide;
@override
int get hashCode => Object.hash(
tint,
contrast,
blur,
haze,
hazeBlur,
refraction,
bevel,
saturation,
brightness,
rim,
rimWidth,
rimTail,
rimFocus,
rimAdded,
endShade,
dispersion,
rimBlur,
hairline,
hairlineSide,
);
double get _hairlineSide => hairlineSide ?? hairline;
/// The standard tier's: no colors parted, and a haze for the whole frost.
LiquidGlassStyle get _standard {
if (haze == 0 && dispersion == 0) {
return this;
}
return LiquidGlassStyle(
tint: tint,
contrast: contrast,
blur: haze > 0 ? hazeBlur : blur,
refraction: refraction,
bevel: bevel,
saturation: saturation,
brightness: brightness,
rim: rim,
rimWidth: rimWidth,
rimTail: rimTail,
rimFocus: rimFocus,
rimAdded: rimAdded,
endShade: endShade,
rimBlur: rimBlur,
hairline: hairline,
hairlineSide: hairlineSide,
);
}
}
/// An iOS 27 menu's or alert's, measured over white.
List<BoxShadow> glassMenuShadows(ColorScheme colorScheme, [double depth = 1]) {
final strength = colorScheme.brightness == Brightness.dark ? 1.5 : 1.0;
final alpha = strength * depth;
return [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.09 * alpha),
blurRadius: 16.5,
offset: const Offset(0, 4),
),
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.045 * alpha),
blurRadius: 42,
offset: const Offset(0, 8),
),
];
}
Color glassFillOf(ColorScheme colorScheme) =>
(colorScheme.brightness == Brightness.dark ? Colors.white : Colors.black)
.withValues(alpha: 0.13);
/// iOS 27's rim around a menu's or an alert's glass (.agents/architecture.md).
@immutable
class LiquidGlassRim {
const LiquidGlassRim({
required this.side,
required this.corner,
required this.flat,
required this.shine,
});
factory LiquidGlassRim.alert(ColorScheme colorScheme) =>
colorScheme.brightness == Brightness.dark
? const LiquidGlassRim(side: 0.75, corner: 0.1, flat: 0.03, shine: 0.22)
: const LiquidGlassRim(side: 0.35, corner: 0.22, flat: 0.12, shine: 0.16);
factory LiquidGlassRim.menu(ColorScheme colorScheme) =>
colorScheme.brightness == Brightness.dark
? const LiquidGlassRim(side: 0.27, corner: 0.1, flat: 0.02, shine: 0.22)
: const LiquidGlassRim(side: 0.25, corner: 0.15, flat: 0.09, shine: 0.16);
final double side;
final double corner;
final double flat;
/// Added light, not white laid over: iOS lifts dark and light panes alike.
final double shine;
LiquidGlassRim scale(double t) {
final amount = t.clamp(0.0, 1.0);
return LiquidGlassRim(
side: side * amount,
corner: corner * amount,
flat: flat * amount,
shine: shine * amount,
);
}
/// The line, outside the pane; the pane given this rim draws the highlight.
void paintLine(Canvas canvas, Rect rect, double radius) {
if (rect.isEmpty) {
return;
}
final round = _roundOf(rect.size, radius);
final shape = AppRadius.all(round).toRSuperellipse(rect);
final across = math.min(round / rect.width, 0.4);
Color dark(double alpha) => Colors.black.withValues(alpha: alpha);
canvas.drawRSuperellipse(
shape.inflate(_hairlineWidth / 2),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = _hairlineWidth
..shader = LinearGradient(
colors: [
dark(side),
dark(corner),
dark(flat),
dark(flat),
dark(corner),
dark(side),
],
stops: [0, across / 4, across, 1 - across, 1 - across / 4, 1],
).createShader(rect),
);
}
static double _roundOf(Size size, double radius) =>
math.min(math.max(0.0, radius), size.shortestSide / 2);
List<double> _uniforms(Size size, double radius) {
final turns = _turns(size, _roundOf(size, radius));
final ends = [for (final band in _shineBands) band.depth + band.width];
return [
for (final (turn, _) in turns) turn,
for (final (_, lit) in turns) shine * lit,
for (var index = 0; index < _shineSlots; index++)
ends[math.min(index, ends.length - 1)],
for (var index = 0; index < _shineSlots; index++)
index < _shineBands.length ? _shineBands[index].lit : 0.0,
];
}
void _paintShine(Canvas canvas, Rect rect, double radius) {
final round = _roundOf(rect.size, radius);
final shape = AppRadius.all(round).toRSuperellipse(rect);
final around = _sweepStops(rect.size, round);
final stops = [for (final (stop, _) in around) stop];
for (final band in _shineBands) {
final alpha = shine * band.lit;
// A ring, not a stroke: Impeller widens a stroke thinner than a pixel.
canvas.drawPath(
Path()
..fillType = PathFillType.evenOdd
..addRSuperellipse(shape.deflate(band.depth))
..addRSuperellipse(shape.deflate(band.depth + band.width)),
Paint()
..blendMode = BlendMode.plus
..shader = SweepGradient(
colors: [
for (final (_, lit) in around)
Colors.white.withValues(alpha: math.min(alpha * lit, 1)),
],
stops: stops,
).createShader(rect),
);
}
}
static List<(double, double)> _turns(Size size, double round) {
final halfWidth = size.width / 2;
final halfHeight = size.height / 2;
final turns = [(0.0, 1.0)];
for (final (:along, :down, :lit) in _shineAround) {
final turn = math.atan2(
halfWidth - math.min(along * round, halfWidth),
halfHeight - math.min(down * round, halfHeight),
);
turns.add((math.max(turns.last.$1, turn), lit));
}
turns.add((math.pi / 2, 0));
return turns;
}
static List<(double, double)> _sweepStops(Size size, double round) {
final turns = _turns(size, round);
const full = 2 * math.pi;
return [
for (final (turn, lit) in turns.reversed)
((math.pi / 2 - turn) / full, lit),
for (final (turn, lit) in turns.skip(1))
((math.pi / 2 + turn) / full, lit),
for (final (turn, lit) in turns.reversed.skip(1))
((math.pi * 1.5 - turn) / full, lit),
for (final (turn, lit) in turns.skip(1))
((math.pi * 1.5 + turn) / full, lit),
];
}
@override
bool operator ==(Object other) =>
other is LiquidGlassRim &&
other.side == side &&
other.corner == corner &&
other.flat == flat &&
other.shine == shine;
@override
int get hashCode => Object.hash(side, corner, flat, shine);
}
/// Light a press casts on a pane, in its coordinates: [strength] at [center],
/// falling off over [reach] to [wash] of it across the rest of the pane.
@immutable
class LiquidGlassGlow {
const LiquidGlassGlow({
required this.center,
required this.reach,
required this.strength,
this.wash = 0,
});
static const none = LiquidGlassGlow(
center: Offset.zero,
reach: 1,
strength: 0,
);
final Offset center;
final double reach;
final double strength;
final double wash;
@override
bool operator ==(Object other) =>
other is LiquidGlassGlow &&
other.center == center &&
other.reach == reach &&
other.strength == strength &&
other.wash == wash;
@override
int get hashCode => Object.hash(center, reach, strength, wash);
}
/// A pane of glass behind [child], after iOS's Liquid Glass, a pill unless
/// [clipper] shapes it. It casts [shadows] only outside itself, as one
/// under it would show through. Without Impeller's shader filters it frosts
/// but does not bend.
class LiquidGlass extends StatelessWidget {
const LiquidGlass({
super.key,
required this.style,
this.clipper,
this.shadows = const [],
this.pressed = false,
this.glow,
this.child,
});
final LiquidGlassStyle style;
/// Shapes the pane to its path in place of the pill. The glass bends
/// to the path by how far it reaches from the pane's center in each
/// direction, so it must meet every ray from the center once.
final CustomClipper<Path>? clipper;
final List<BoxShadow> shadows;
final bool pressed;
/// Lights the pane in place of the glow [pressed] lights at its center.
final LiquidGlassGlow? glow;
final Widget? child;
/// Builds the scenes of [capture] without panes, for a snapshot: offscreen
/// a pane has no backdrop to bend and comes out a flat tint.
static T withoutPanes<T>(T Function() capture) {
_LiquidGlassLayer._skipped++;
try {
return capture();
} finally {
_LiquidGlassLayer._skipped--;
}
}
/// Replaces a button's fill with glass.
static ButtonStyle buttonStyle(
LiquidGlassStyle Function(Set<WidgetState> states) styleOf,
) {
return ButtonStyle(
backgroundColor: const WidgetStatePropertyAll(Colors.transparent),
backgroundBuilder: (context, states, child) => LiquidGlass(
style: styleOf(states),
pressed: states.contains(WidgetState.pressed),
child: child,
),
);
}
@override
Widget build(BuildContext context) {
if (glow case final glow?) {
return _LiquidGlassPane(
style: style,
clipper: clipper,
shadows: shadows,
press: 0,
fading: false,
glow: glow,
child: child,
);
}
return TweenAnimationBuilder<double>(
tween: Tween(end: pressed ? 1 : 0),
duration: context.motionDuration(pressed ? _glowIn : _glowOut),
curve: Curves.easeOut,
builder: (context, press, child) => _LiquidGlassPane(
style: style,
clipper: clipper,
shadows: shadows,
press: press,
fading: !pressed,
glow: null,
child: child,
),
child: child,
);
}
}
class _LiquidGlassPane extends SingleChildRenderObjectWidget {
const _LiquidGlassPane({
required this.style,
required this.clipper,
required this.shadows,
required this.press,
required this.fading,
required this.glow,
super.child,
});
final LiquidGlassStyle style;
final CustomClipper<Path>? clipper;
final List<BoxShadow> shadows;
final double press;
final bool fading;
final LiquidGlassGlow? glow;
@override
_RenderLiquidGlass createRenderObject(BuildContext context) {
return _RenderLiquidGlass(
style: style,
clipper: clipper,
shadows: shadows,
press: press,
fading: fading,
glow: glow,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderLiquidGlass renderObject,
) {
renderObject
..style = style
..clipper = clipper
..shadows = shadows
..press = press
..fading = fading
..glow = glow;
}
}
class _RenderLiquidGlass extends RenderProxyBox {
_RenderLiquidGlass({
required LiquidGlassStyle style,
required CustomClipper<Path>? clipper,
required List<BoxShadow> shadows,
required double press,
required bool fading,
required LiquidGlassGlow? glow,
}) : _style = style,
_clipper = clipper,
_shadows = shadows,
_press = press,
_fading = fading,
_glow = glow;
late final _pane = LiquidGlassPane(markNeedsPaint);
LiquidGlassStyle _style;
set style(LiquidGlassStyle value) {
if (value == _style) {
return;
}
_style = value;
markNeedsPaint();
}
CustomClipper<Path>? _clipper;
set clipper(CustomClipper<Path>? value) {
final old = _clipper;
if (value == old) {
return;
}
if (attached) {
old?.removeListener(_reclip);
value?.addListener(_reclip);
}
_clipper = value;
if (value == null ||
old == null ||
value.runtimeType != old.runtimeType ||
value.shouldReclip(old)) {
_reclip();
}
}
Path? _outline;
Size? _outlineSize;
void _reclip() {
_outline = null;
markNeedsPaint();
}
List<BoxShadow> _shadows;
set shadows(List<BoxShadow> value) {
if (listEquals(value, _shadows)) {
return;
}
_shadows = value;
markNeedsPaint();
}
double _press;
set press(double value) {
if (value == _press) {
return;
}
_press = value;
if (value == 0) {
_touch = null;
}
markNeedsPaint();
}
bool _fading;
set fading(bool value) {
if (value == _fading) {
return;
}
_fading = value;
markNeedsPaint();
}
// The pane wraps what it is the glass of, so it hears the presses on it and
// lights from the finger, as iOS does; a key press lights its center.
Offset? _touch;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
if (event is PointerDownEvent || event is PointerMoveEvent) {
_touch = event.localPosition;
if (_press > 0) {
markNeedsPaint();
}
} else if (_press == 0) {
_touch = null;
}
}
LiquidGlassGlow? _glow;
set glow(LiquidGlassGlow? value) {
if (value == _glow) {
return;
}
_glow = value;
markNeedsPaint();
}
@override
bool get alwaysNeedsCompositing => true;
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_clipper?.addListener(_reclip);
}
@override
void detach() {
_clipper?.removeListener(_reclip);
super.detach();
}
@override
void dispose() {
_pane.dispose();
super.dispose();
}
Path? get _currentOutline {
final clipper = _clipper;
if (clipper == null) {
return null;
}
if (_outline == null || _outlineSize != size) {
_outline = clipper.getClip(size);
_outlineSize = size;
}
return _outline;
}
@override
void paint(PaintingContext context, Offset offset) {
if (size.isEmpty) {
_pane.clear();
} else {
final outline = _currentOutline;
_pane.paint(
context,
offset & size,
shape: outline != null
? LiquidGlassOutline(outline)
: LiquidGlassCorners(BorderRadius.circular(size.shortestSide / 2)),
style: _style,
shadows: _shadows,
glow:
_glow ??
LiquidGlassGlow(
center: _touch ?? size.center(Offset.zero),
reach:
size.height /
math.sqrt2 *
(1 + (_fading ? _glowSpread * (1 - _press) : 0)),
strength: _press * 0.16,
),
);
}
super.paint(context, offset);
}
}
sealed class LiquidGlassShape {
const LiquidGlassShape();
}
/// A rounded rectangle, no corner rounder than half the pane's short side.
final class LiquidGlassCorners extends LiquidGlassShape {
const LiquidGlassCorners(this.radius);
LiquidGlassCorners.circular(double radius)
: radius = BorderRadius.circular(radius);
final BorderRadius radius;
}
/// A path laid out from the pane's top left, as [LiquidGlass.clipper] gives.
final class LiquidGlassOutline extends LiquidGlassShape {
const LiquidGlassOutline(this.path);
final Path path;
}
/// A pane for a render object that shapes the glass itself, as a morph does.
class LiquidGlassPane {
LiquidGlassPane(this.onReady);
final VoidCallback onReady;
final _layer = LayerHandle<_LiquidGlassLayer>();
Path? _profiled;
List<double> _profile = _flatProfile;
/// A pane moving within [hold] keeps one capture (.agents/architecture.md).
void paint(
PaintingContext context,
Rect rect, {
required LiquidGlassShape shape,
required LiquidGlassStyle style,
List<BoxShadow> shadows = const [],
LiquidGlassGlow glow = LiquidGlassGlow.none,
Rect? hold,
LiquidGlassRim? rim,
}) {
final pane = _layer.layer ??= _LiquidGlassLayer();
assert(rim == null || shape is LiquidGlassCorners);
switch (shape) {
case LiquidGlassCorners(:final radius):
final limit = Radius.circular(rect.shortestSide / 2);
final corners = BorderRadius.only(
topLeft: radius.topLeft.clamp(maximum: limit),
topRight: radius.topRight.clamp(maximum: limit),
bottomLeft: radius.bottomLeft.clamp(maximum: limit),
bottomRight: radius.bottomRight.clamp(maximum: limit),
);
_profiled = null;
if (hold == null) {
_paintShadows(context.canvas, rect, corners, shadows);
}
pane
..corners = corners
..outline = null
..profile = _flatProfile;
case LiquidGlassOutline(:final path):
assert(shadows.isEmpty, 'An outlined pane casts none');
if (!identical(path, _profiled)) {
_profiled = path;
_profile = liquidGlassProfile(path, rect.size.center(Offset.zero));
}
pane
..corners = BorderRadius.zero
..outline = path.shift(rect.topLeft)
..profile = _profile;
}
pane
..program = _GlassProgram.request(onReady)
..rect = rect
..style = style
..glow = glow
..hold = hold
..rim = rim;
context.addLayer(pane);
if (hold != null) {
_paintShadows(context.canvas, rect, pane.corners, shadows);
}
}
void clear() => _layer.layer = null;
void dispose() {
_GlassProgram.forget(onReady);
clear();
}
static void _paintShadows(
Canvas canvas,
Rect rect,
BorderRadius corners,
List<BoxShadow> shadows,
) {
if (shadows.isEmpty) {
return;
}
final shape = AppShape.of(corners);
final reach = shadows.fold(
0.0,
(reach, shadow) => math.max(
reach,
shadow.offset.distance + shadow.spreadRadius + shadow.blurRadius * 2,
),
);
canvas
..save()
..clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(rect.inflate(reach))
..addPath(shape.getOuterPath(rect), Offset.zero),
);
for (final shadow in shadows) {
canvas.drawRSuperellipse(
corners.toRSuperellipse(
rect.shift(shadow.offset).inflate(shadow.spreadRadius),
),
shadow.toPaint(),
);
}
canvas.restore();
}
}
abstract final class _GlassProgram {
static ui.FragmentProgram? _program;
static Future<void>? _loading;
/// Final: a bundled shader that failed once fails again, and every attempt
/// loads synchronously on the UI isolate.
static var _failed = false;
static final _waiting = <VoidCallback>{};
static ui.FragmentProgram? request(VoidCallback onLoaded) {
if (_program != null ||
_failed ||
!ui.ImageFilter.isShaderFilterSupported) {
return _program;
}
_waiting.add(onLoaded);
_loading ??= _load().then(
(program) {
_program = program;
final waiting = _waiting.toList();
_waiting.clear();
for (final callback in waiting) {
callback();
}
},
onError: (Object error, StackTrace stack) {
_failed = true;
_waiting.clear();
FlutterError.reportError(
FlutterErrorDetails(
exception: error,
stack: stack,
library: 'liquid glass',
),
);
},
);
return null;
}
static void forget(VoidCallback onLoaded) => _waiting.remove(onLoaded);
static Future<ui.FragmentProgram> _load() =>
ui.FragmentProgram.fromAsset(_shaderAsset);
}
class _LiquidGlassLayer extends Layer {
static int _skipped = 0;
ui.FragmentProgram? program;
Rect rect = Rect.zero;
BorderRadius corners = BorderRadius.zero;
Path? outline;
List<double> profile = _flatProfile;
LiquidGlassStyle style = const LiquidGlassStyle(tint: Colors.transparent);
LiquidGlassGlow glow = LiquidGlassGlow.none;
Rect? hold;
LiquidGlassRim? rim;
_Placement? _held;
ui.Picture? _frost;
(Rect, BorderRadius, Path?, LiquidGlassStyle, LiquidGlassRim?)? _frostKey;
int _capturedPass = -1;
ui.Picture? _captured;
ui.FragmentShader? _captureShader;
(ui.Image, Path?, BlendMode, ui.FragmentProgram?, List<double>)? _captureKey;
@override
bool get alwaysNeedsAddToScene => true;
@override
void dispose() {
_frost?.dispose();
_dropCaptured();
_captureShader?.dispose();
super.dispose();
}
@override
void addToScene(ui.SceneBuilder builder) {
if (_skipped > 0) {
return;
}
if (_capturedPass == _LiquidGlassHostLayer._pass) {
if (_captured case final picture?) {
builder.addPicture(Offset.zero, picture);
}
return;
}
_dropCaptured();
_addFrost(builder);
}
/// The uniforms from the pane's shape on, after where it and its texture
/// lie.
List<double> _look(LiquidGlassStyle style) => [
rect.width,
rect.height,
style.bevel,
style.blur,
corners.topLeft.x,
corners.topRight.x,
corners.bottomRight.x,
corners.bottomLeft.x,
...liquidGlassCornerCurves(rect.size, corners),
style.refraction,
style.saturation,
style.brightness,
style.tint.r,
style.tint.g,
style.tint.b,
style.tint.a,
style.rim,
style.rimWidth,
style.endShade,
glow.center.dx,
glow.center.dy,
glow.strength,
style.contrast,
glow.reach,
glow.wash,
style.hairline,
style.dispersion,
style.rimBlur,
style.rimTail,
style.rimFocus,
style.rimAdded,
style._hairlineSide,
outline == null ? 0.0 : 1.0,
...profile,
...rim?._uniforms(rect.size, corners.topLeft.x) ?? _noShine,
];
void _addFrost(ui.SceneBuilder builder) {
final key = (rect, corners, outline, style, rim);
if (_frost == null || key != _frostKey) {
_frost?.dispose();
_frost = _paintFrost();
_frostKey = key;
}
if (outline case final outline?) {
builder.pushClipPath(outline);
} else {
builder.pushClipRSuperellipse(corners.toRSuperellipse(rect));
}
builder.addPicture(Offset.zero, _frost!);
builder.pop();
}
/// Draws the pane from [atlas], where a host laid out what lies behind it,
/// until the next pass of the host; [mapping] places the pane's slot.
void _captureFrom(
int pass,
ui.Image atlas,
List<double> mapping,
BlendMode blendMode,
LiquidGlassStyle style,
) {
_capturedPass = pass;
final program = this.program;
final uniforms = <double>[
atlas.width.toDouble(),
atlas.height.toDouble(),
-rect.left,
-rect.top,
1,
1,
...mapping,
..._look(style),
];
if (_captureKey
case (final image, final path, final mode, final shaded, final values)?
when _captured != null &&
identical(image, atlas) &&
identical(path, outline) &&
mode == blendMode &&
identical(shaded, program) &&
listEquals(values, uniforms)) {
return;
}
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
if (program == null) {
_frostFrom(canvas, atlas, mapping, blendMode, style);
} else {
final shader = _captureShader ??= program.fragmentShader();
shader.setImageSampler(0, atlas, filterQuality: FilterQuality.low);
for (final (index, value) in uniforms.indexed) {
shader.setFloat(index, value);
}
final paint = Paint()
..shader = shader
..blendMode = blendMode;
if (outline case final outline?) {
canvas.drawPath(outline, paint);
} else {
canvas.drawRSuperellipse(corners.toRSuperellipse(rect), paint);
}
}
_captured?.dispose();
_captured = recorder.endRecording();
_captureKey = (atlas, outline, blendMode, program, uniforms);
}
/// Frosts the pane, without bending it, from the part of [atlas] behind it,
/// which holds its blur already.
void _frostFrom(
Canvas canvas,
ui.Image atlas,
List<double> mapping,
BlendMode blendMode,
LiquidGlassStyle style,
) {
canvas.save();
if (outline case final outline?) {
canvas.clipPath(outline);
} else {
canvas.clipRSuperellipse(corners.toRSuperellipse(rect));
}
final saturate = ColorFilter.matrix(
_optics(style.saturation, style.brightness),
);
void drawSlot(int from, Paint paint) => canvas.drawImageRect(
atlas,
Rect.fromLTWH(
-mapping[from] / mapping[from + 2],
-mapping[from + 1] / mapping[from + 3],
rect.width / mapping[from + 2],
rect.height / mapping[from + 3],
),
rect,
paint
..filterQuality = FilterQuality.low
..colorFilter = saturate,
);
drawSlot(0, Paint()..blendMode = blendMode);
final haze = mapping[_hazeShare];
if (haze > 0) {
drawSlot(_hazeSample, Paint()..color = Color.fromRGBO(0, 0, 0, haze));
}
_drawFrost(canvas, overCapture: true);
canvas.restore();
}
/// Draws nothing until the next pass of the host, as nothing of the pane
/// shows.
void _captureNothing(int pass) {
_capturedPass = pass;
_dropCaptured();
}
void _dropCaptured() {
_captured?.dispose();
_captured = null;
_captureKey = null;
}
ui.Picture _paintFrost() {
final recorder = ui.PictureRecorder();
_drawFrost(Canvas(recorder));
return recorder.endRecording();
}
/// Thickens the tint to stand in for the frost, unless the pane lies
/// [overCapture], which holds the blur.
void _drawFrost(Canvas canvas, {bool overCapture = false}) {
final tint = style.tint;
final fill = Paint()
..color = overCapture
? tint
: tint.withValues(alpha: math.min(1, tint.a + 0.2));
final rim = style.rim;
final stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = style.rimWidth
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: rim),
Colors.white.withValues(alpha: rim * 0.15),
Colors.white.withValues(alpha: rim * 0.5),
],
).createShader(rect);
final hairline = Paint()
..style = PaintingStyle.stroke
..color = Colors.black.withValues(
alpha: (style.hairline + style._hairlineSide) / 2 * 0.3,
);
if (outline case final outline?) {
// The clip keeps the inner half of a stroke twice as wide.
canvas
..drawPath(outline, fill)
..drawPath(outline, stroke..strokeWidth = style.rimWidth * 2)
..drawPath(outline, hairline..strokeWidth = _hairlineWidth * 2);
} else {
final shape = corners.toRSuperellipse(rect);
canvas
..drawRSuperellipse(shape, fill)
..drawRSuperellipse(shape.deflate(style.rimWidth / 2), stroke)
..drawRSuperellipse(
shape.deflate(_hairlineWidth / 2),
hairline..strokeWidth = _hairlineWidth,
);
this.rim?._paintShine(canvas, rect, corners.topLeft.x);
}
}
}
/// How far [outline] reaches from [center] along [_profileTurns] even turns,
/// the first toward +x and the rest toward +y, as the shader reads it.
@visibleForTesting
List<double> liquidGlassProfile(Path outline, Offset center) {
const step = 2 * math.pi / _profileTurns;
final profile = List<double>.filled(_profileTurns, 0);
void cross(Offset from, Offset to) {
final start = from.direction;
var span = to.direction - start;
if (span > math.pi) {
span -= 2 * math.pi;
} else if (span <= -math.pi) {
span += 2 * math.pi;
}
final edge = to - from;
final first = (math.min(start, start + span) / step).ceil();
final last = (math.max(start, start + span) / step).floor();
for (var turn = first; turn <= last; turn++) {
final ray = Offset.fromDirection(turn * step);
final facing = ray.dx * edge.dy - ray.dy * edge.dx;
profile[turn % _profileTurns] = facing.abs() < 1e-9
? math.max(from.distance, to.distance)
: (from.dx * edge.dy - from.dy * edge.dx) / facing;
}
}
for (final metric in outline.computeMetrics()) {
final samples = math.max(_profileTurns * 4, metric.length.ceil());
Offset pointAt(int index) =>
metric.getTangentForOffset(metric.length * index / samples)!.position -
center;
var previous = pointAt(0);
for (var index = 1; index <= samples; index++) {
final next = pointAt(index);
cross(previous, next);
previous = next;
}
}
return profile;
}
@visibleForTesting
List<double> liquidGlassCornerCurves(Size size, BorderRadius corners) => [
for (final radius in [
corners.topLeft.x,
corners.topRight.x,
corners.bottomRight.x,
corners.bottomLeft.x,
])
..._cornerCurve(size.width / 2, size.height / 2, radius),
];
List<double> _cornerCurve(double halfWidth, double halfHeight, double radius) {
final along = _superellipseOctant(halfWidth, radius);
final across = _superellipseOctant(halfHeight, radius);
return [
along.n,
along.joint.dx,
along.joint.dy,
along.radius,
across.n,
across.joint.dx,
across.joint.dy,
across.radius,
along.center.dx,
along.center.dy,
across.center.dx,
across.center.dy,
];
}
typedef _Octant = ({double n, Offset joint, Offset center, double radius});
/// ComputeOctant in impeller/geometry/round_superellipse_param.cc, which the
/// shader's cornerInset follows.
_Octant _superellipseOctant(double a, double radius) {
if (radius <= 1e-3) {
return (n: 0, joint: Offset.zero, center: Offset.zero, radius: 0);
}
final (n, jointAt) = _octantFit(a * 2 / radius);
final joint = Offset(
jointAt * a,
math.pow(1 - math.pow(jointAt, n), 1 / n) * a,
);
final tangent = math.pow(joint.dx / joint.dy, n - 1);
final diagonal = (joint.dx - tangent * joint.dy) / (1 - tangent);
final meet = a - _octantGap * radius;
final arc = (meet - diagonal) * math.sqrt2;
final chord = Offset(meet, meet) - joint;
final reach = math.sqrt(math.max(0, arc * arc - chord.distanceSquared / 4));
final center =
(joint + Offset(meet, meet)) / 2 +
Offset(chord.dy, -chord.dx) / chord.distance * reach;
return (n: n, joint: joint, center: center, radius: arc);
}
const _octantGap = 1 - math.sqrt1_2;
/// By the ratio of an octant's side to its radius: n, and 1 / (1 - xJ / a).
const _octantFits = [
(2.0, 2.00000000, 1.13276676),
(2.1, 2.18349805, 1.20311921),
(2.2, 2.33888662, 1.28698796),
(2.3, 2.48660575, 1.36351941),
(2.4, 2.62226596, 1.44717976),
(2.5, 2.75148990, 1.53385819),
(3.0, 3.36298265, 1.98288283),
(3.5, 4.08649929, 2.23811846),
(4.0, 4.85481134, 2.47563463),
(4.5, 5.62945551, 2.72948597),
(5.0, 6.43023796, 2.98020421),
];
(double, double) _octantFit(double ratio) {
final (lastRatio, lastN, lastK) = _octantFits.last;
final past = ratio - lastRatio;
if (past > 0) {
return (lastN + 1.559599389 * past, 1 - 1 / (lastK + 0.522807185 * past));
}
var index = 0;
while (index < _octantFits.length - 2 && ratio > _octantFits[index + 1].$1) {
index++;
}
final (r0, n0, k0) = _octantFits[index];
final (r1, n1, k1) = _octantFits[index + 1];
final t = math.max(0.0, (ratio - r0) / (r1 - r0));
return (ui.lerpDouble(n0, n1, t)!, 1 - 1 / ui.lerpDouble(k0, k1, t)!);
}
List<double> _optics(double saturation, double brightness) {
final red = 0.2126 * brightness;
final green = 0.7152 * brightness;
final blue = 0.0722 * brightness;
final rest = 1 - saturation;
final kept = saturation * brightness;
return [
red * rest + kept, green * rest, blue * rest, 0, 0, //
red * rest, green * rest + kept, blue * rest, 0, 0,
red * rest, green * rest, blue * rest + kept, 0, 0,
0, 0, 0, 1, 0,
];
}