Files
FlClash/test/widgets/liquid_glass_test.dart
T
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

170 lines
5.4 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
void main() {
test('a profile reads how far an outline reaches each way', () {
final box = liquidGlassProfile(
Path()..addRect(const Rect.fromLTWH(0, 0, 40, 20)),
const Offset(20, 10),
);
expect(box[0], moreOrLessEquals(20, epsilon: 1e-6));
expect(box[16], moreOrLessEquals(10, epsilon: 1e-6));
expect(box[32], moreOrLessEquals(20, epsilon: 1e-6));
expect(box[48], moreOrLessEquals(10, epsilon: 1e-6));
expect(box[8], moreOrLessEquals(10 * math.sqrt2, epsilon: 1e-6));
final round = liquidGlassProfile(
Path()..addOval(Rect.fromCircle(center: Offset.zero, radius: 30)),
Offset.zero,
);
expect(round, everyElement(moreOrLessEquals(30, epsilon: 0.05)));
});
test('corner curves lie on the superellipse the engine clips to', () {
for (final (size, radius) in [
(const Size(338, 62), 31.0),
(const Size(84, 54), 27.0),
(const Size(44, 44), 22.0),
(const Size(270, 160), 34.0),
(const Size(60, 200), 30.0),
]) {
final corners = BorderRadius.circular(radius);
final path = Path()
..addRSuperellipse(corners.toRSuperellipse(Offset.zero & size));
final outline = [
for (final metric in path.computeMetrics())
for (var at = 0.0; at < metric.length; at += 0.02)
metric.getTangentForOffset(at)!.position,
];
double offPath(Offset point) =>
outline.map((on) => (on - point).distance).reduce(math.min);
final curves = liquidGlassCornerCurves(size, corners);
final half = size.center(Offset.zero);
final shift = half.dx - half.dy;
final octants = [
(
a: half.dx,
curve: curves.sublist(0, 4),
center: Offset(curves[8], curves[9]),
place: (Offset w) => half - Offset(w.dx, w.dy - shift),
),
(
a: half.dy,
curve: curves.sublist(4, 8),
center: Offset(curves[10], curves[11]),
place: (Offset w) => half - Offset(w.dy + shift, w.dx),
),
];
for (final (:a, :curve, :center, :place) in octants) {
final [n, jx, jy, arc] = curve;
for (var x = 0.0; x <= jx; x += jx / 16) {
final y = math.pow(1 - math.pow(x / a, n), 1 / n) * a;
expect(offPath(place(Offset(x, y))), lessThan(0.06));
}
final from = (Offset(jx, jy) - center).direction;
for (var step = 0; step <= 8; step++) {
final turn = from - (from - math.pi / 4) * step / 8;
final point = center + Offset.fromDirection(turn, arc);
expect(offPath(place(point)), lessThan(0.06));
}
}
}
expect(
liquidGlassCornerCurves(const Size(40, 20), BorderRadius.zero),
everyElement(0),
);
});
testWidgets('a pane shaped by a clipper keeps to its path', (tester) async {
final key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Center(
child: RepaintBoundary(
key: key,
child: const SizedBox.square(
dimension: 40,
child: LiquidGlass(
style: LiquidGlassStyle(tint: Colors.blue),
clipper: _Diamond(),
),
),
),
),
),
);
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = boundary.toImageSync();
addTearDown(image.dispose);
await tester.runAsync(() async {
final bytes = (await image.toByteData())!;
int alphaAt(int x, int y) =>
bytes.getUint8((y * image.width + x) * 4 + 3);
expect(alphaAt(5, 5), 0);
expect(alphaAt(34, 34), 0);
expect(alphaAt(20, 20), greaterThan(0));
expect(alphaAt(20, 3), greaterThan(0));
});
});
testWidgets('a snapshot taken without panes holds only what stands on them', (
tester,
) async {
final key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Center(
child: RepaintBoundary(
key: key,
child: const SizedBox.square(
dimension: 40,
child: LiquidGlass(style: LiquidGlassStyle(tint: Colors.blue)),
),
),
),
),
);
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final bare = LiquidGlass.withoutPanes(boundary.toImageSync);
final glass = boundary.toImageSync();
addTearDown(bare.dispose);
addTearDown(glass.dispose);
Future<int> centerAlpha(ui.Image image) async {
final bytes = (await image.toByteData())!;
return bytes.getUint8(
((image.height ~/ 2) * image.width + image.width ~/ 2) * 4 + 3,
);
}
await tester.runAsync(() async {
expect(await centerAlpha(bare), 0);
expect(await centerAlpha(glass), greaterThan(0));
});
});
}
class _Diamond extends CustomClipper<Path> {
const _Diamond();
@override
Path getClip(Size size) => Path()
..moveTo(size.width / 2, 0)
..lineTo(size.width, size.height / 2)
..lineTo(size.width / 2, size.height)
..lineTo(0, size.height / 2)
..close();
@override
bool shouldReclip(_Diamond oldClipper) => false;
}