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

824 lines
23 KiB
Dart

import 'dart:math' as math;
import 'package:fl_clash/ui/color.dart';
import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/input.dart';
import 'package:fl_clash/ui/liquid_glass.dart';
import 'package:fl_clash/ui/motion.dart';
import 'package:fl_clash/ui/navigation_dock.dart';
import 'package:fl_clash/ui/shape.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:fl_clash/ui/typography.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
const double _actionGap = 8;
const double _actionInset = 16;
const double _actionHeight = 48;
const double _textInset = 30;
const double _alertWidth = 348;
const double _contentMaxWidth = 300;
// Material 3 sets a filled field's text 4 in past its content padding.
const double _fieldInputGap = 4;
const double _pressGrowth = 4;
const double _pressPull = 3;
const double _pressedFillShare = 0.4;
const double _pressedDarkDefaultShare = 0.5;
// UIAlertController on iOS 27 grows in from 1.2 times its size and only fades
// out, both on one critically damped spring that settles in 0.404 s.
const _alertDuration = Duration(milliseconds: 404);
const double _alertEntryScale = 1.2;
final _alertSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 275),
),
seconds: _alertDuration.inMicroseconds / Duration.microsecondsPerSecond,
);
Future<T?> showFloatingDialog<T>({
required BuildContext context,
required Widget child,
bool dismissible = true,
}) {
return Navigator.of(context).push<T>(
_AlertRoute<T>(
barrierColor: context.colorScheme.alertScrim,
barrierDismissible: dismissible,
barrierLabel: dismissible
? MaterialLocalizations.of(context).modalBarrierDismissLabel
: null,
builder: (context) => SurfaceScope(
level: SurfaceLevel.floatingOf(context.colorScheme),
child: child,
),
),
);
}
/// A route whose navigator an alert raised from inside it opens on.
mixin AlertHostRoute<T> on ModalRoute<T> {}
class _AlertRoute<T> extends PopupRoute<T>
with AlertMotionRouteMixin<T>, AlertHostRoute<T> {
_AlertRoute({
required this.barrierColor,
required this.barrierDismissible,
required this.barrierLabel,
required this.builder,
});
@override
final Color barrierColor;
@override
final bool barrierDismissible;
@override
final String? barrierLabel;
final WidgetBuilder builder;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: SafeArea(child: Builder(builder: builder)),
);
}
}
/// Moves a route, and its barrier, as UIKit moves an alert.
mixin AlertMotionRouteMixin<T> on PopupRoute<T> {
CurvedAnimation? _sprung;
// The barrier fades on the spring both ways, where `barrierCurve` would
// replay it backwards on the way out.
Animation<double> get _spring => _sprung ??= CurvedAnimation(
parent: animation!,
curve: _alertSpring,
reverseCurve: _alertSpring.flipped,
);
VoidCallback? get onBarrierDismiss => null;
@override
Duration get transitionDuration => _alertDuration;
@override
Widget buildModalBarrier() {
final barrierColor = this.barrierColor;
if (offstage || barrierColor == null) {
return ModalBarrier(
dismissible: barrierDismissible,
semanticsLabel: barrierLabel,
barrierSemanticsDismissible: semanticsDismissible,
onDismiss: onBarrierDismiss,
);
}
return AnimatedModalBarrier(
color: _spring.drive(
ColorTween(begin: barrierColor.withValues(alpha: 0), end: barrierColor),
),
dismissible: barrierDismissible,
semanticsLabel: barrierLabel,
barrierSemanticsDismissible: semanticsDismissible,
onDismiss: onBarrierDismiss,
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _AlertTransition(animation: _spring, child: child);
}
@override
void dispose() {
_sprung?.dispose();
super.dispose();
}
}
class _AlertTransition extends StatefulWidget {
const _AlertTransition({required this.animation, required this.child});
final Animation<double> animation;
final Widget child;
@override
State<_AlertTransition> createState() => _AlertTransitionState();
}
class _AlertTransitionState extends State<_AlertTransition> {
double? _heldScale;
double get _scale =>
_heldScale ??
_alertEntryScale + (1 - _alertEntryScale) * widget.animation.value;
@override
void initState() {
super.initState();
widget.animation.addStatusListener(_handleStatus);
}
@override
void didUpdateWidget(_AlertTransition oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.animation != oldWidget.animation) {
oldWidget.animation.removeStatusListener(_handleStatus);
widget.animation.addStatusListener(_handleStatus);
}
}
@override
void dispose() {
widget.animation.removeStatusListener(_handleStatus);
super.dispose();
}
void _handleStatus(AnimationStatus status) {
_heldScale = status == AnimationStatus.reverse ? _scale : null;
}
@override
Widget build(BuildContext context) {
return LiquidGlassFade(
opacity: widget.animation,
child: AnimatedBuilder(
animation: widget.animation,
builder: (_, child) => Transform.scale(scale: _scale, child: child),
child: widget.child,
),
);
}
}
RenderPhysicalShape? _surfaceIn(RenderObject node) {
if (node is RenderPhysicalShape) {
return node;
}
RenderPhysicalShape? surface;
node.visitChildren((child) => surface ??= _surfaceIn(child));
return surface;
}
Rect _surfaceRectIn(RenderBox box) {
final surface = _surfaceIn(box);
if (surface == null) {
return Offset.zero & box.size;
}
return MatrixUtils.transformRect(
surface.getTransformTo(box),
Offset.zero & surface.size,
);
}
/// An [AlertDialog] takes only a color, so the pane goes where its [Material]
/// lays out its [PhysicalShape].
class _AlertGlass extends SingleChildRenderObjectWidget {
const _AlertGlass({
required this.style,
required this.rim,
required this.shadows,
super.child,
});
final LiquidGlassStyle style;
final LiquidGlassRim rim;
final List<BoxShadow> shadows;
@override
_RenderAlertGlass createRenderObject(BuildContext context) {
return _RenderAlertGlass(style, rim, shadows);
}
@override
void updateRenderObject(
BuildContext context,
_RenderAlertGlass renderObject,
) {
renderObject
..style = style
..rim = rim
..shadows = shadows;
}
}
class _RenderAlertGlass extends RenderProxyBox {
_RenderAlertGlass(this._style, this._rim, this._shadows);
late final _pane = LiquidGlassPane(markNeedsPaint);
LiquidGlassStyle _style;
set style(LiquidGlassStyle value) {
if (value == _style) {
return;
}
_style = value;
markNeedsPaint();
}
LiquidGlassRim _rim;
set rim(LiquidGlassRim value) {
if (value == _rim) {
return;
}
_rim = value;
markNeedsPaint();
}
List<BoxShadow> _shadows;
set shadows(List<BoxShadow> value) {
if (listEquals(value, _shadows)) {
return;
}
_shadows = value;
markNeedsPaint();
}
@override
bool get alwaysNeedsCompositing => true;
@override
void dispose() {
_pane.dispose();
super.dispose();
}
@override
void paint(PaintingContext context, Offset offset) {
if (_surfaceIn(this) == null) {
_pane.clear();
} else {
final rect = _surfaceRectIn(this).shift(offset);
_pane.paint(
context,
rect,
shape: LiquidGlassCorners.circular(AppCorner.alert),
style: _style,
shadows: _shadows,
rim: _rim,
);
_rim.paintLine(context.canvas, rect, AppCorner.alert);
}
super.paint(context, offset);
}
}
class CommonDialog extends StatelessWidget {
final String title;
final Widget? trailing;
final Widget? child;
/// Text buttons, laid out as an iOS alert lays out its own: the last one
/// is the default action and is filled, and the first one, the cancel action
/// by convention, ends a stack.
final List<Widget>? actions;
final EdgeInsets? padding;
/// Sets the fields in [child] as an iOS alert does: as wide as its buttons.
final bool hasFields;
/// The last action destroys data: red on the plain fill, as in UIKit.
final bool destructive;
const CommonDialog({
super.key,
required this.title,
this.trailing,
this.actions,
this.child,
this.padding,
this.hasFields = false,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
final actions = this.actions ?? const <Widget>[];
final contentInset = hasFields ? _actionInset : _textInset;
final glass = LiquidGlassScope.of(context);
final colorScheme = context.colorScheme;
final fill = glass ? glassFillOf(colorScheme) : null;
final dialog = AlertDialog(
backgroundColor: glass ? Colors.transparent : null,
titleTextStyle: hasFields
? AppTextStyle.body.of(context)
: AppTextStyle.headline.of(context),
contentTextStyle: AppTextStyle.subheadline
.of(context)
.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: colorScheme.brightness == Brightness.dark ? 1 : 0.85,
),
),
title: _buildTitle(),
actions: actions.isEmpty
? null
: [
AlertActions(
actions: actions,
fill: fill,
destructive: destructive,
),
],
actionsPadding: const EdgeInsets.fromLTRB(
_actionInset,
0,
_actionInset,
_actionInset,
),
titlePadding: const EdgeInsets.fromLTRB(_textInset, 21, _textInset, 0),
contentPadding:
padding ??
EdgeInsets.fromLTRB(
contentInset,
hasFields ? 16 : 6,
contentInset,
20,
),
content: Container(
constraints: BoxConstraints(
maxWidth: padding == null
? _alertWidth - contentInset * 2
: _contentMaxWidth,
),
width: double.infinity,
child: SingleChildScrollView(
child: hasFields
? FilledInputTheme(
fillColor: fill,
borderRadius: AppRadius.all(_actionHeight / 2),
contentPadding: const EdgeInsets.symmetric(
horizontal: _textInset - _actionInset - _fieldInputGap,
vertical: 12,
),
child: child ?? const SizedBox.shrink(),
)
: child,
),
),
);
return ElasticPress(
pullPastEdges: true,
maxGrowth: _pressGrowth,
maxPull: _pressPull,
panelOf: _surfaceRectIn,
child: glass
? _AlertGlass(
style: LiquidGlassStyle.alert(colorScheme),
rim: LiquidGlassRim.alert(colorScheme),
shadows: glassMenuShadows(colorScheme),
child: dialog,
)
: dialog,
);
}
Widget _buildTitle() {
final trailing = this.trailing;
if (!hasFields) {
return trailing == null
? Text(title)
: Row(
children: [
Expanded(child: Text(title)),
trailing,
],
);
}
final text = Text(title, textAlign: TextAlign.center);
if (trailing == null) {
return SizedBox(width: double.infinity, child: text);
}
return _CenteredTitle(title: text, trailing: trailing);
}
}
/// A title centred on its whole width, beside a control at its end.
class _CenteredTitle extends MultiChildRenderObjectWidget {
_CenteredTitle({required Widget title, required Widget trailing})
: super(children: [title, trailing]);
@override
_RenderCenteredTitle createRenderObject(BuildContext context) {
return _RenderCenteredTitle(textDirection: Directionality.of(context));
}
@override
void updateRenderObject(
BuildContext context,
_RenderCenteredTitle renderObject,
) {
renderObject.textDirection = Directionality.of(context);
}
}
class _RenderCenteredTitle extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
_RenderCenteredTitle({required TextDirection textDirection})
: _textDirection = textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
if (value == _textDirection) {
return;
}
_textDirection = value;
markNeedsLayout();
}
RenderBox get _title => firstChild!;
RenderBox get _trailing => lastChild!;
@override
void setupParentData(RenderBox child) {
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
@override
double computeMinIntrinsicWidth(double height) =>
_title.getMinIntrinsicWidth(height) +
_trailing.getMinIntrinsicWidth(height) * 2;
@override
double computeMaxIntrinsicWidth(double height) =>
_title.getMaxIntrinsicWidth(height) +
_trailing.getMaxIntrinsicWidth(height) * 2;
@override
double computeMinIntrinsicHeight(double width) => math.max(
_title.getMinIntrinsicHeight(width),
_trailing.getMinIntrinsicHeight(width),
);
@override
double computeMaxIntrinsicHeight(double width) => math.max(
_title.getMaxIntrinsicHeight(width),
_trailing.getMaxIntrinsicHeight(width),
);
double _widthOf(BoxConstraints constraints, double side) =>
constraints.hasBoundedWidth
? constraints.maxWidth
: _title.getMaxIntrinsicWidth(double.infinity) + side * 2;
BoxConstraints _titleConstraints(double width, double side) =>
BoxConstraints.tightFor(width: math.max(0.0, width - side * 2));
@override
Size computeDryLayout(covariant BoxConstraints constraints) {
final trailing = _trailing.getDryLayout(constraints.loosen());
final width = _widthOf(constraints, trailing.width);
final title = _title.getDryLayout(_titleConstraints(width, trailing.width));
return constraints.constrain(
Size(width, math.max(title.height, trailing.height)),
);
}
@override
void performLayout() {
_trailing.layout(constraints.loosen(), parentUsesSize: true);
final side = _trailing.size.width;
final width = _widthOf(constraints, side);
_title.layout(_titleConstraints(width, side), parentUsesSize: true);
size = constraints.constrain(
Size(width, math.max(_title.size.height, _trailing.size.height)),
);
final rtl = _textDirection == TextDirection.rtl;
(_title.parentData! as MultiChildLayoutParentData).offset = Offset(
side,
(size.height - _title.size.height) / 2,
);
(_trailing.parentData! as MultiChildLayoutParentData).offset = Offset(
rtl ? 0 : size.width - side,
(size.height - _trailing.size.height) / 2,
);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
/// Text buttons laid out and filled as [CommonDialog] sets its own.
class AlertActions extends StatelessWidget {
const AlertActions({
super.key,
required this.actions,
this.fill,
this.destructive = false,
});
final List<Widget> actions;
/// The plain buttons' fill, in place of the raised surface's.
final Color? fill;
final bool destructive;
@override
Widget build(BuildContext context) {
return _AlertActions(
children: [
for (final (index, action) in actions.indexed)
_AlertAction(
isDefault: !destructive && index == actions.length - 1,
destructive: destructive && index == actions.length - 1,
fill: fill,
child: action,
),
],
);
}
}
class _AlertAction extends StatelessWidget {
const _AlertAction({
required this.isDefault,
required this.destructive,
required this.fill,
required this.child,
});
final bool isDefault;
final bool destructive;
final Color? fill;
final Widget child;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final fill = this.fill ?? context.raisedSurfaceLevel.colorOf(colorScheme);
final foreground = isDefault
? colorScheme.onPrimary
: destructive
? colorScheme.destructive
: colorScheme.onSurface;
final background = isDefault ? colorScheme.primary : fill;
final pressedShare = switch ((isDefault, colorScheme.brightness)) {
(true, Brightness.dark) => _pressedDarkDefaultShare,
(false, Brightness.dark) => 1.0,
_ => _pressedFillShare,
};
return TextButtonTheme(
data: TextButtonThemeData(
style:
TextButton.styleFrom(
foregroundColor: foreground,
disabledForegroundColor: colorScheme.onSurface.withValues(
alpha: 0.38,
),
textStyle: isDefault
? AppTextStyle.body.of(context, emphasized: true)
: AppTextStyle.body
.of(context)
.copyWith(fontWeight: FontWeight.w500),
minimumSize: const Size(0, _actionHeight),
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: AppShape.full,
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
splashFactory: NoSplash.splashFactory,
).copyWith(
backgroundColor: WidgetStateProperty<Color?>.fromMap({
WidgetState.disabled: fill,
WidgetState.pressed: background.withValues(
alpha: background.a * pressedShare,
),
WidgetState.any: background,
}),
overlayColor: WidgetStateProperty<Color?>.fromMap({
WidgetState.focused: foreground.withValues(alpha: 0.1),
WidgetState.pressed: Colors.transparent,
WidgetState.hovered: foreground.withValues(alpha: 0.08),
}),
side: focusRingStyle(colorScheme, standsOff: isDefault).side,
),
),
child: child,
);
}
}
/// Two actions side by side when both labels fit half the width; any other
/// count, or labels too long, stacked in order with the first one at the
/// bottom.
class _AlertActions extends MultiChildRenderObjectWidget {
const _AlertActions({required super.children});
@override
_RenderAlertActions createRenderObject(BuildContext context) {
return _RenderAlertActions(textDirection: Directionality.of(context));
}
@override
void updateRenderObject(
BuildContext context,
_RenderAlertActions renderObject,
) {
renderObject.textDirection = Directionality.of(context);
}
}
class _AlertActionsParentData extends ContainerBoxParentData<RenderBox> {}
class _RenderAlertActions extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _AlertActionsParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _AlertActionsParentData> {
_RenderAlertActions({required TextDirection textDirection})
: _textDirection = textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
if (value == _textDirection) {
return;
}
_textDirection = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _AlertActionsParentData) {
child.parentData = _AlertActionsParentData();
}
}
bool _sideBySide(List<RenderBox> children, double width) =>
children.length == 2 &&
children.every(
(child) =>
child.getMaxIntrinsicWidth(double.infinity) <=
(width - _actionGap) / 2,
);
double _childWidth(bool sideBySide, double width) =>
sideBySide ? (width - _actionGap) / 2 : width;
double _widest(double Function(RenderBox child) widthOf) =>
getChildrenAsList().map(widthOf).fold(0, math.max);
double _heightFor(
double width,
double Function(RenderBox child, double width) heightOf,
) {
final children = getChildrenAsList();
if (children.isEmpty) {
return 0;
}
final sideBySide = _sideBySide(children, width);
final heights = children.map(
(child) => heightOf(child, _childWidth(sideBySide, width)),
);
return sideBySide
? heights.reduce(math.max)
: heights.reduce((a, b) => a + b) + _actionGap * (children.length - 1);
}
@override
double computeMinIntrinsicWidth(double height) =>
_widest((child) => child.getMinIntrinsicWidth(height));
@override
double computeMaxIntrinsicWidth(double height) =>
_widest((child) => child.getMaxIntrinsicWidth(height));
@override
double computeMinIntrinsicHeight(double width) =>
_heightFor(width, (child, width) => child.getMinIntrinsicHeight(width));
@override
double computeMaxIntrinsicHeight(double width) =>
_heightFor(width, (child, width) => child.getMaxIntrinsicHeight(width));
@override
Size computeDryLayout(BoxConstraints constraints) =>
_layout(constraints, ChildLayoutHelper.dryLayoutChild);
@override
void performLayout() {
size = _layout(constraints, ChildLayoutHelper.layoutChild, place: true);
}
Size _layout(
BoxConstraints constraints,
ChildLayouter layoutChild, {
bool place = false,
}) {
final children = getChildrenAsList();
final width = constraints.hasBoundedWidth
? constraints.maxWidth
: _widest((child) => child.getMaxIntrinsicWidth(double.infinity));
final sideBySide = _sideBySide(children, width);
final childConstraints = BoxConstraints.tightFor(
width: _childWidth(sideBySide, width),
);
final sizes = [
for (final child in children) layoutChild(child, childConstraints),
];
final height = sizes.isEmpty
? 0.0
: sideBySide
? math.max(sizes.first.height, sizes.last.height)
: sizes.fold(0.0, (sum, size) => sum + size.height) +
_actionGap * (sizes.length - 1);
if (place) {
final rtl = _textDirection == TextDirection.rtl;
var y = 0.0;
for (var step = 1; step <= children.length; step++) {
final index = step % children.length;
final size = sizes[index];
final parentData =
children[index].parentData! as _AlertActionsParentData;
if (sideBySide) {
final atLeft = (index == 0) != rtl;
parentData.offset = Offset(
atLeft ? 0 : width - size.width,
(height - size.height) / 2,
);
} else {
parentData.offset = Offset(0, y);
y += size.height + _actionGap;
}
}
}
return constraints.constrain(Size(width, height));
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}