Files
FlClash/lib/ui/shape.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

310 lines
9.4 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' show ClipOp, lerpDouble;
import 'package:material_ui/material_ui.dart';
abstract final class AppCorner {
static const double none = 0;
static const double xs = 4;
static const double sm = 8;
static const double md = 16;
static const double lg = 20;
static const double xl = 24;
static const double xxl = 28;
/// UIAlertController's corner on iOS 27, rounder than a sheet's.
static const double alert = 34;
static const double full = 1000;
static double fit(double shortestSide) {
final limit = shortestSide / 3;
if (limit >= xxl) return xxl;
if (limit >= xl) return xl;
if (limit >= lg) return lg;
if (limit >= md) return md;
if (limit >= sm) return sm;
if (limit >= xs) return xs;
return none;
}
}
abstract final class AppRadius {
static const BorderRadius none = BorderRadius.zero;
static const BorderRadius xs = BorderRadius.all(
Radius.circular(AppCorner.xs),
);
static const BorderRadius sm = BorderRadius.all(
Radius.circular(AppCorner.sm),
);
static const BorderRadius md = BorderRadius.all(
Radius.circular(AppCorner.md),
);
static const BorderRadius lg = BorderRadius.all(
Radius.circular(AppCorner.lg),
);
static const BorderRadius xl = BorderRadius.all(
Radius.circular(AppCorner.xl),
);
static const BorderRadius xxl = BorderRadius.all(
Radius.circular(AppCorner.xxl),
);
static const BorderRadius full = BorderRadius.all(
Radius.circular(AppCorner.full),
);
static BorderRadius all(double corner) => BorderRadius.circular(corner);
static BorderRadius top(double corner) =>
BorderRadius.vertical(top: Radius.circular(corner));
static BorderRadius vertical({
double top = AppCorner.none,
double bottom = AppCorner.none,
}) => BorderRadius.vertical(
top: Radius.circular(top),
bottom: Radius.circular(bottom),
);
}
abstract final class AppShape {
static const RoundedSuperellipseBorder none = RoundedSuperellipseBorder();
static const RoundedSuperellipseBorder xs = RoundedSuperellipseBorder(
borderRadius: AppRadius.xs,
);
static const RoundedSuperellipseBorder sm = RoundedSuperellipseBorder(
borderRadius: AppRadius.sm,
);
static const RoundedSuperellipseBorder md = RoundedSuperellipseBorder(
borderRadius: AppRadius.md,
);
static const RoundedSuperellipseBorder lg = RoundedSuperellipseBorder(
borderRadius: AppRadius.lg,
);
static const RoundedSuperellipseBorder xl = RoundedSuperellipseBorder(
borderRadius: AppRadius.xl,
);
static const RoundedSuperellipseBorder xxl = RoundedSuperellipseBorder(
borderRadius: AppRadius.xxl,
);
static const RoundedSuperellipseBorder full = RoundedSuperellipseBorder(
borderRadius: AppRadius.full,
);
static const CircleBorder circle = CircleBorder();
static const AppInputBorder input = AppInputBorder();
static RoundedSuperellipseBorder all(double corner) =>
RoundedSuperellipseBorder(borderRadius: AppRadius.all(corner));
static RoundedSuperellipseBorder top(double corner) =>
RoundedSuperellipseBorder(borderRadius: AppRadius.top(corner));
static RoundedSuperellipseBorder vertical({
double top = AppCorner.none,
double bottom = AppCorner.none,
}) => RoundedSuperellipseBorder(
borderRadius: AppRadius.vertical(top: top, bottom: bottom),
);
static RoundedSuperellipseBorder of(BorderRadius borderRadius) =>
RoundedSuperellipseBorder(borderRadius: borderRadius);
}
class AppInputBorder extends InputBorder {
const AppInputBorder({
super.borderSide = const BorderSide(),
this.borderRadius = AppRadius.md,
this.gapPadding = 4.0,
});
final BorderRadius borderRadius;
final double gapPadding;
RoundedSuperellipseBorder get _shape =>
RoundedSuperellipseBorder(borderRadius: borderRadius, side: borderSide);
@override
bool get isOutline => true;
@override
bool get preferPaintInterior => true;
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.all(borderSide.width);
@override
AppInputBorder copyWith({
BorderSide? borderSide,
BorderRadius? borderRadius,
double? gapPadding,
}) {
return AppInputBorder(
borderSide: borderSide ?? this.borderSide,
borderRadius: borderRadius ?? this.borderRadius,
gapPadding: gapPadding ?? this.gapPadding,
);
}
@override
AppInputBorder scale(double t) {
return AppInputBorder(
borderSide: borderSide.scale(t),
borderRadius: borderRadius * t,
gapPadding: gapPadding * t,
);
}
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
_shape.getInnerPath(rect, textDirection: textDirection);
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
_shape.getOuterPath(rect, textDirection: textDirection);
@override
void paintInterior(
Canvas canvas,
Rect rect,
Paint paint, {
TextDirection? textDirection,
}) => _shape.paintInterior(canvas, rect, paint, textDirection: textDirection);
@override
ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
if (a is AppInputBorder) {
return AppInputBorder(
borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t)!,
gapPadding: a.gapPadding,
);
}
return super.lerpFrom(a, t);
}
@override
ShapeBorder? lerpTo(ShapeBorder? b, double t) {
if (b is AppInputBorder) {
return AppInputBorder(
borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t)!,
gapPadding: b.gapPadding,
);
}
return super.lerpTo(b, t);
}
@override
void paint(
Canvas canvas,
Rect rect, {
double? gapStart,
double gapExtent = 0.0,
double gapPercentage = 0.0,
TextDirection? textDirection,
}) {
final outline = rect.deflate(borderSide.width / 2);
if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) {
_shape.paint(canvas, rect, textDirection: textDirection);
return;
}
final extent = lerpDouble(
0.0,
gapExtent + gapPadding * 2.0,
gapPercentage,
)!;
final start = switch (textDirection ?? TextDirection.ltr) {
TextDirection.rtl => gapStart + gapPadding - extent,
TextDirection.ltr => gapStart - gapPadding,
};
final radii = borderRadius.resolve(textDirection);
final left = outline.left + math.max(0.0, start);
final depth =
math.max(radii.topLeft.y, radii.topRight.y) + borderSide.width;
canvas
..save()
..clipRect(
Rect.fromLTRB(
left,
outline.top - borderSide.width,
left + extent,
outline.top + depth,
),
clipOp: ClipOp.difference,
);
_shape.paint(canvas, rect, textDirection: textDirection);
canvas.restore();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is AppInputBorder &&
other.borderSide == borderSide &&
other.borderRadius == borderRadius &&
other.gapPadding == gapPadding;
}
@override
int get hashCode => Object.hash(borderSide, borderRadius, gapPadding);
}
extension AppShapeThemeExt on ThemeData {
ThemeData get withAppShapes => copyWith(
cardTheme: cardTheme.copyWith(shape: AppShape.md),
dialogTheme: dialogTheme.copyWith(shape: AppShape.all(AppCorner.alert)),
bottomSheetTheme: bottomSheetTheme.copyWith(
shape: AppShape.top(AppCorner.xxl),
),
snackBarTheme: snackBarTheme.copyWith(shape: AppShape.xs),
tooltipTheme: tooltipTheme.copyWith(
// A decoration replaces Material's tooltip colours too, so restate them.
decoration: ShapeDecoration(
shape: AppShape.xs,
color: switch (brightness) {
Brightness.dark => Colors.white.withAlpha(230),
Brightness.light => Colors.grey[700]!.withAlpha(230),
},
),
),
chipTheme: chipTheme.copyWith(shape: AppShape.sm),
popupMenuTheme: popupMenuTheme.copyWith(shape: AppShape.md),
menuTheme: MenuThemeData(
style: (menuTheme.style ?? const MenuStyle()).copyWith(
shape: const WidgetStatePropertyAll(AppShape.md),
),
),
inputDecorationTheme: inputDecorationTheme.copyWith(border: AppShape.input),
floatingActionButtonTheme: floatingActionButtonTheme.copyWith(
shape: AppShape.md,
),
navigationBarTheme: navigationBarTheme.copyWith(
indicatorShape: AppShape.full,
),
navigationRailTheme: navigationRailTheme.copyWith(
indicatorShape: AppShape.full,
),
progressIndicatorTheme: progressIndicatorTheme.copyWith(
borderRadius: AppRadius.full,
),
filledButtonTheme: FilledButtonThemeData(
style: filledButtonTheme.style?.merge(_pillButton) ?? _pillButton,
),
textButtonTheme: TextButtonThemeData(
style: textButtonTheme.style?.merge(_pillButton) ?? _pillButton,
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: outlinedButtonTheme.style?.merge(_pillButton) ?? _pillButton,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: elevatedButtonTheme.style?.merge(_pillButton) ?? _pillButton,
),
iconButtonTheme: IconButtonThemeData(
style: iconButtonTheme.style?.merge(_pillButton) ?? _pillButton,
),
);
}
const _pillButton = ButtonStyle(shape: WidgetStatePropertyAll(AppShape.full));