Files
FlClash/lib/widgets/popup.dart
T
chen08209 8c43053951 feat(ui): stack bottom sheets and add context menus
Changelog: Stacked sheets, context menus and a sort mode
2026-10-12 10:17:54 +08:00

1389 lines
37 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' as ui show Image;
import 'dart:ui' show ImageFilter, lerpDouble;
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:flutter/foundation.dart' show ValueListenable, listEquals;
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
typedef PopupAnchorResolver = Rect? Function();
typedef PopupOpen = void Function({Offset offset});
enum PopupPlacement { overAnchorEnd, belowPoint }
const _screenMargin = 16.0;
const _anchorOverlap = 8.0;
const _avoidGap = 8.0;
const _cardInset = 8.0;
const _itemRadius = AppCorner.md;
const _cardRadius = _itemRadius + _cardInset;
const _itemIconSize = 20.0;
const _submenuArrowSize = 16.0;
const _itemPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 12);
const _itemArrowPadding = EdgeInsets.only(
left: 12,
top: 12,
bottom: 12,
right: 8,
);
const _dividerHeight = _cardInset * 2 + 1;
// Soft and wide like HyperOS menus rather than a Material elevation.
List<BoxShadow> _cardShadows(ColorScheme colorScheme, [double depth = 1]) {
final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0;
final alpha = strength * depth;
return [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.05 * alpha),
blurRadius: 10,
offset: const Offset(0, 2),
),
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.04 * alpha),
blurRadius: 30,
offset: const Offset(0, 4),
),
];
}
const _maxContentBlur = 8.0;
const _openDuration = Duration(milliseconds: 750);
const _closeDuration = Duration(milliseconds: 400);
const _unfoldDuration = Duration(milliseconds: 480);
const _frameSpan = 0.3;
double _secondsOf(Duration duration) =>
duration.inMicroseconds / Duration.microsecondsPerSecond;
final _openSeconds = _secondsOf(_openDuration);
final _closeSeconds = _secondsOf(_closeDuration);
final _growSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 500),
bounce: 0.3,
),
seconds: _openSeconds,
);
final _moveYSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 340),
bounce: 0.12,
),
seconds: _openSeconds,
);
final _moveXSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 520),
bounce: 0.2,
),
seconds: _openSeconds,
);
final _shrinkSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 340),
),
seconds: _closeSeconds,
).flipped;
final _returnXSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 300),
),
seconds: _closeSeconds,
).flipped;
final _returnYSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 440),
),
seconds: _closeSeconds,
).flipped;
final _unfoldSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 420),
bounce: 0.2,
),
seconds: _secondsOf(_unfoldDuration),
);
final _frameSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 150),
),
seconds: _secondsOf(_unfoldDuration) * _frameSpan,
);
class CommonPopupRoute<T> extends PopupRoute<T> {
CommonPopupRoute({
required this.builder,
required this.anchorOf,
required this.barrierLabel,
this.placement = PopupPlacement.overAnchorEnd,
this.anchorShift = Offset.zero,
this.sourceColor = Colors.transparent,
this.sourceImage,
this.modal = true,
this.avoid,
}) : super(requestFocus: modal ? null : false);
final WidgetBuilder builder;
/// Resolves the rect of the control the popup grows out of.
final PopupAnchorResolver anchorOf;
final PopupPlacement placement;
/// Moves where the popup is placed without moving where it grows from.
final Offset anchorShift;
/// The background of the source control, which the platter starts from.
final Color sourceColor;
/// A snapshot of the source control, drawn inside the platter while the
/// control itself is hidden; null while it is still being taken.
final ValueListenable<ui.Image?>? sourceImage;
/// Off, focus stays on the page and touches outside pass through to it.
final bool modal;
/// A rect the popup keeps clear of when there is room above or below it.
final Rect? avoid;
@override
final String? barrierLabel;
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
Duration get transitionDuration => _openDuration;
@override
Duration get reverseTransitionDuration => _closeDuration;
late final CurvedAnimation _grow = CurvedAnimation(
parent: animation!,
curve: _growSpring,
reverseCurve: _shrinkSpring,
);
late final CurvedAnimation _moveX = CurvedAnimation(
parent: animation!,
curve: _moveXSpring,
reverseCurve: _returnXSpring,
);
late final CurvedAnimation _moveY = CurvedAnimation(
parent: animation!,
curve: _moveYSpring,
reverseCurve: _returnYSpring,
);
late final CurvedAnimation _content = CurvedAnimation(
parent: animation!,
curve: const Interval(0.04, 0.45, curve: Curves.easeOutCubic),
reverseCurve: const Interval(0.5, 1, curve: Curves.easeIn),
);
final _unfolded = ValueNotifier<bool>(false);
final _pin = _PopupPin();
@override
void dispose() {
_grow.dispose();
_moveX.dispose();
_moveY.dispose();
_content.dispose();
_unfolded.dispose();
super.dispose();
}
void _handleDismiss() {
if (isCurrent) {
navigator?.pop();
}
}
@override
Widget buildModalBarrier() {
return modal ? super.buildModalBarrier() : const SizedBox.shrink();
}
Widget _buildDismissLayer() {
if (modal) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: _handleDismiss,
);
}
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => _handleDismiss(),
onPointerPanZoomStart: (_) => _handleDismiss(),
onPointerSignal: (_) => _handleDismiss(),
);
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return builder(context);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final colorScheme = context.colorScheme;
return Stack(
children: [
Positioned.fill(child: _buildDismissLayer()),
_PopupAnchorTracker(
anchorOf: anchorOf,
builder: (anchor, safeInsets, child) => CustomSingleChildLayout(
delegate: _PopupLayoutDelegate(
anchor: anchor.shift(anchorShift),
safeInsets: safeInsets,
placement: placement,
avoid: avoid,
pin: _pin,
),
child: _PopupMorph(
anchor: anchor,
animation: animation,
grow: _grow,
moveX: _moveX,
moveY: _moveY,
content: _content,
unfolded: _unfolded,
sourceColor: sourceColor,
sourceImage: sourceImage,
surfaceColor: colorScheme.surfaceContainer,
shadows: _cardShadows(colorScheme),
child: child,
),
),
child: NotificationListener<_PopupUnfoldNotification>(
onNotification: (_) {
_unfolded.value = true;
return true;
},
child: child,
),
),
],
);
}
}
class _PopupUnfoldNotification extends Notification {
const _PopupUnfoldNotification();
}
class _PopupMorph extends SingleChildRenderObjectWidget {
const _PopupMorph({
required this.anchor,
required this.animation,
required this.grow,
required this.moveX,
required this.moveY,
required this.content,
required this.unfolded,
required this.sourceColor,
required this.sourceImage,
required this.surfaceColor,
required this.shadows,
super.child,
});
final Rect anchor;
final Animation<double> animation;
final Animation<double> grow;
final Animation<double> moveX;
final Animation<double> moveY;
final Animation<double> content;
final ValueListenable<bool> unfolded;
final Color sourceColor;
final ValueListenable<ui.Image?>? sourceImage;
final Color surfaceColor;
final List<BoxShadow> shadows;
@override
_RenderPopupMorph createRenderObject(BuildContext context) {
return _RenderPopupMorph(
anchor: anchor,
animation: animation,
grow: grow,
moveX: moveX,
moveY: moveY,
content: content,
unfolded: unfolded,
sourceColor: sourceColor,
sourceImage: sourceImage,
surfaceColor: surfaceColor,
shadows: shadows,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderPopupMorph renderObject,
) {
renderObject
..anchor = anchor
..animation = animation
..grow = grow
..moveX = moveX
..moveY = moveY
..content = content
..unfolded = unfolded
..sourceColor = sourceColor
..sourceImage = sourceImage
..surfaceColor = surfaceColor
..shadows = shadows;
}
}
/// Grows the popup out of its anchor the way iOS 26 menus leave their button:
/// a platter flies from the anchor's capsule to the card's center on an arc,
/// growing on its own spring, while the content rides inside it, scaling,
/// fading and sharpening in; closing shrinks it first and flies it back.
class _RenderPopupMorph extends RenderProxyBox {
_RenderPopupMorph({
required Rect anchor,
required Animation<double> animation,
required this.grow,
required this.moveX,
required this.moveY,
required this.content,
required ValueListenable<bool> unfolded,
required Color sourceColor,
required ValueListenable<ui.Image?>? sourceImage,
required Color surfaceColor,
required List<BoxShadow> shadows,
}) : _anchor = anchor,
_animation = animation,
_unfolded = unfolded,
_sourceColor = sourceColor,
_sourceImage = sourceImage,
_surfaceColor = surfaceColor,
_shadows = shadows;
Animation<double> grow;
Animation<double> moveX;
Animation<double> moveY;
Animation<double> content;
Rect _anchor;
set anchor(Rect value) {
if (value == _anchor) {
return;
}
_anchor = value;
markNeedsPaint();
}
Animation<double> _animation;
set animation(Animation<double> value) {
if (value == _animation) {
return;
}
if (attached) {
_animation.removeListener(markNeedsPaint);
value.addListener(markNeedsPaint);
}
_animation = value;
markNeedsPaint();
}
ValueListenable<bool> _unfolded;
set unfolded(ValueListenable<bool> value) {
if (value == _unfolded) {
return;
}
if (attached) {
_unfolded.removeListener(markNeedsPaint);
value.addListener(markNeedsPaint);
}
_unfolded = value;
markNeedsPaint();
}
Color _sourceColor;
set sourceColor(Color value) {
if (value == _sourceColor) {
return;
}
_sourceColor = value;
markNeedsPaint();
}
ValueListenable<ui.Image?>? _sourceImage;
set sourceImage(ValueListenable<ui.Image?>? value) {
if (value == _sourceImage) {
return;
}
if (attached) {
_sourceImage?.removeListener(markNeedsPaint);
value?.addListener(markNeedsPaint);
}
_sourceImage = value;
markNeedsPaint();
}
Color _surfaceColor;
set surfaceColor(Color value) {
if (value == _surfaceColor) {
return;
}
_surfaceColor = value;
markNeedsPaint();
}
List<BoxShadow> _shadows;
set shadows(List<BoxShadow> value) {
if (listEquals(value, _shadows)) {
return;
}
_shadows = value;
markNeedsPaint();
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_animation.addListener(markNeedsPaint);
_unfolded.addListener(markNeedsPaint);
_sourceImage?.addListener(markNeedsPaint);
}
@override
void detach() {
_animation.removeListener(markNeedsPaint);
_unfolded.removeListener(markNeedsPaint);
_sourceImage?.removeListener(markNeedsPaint);
super.detach();
}
@override
bool get alwaysNeedsCompositing => child != null;
bool get _closing => _animation.status == AnimationStatus.reverse;
@override
void paint(PaintingContext context, Offset offset) {
final child = this.child;
if (child == null) {
return;
}
if (_animation.isCompleted) {
context.paintChild(child, offset);
return;
}
// The anchor comes in the coordinates of the layout box that places this
// one, so the placement offset turns it local.
final placement = (parentData! as BoxParentData).offset;
final from = _anchor.shift(-placement);
final bounds = Offset.zero & size;
final progress = grow.value;
final extent = Size.lerp(from.size, bounds.size, progress)!;
final rect = Rect.fromCenter(
center: Offset(
lerpDouble(from.center.dx, bounds.center.dx, moveX.value)!,
lerpDouble(from.center.dy, bounds.center.dy, moveY.value)!,
),
width: math.max(0.0, extent.width),
height: math.max(0.0, extent.height),
);
final radius = lerpDouble(from.shortestSide / 2, _cardRadius, progress)!;
final clip = AppShape.all(math.max(0.0, radius)).getOuterPath(rect);
final sourceImage = _sourceImage;
final image = sourceImage?.value;
// The source control stays visible until its snapshot exists, so the
// platter would only cover it.
if (sourceImage != null && image == null && !_closing) {
return;
}
final reveal = content.value;
// The card paints the same surface and shadow as the platter, so once it
// is revealed, or a sub menu leaves the root's bounds, the platter steps
// aside instead of clipping the levels until the spring settles.
if ((reveal >= 1 || _unfolded.value) && !_closing && !size.isEmpty) {
context.pushTransform(
needsCompositing,
offset,
_contentTransform(rect),
(context, offset) => context.paintChild(child, offset),
);
return;
}
final closing = _closing;
// Closing levels keep their own shadows as they fade into the platter.
final platter = closing ? 1 - reveal : 1.0;
final settled = progress.clamp(0.0, 1.0);
final path = clip.shift(offset);
final canvas = context.canvas;
final depth = settled * platter;
final reach = _shadows.fold(
0.0,
(reach, shadow) =>
math.max(reach, shadow.blurSigma * 3 + shadow.offset.distance),
);
// The blur shades under the platter too; keep that out of fading content.
canvas
..save()
..clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(path.getBounds().inflate(reach))
..addPath(path, Offset.zero),
);
for (final shadow in _shadows) {
canvas.drawPath(
path.shift(shadow.offset),
shadow.toPaint()
..color = shadow.color.withValues(alpha: shadow.color.a * depth),
);
}
canvas.restore();
final fill = Color.lerp(_sourceColor, _surfaceColor, settled)!;
canvas.drawPath(
path,
Paint()..color = fill.withValues(alpha: fill.a * platter),
);
final glyph = 1 - reveal;
if (image != null && glyph > 0) {
final source = from.shift(rect.center - from.center + offset);
canvas.drawImageRect(
image,
Offset.zero & Size(image.width.toDouble(), image.height.toDouble()),
source,
Paint()
..color = Color.fromRGBO(0, 0, 0, glyph)
..filterQuality = FilterQuality.medium,
);
}
if (reveal <= 0 || size.isEmpty) {
return;
}
final transform = _contentTransform(rect, fit: closing);
final sigma = _maxContentBlur * (1 - reveal);
void paintContent(PaintingContext context, Offset offset) =>
context.pushOpacity(
offset,
Color.getAlphaFromOpacity(reveal),
(context, offset) => context.pushLayer(
ImageFilterLayer(
imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
),
(context, offset) => context.pushTransform(
needsCompositing,
offset,
transform,
(context, offset) => context.paintChild(child, offset),
),
offset,
),
);
if (closing) {
paintContent(context, offset);
return;
}
context.pushClipPath(needsCompositing, offset, bounds, clip, paintContent);
}
Matrix4 _contentTransform(Rect rect, {bool fit = false}) {
final scale = (fit ? math.min : math.max)(
rect.width / size.width,
rect.height / size.height,
);
return Matrix4.translationValues(rect.center.dx, rect.center.dy, 0)
..scaleByDouble(scale, scale, 1, 1)
..translateByDouble(-size.width / 2, -size.height / 2, 0, 1);
}
}
class _PopupAnchorTracker extends StatefulWidget {
const _PopupAnchorTracker({
required this.anchorOf,
required this.builder,
required this.child,
});
final PopupAnchorResolver anchorOf;
final Widget Function(Rect anchor, EdgeInsets safeInsets, Widget child)
builder;
final Widget child;
@override
State<_PopupAnchorTracker> createState() => _PopupAnchorTrackerState();
}
class _PopupAnchorTrackerState extends State<_PopupAnchorTracker> {
Rect? _anchor;
bool _syncScheduled = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scheduleSync();
}
void _scheduleSync() {
if (_syncScheduled) {
return;
}
_syncScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_syncScheduled = false;
if (!mounted) {
return;
}
final anchor = widget.anchorOf();
if (anchor == null || anchor == _anchor) {
return;
}
setState(() {
_anchor = anchor;
});
});
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final anchor = _anchor ??= widget.anchorOf() ?? Rect.zero;
// padding already gives up the part of the bottom inset the keyboard
// covers, so the sum keeps clear of the taller of the two.
return widget.builder(
anchor,
mediaQuery.padding + mediaQuery.viewInsets,
widget.child,
);
}
}
class _PopupLayoutDelegate extends SingleChildLayoutDelegate {
const _PopupLayoutDelegate({
required this.anchor,
required this.safeInsets,
required this.placement,
required this.pin,
this.avoid,
});
final Rect anchor;
final EdgeInsets safeInsets;
final PopupPlacement placement;
final Rect? avoid;
final _PopupPin pin;
EdgeInsets get _insets => safeInsets + const EdgeInsets.all(_screenMargin);
@override
Size getSize(BoxConstraints constraints) => constraints.biggest;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final insets = _insets;
return BoxConstraints.loose(
Size(
math.max(0.0, constraints.maxWidth - insets.horizontal),
math.max(0.0, constraints.maxHeight - insets.vertical),
),
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final insets = _insets;
final maxX = size.width - insets.right - childSize.width;
final maxY = size.height - insets.bottom - childSize.height;
final pinned = pin.topRightFor(this, size);
final (x, y) = pinned != null
? (pinned.dx - childSize.width, pinned.dy)
: switch (placement) {
PopupPlacement.overAnchorEnd => (
anchor.right - childSize.width,
anchor.top - _anchorOverlap,
),
PopupPlacement.belowPoint => (
anchor.left,
_yBelowPoint(childSize.height, insets.top, maxY),
),
};
final position = Offset(
x.clamp(insets.left, math.max(insets.left, maxX)),
y.clamp(insets.top, math.max(insets.top, maxY)),
);
if (pinned == null) {
pin.hold(this, size, position.translate(childSize.width, 0));
}
return position;
}
double _yBelowPoint(double height, double minY, double maxY) {
final avoid = this.avoid;
if (avoid != null) {
final above = avoid.top - _avoidGap - height;
if (above >= minY) {
return above;
}
final below = avoid.bottom + _avoidGap;
if (below <= maxY) {
return below;
}
}
if (anchor.bottom > maxY && anchor.top - height >= minY) {
return anchor.top - height;
}
return anchor.bottom;
}
bool _placesLike(_PopupLayoutDelegate other) {
return other.anchor == anchor &&
other.safeInsets == safeInsets &&
other.placement == placement &&
other.avoid == avoid;
}
@override
bool shouldRelayout(_PopupLayoutDelegate oldDelegate) {
return !_placesLike(oldDelegate) || oldDelegate.pin != pin;
}
}
/// Keeps the top right corner the menu levels stack from while a sub menu
/// unfolds, so the card neither slides nor flips around what it avoids.
class _PopupPin {
_PopupLayoutDelegate? _placement;
Size _size = Size.zero;
Offset _topRight = Offset.zero;
Offset? topRightFor(_PopupLayoutDelegate placement, Size size) {
final held = _placement;
if (held == null || size != _size || !held._placesLike(placement)) {
return null;
}
return _topRight;
}
void hold(_PopupLayoutDelegate placement, Size size, Offset topRight) {
_placement = placement;
_size = size;
_topRight = topRight;
}
}
class CommonPopupBox extends StatefulWidget {
const CommonPopupBox({
super.key,
required this.targetBuilder,
required this.popupBuilder,
});
final Widget Function(PopupOpen open) targetBuilder;
final WidgetBuilder popupBuilder;
@override
State<CommonPopupBox> createState() => _CommonPopupBoxState();
}
class _CommonPopupBoxState extends State<CommonPopupBox> {
final _sourceKey = GlobalKey();
final _sourceHidden = ValueNotifier<bool>(false);
@override
void dispose() {
_sourceHidden.dispose();
super.dispose();
}
Rect? _sourceRect() {
if (!mounted) {
return null;
}
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.attached || !renderBox.hasSize) {
return null;
}
final navigatorBox =
Navigator.maybeOf(context)?.context.findRenderObject() as RenderBox?;
final origin = renderBox.localToGlobal(Offset.zero, ancestor: navigatorBox);
return origin & renderBox.size;
}
Color _sourceColor() {
Color? color;
void visit(Element element) {
if (color != null) {
return;
}
if (element.widget case final Material material) {
color = material.type == MaterialType.transparency
? Colors.transparent
: material.color ?? Colors.transparent;
return;
}
element.visitChildren(visit);
}
context.visitChildElements(visit);
final resolved = color;
if (resolved == null || resolved.a == 0) {
return context.colorScheme.surfaceContainer;
}
return resolved;
}
void _captureSource(
CommonPopupRoute<void> route,
ValueNotifier<ui.Image?> snapshot,
) {
if (!mounted || !route.isActive) {
return;
}
final boundary = _sourceKey.currentContext?.findRenderObject();
if (boundary is! RenderRepaintBoundary || !boundary.hasSize) {
return;
}
snapshot.value = boundary.toImageSync(
pixelRatio: MediaQuery.devicePixelRatioOf(context),
);
_sourceHidden.value = true;
}
void _open({Offset offset = Offset.zero}) {
final snapshot = ValueNotifier<ui.Image?>(null);
final route = CommonPopupRoute<void>(
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
builder: (context) => widget.popupBuilder(context),
anchorOf: _sourceRect,
anchorShift: offset,
sourceColor: _sourceColor(),
sourceImage: snapshot,
);
Navigator.of(context).push(route);
void reveal() {
if (mounted) {
_sourceHidden.value = false;
}
}
route.animation!.addStatusListener((status) {
if (status.isDismissed) {
reveal();
}
});
route.completed.whenComplete(() {
reveal();
snapshot.value?.dispose();
snapshot.dispose();
});
// The control repaints its ink as it is tapped, so it can only be
// captured once that frame has been painted.
WidgetsBinding.instance.addPostFrameCallback(
(_) => _captureSource(route, snapshot),
);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: _sourceHidden,
builder: (_, hidden, child) =>
Opacity(opacity: hidden ? 0 : 1, child: child),
child: RepaintBoundary(
key: _sourceKey,
child: widget.targetBuilder(_open),
),
);
}
}
class CommonPopupMenuItem {
const CommonPopupMenuItem({
required this.label,
this.glyph,
this.onPressed,
this.danger = false,
this.checked,
this.subItems = const [],
});
final String label;
final Glyph? glyph;
final VoidCallback? onPressed;
final bool danger;
final bool? checked;
final List<CommonPopupMenuItem> subItems;
}
class CommonPopupMenu extends StatefulWidget {
const CommonPopupMenu({
super.key,
required this.items,
this.minWidth = 160,
this.maxWidth = 280,
});
final List<CommonPopupMenuItem> items;
final double minWidth;
final double maxWidth;
@override
State<CommonPopupMenu> createState() => _CommonPopupMenuState();
}
class _MenuStep {
const _MenuStep({
required this.index,
required this.itemTop,
required this.cardWidth,
});
final int index;
final double itemTop;
final double cardWidth;
}
class _MenuLevel {
const _MenuLevel({
required this.items,
required this.top,
required this.fromWidth,
required this.minWidth,
required this.maxWidth,
this.owner,
});
final List<CommonPopupMenuItem> items;
final CommonPopupMenuItem? owner;
final double top;
final double fromWidth;
final double minWidth;
final double maxWidth;
}
class _CommonPopupMenuState extends State<CommonPopupMenu>
with SingleTickerProviderStateMixin {
static const _levelWidthScale = 1.12;
static const _levelScaleStep = 0.05;
static const _levelFadeStep = 0.2;
static const _levelShadowStep = 1 / 3;
static const _minShadow = 1 / 6;
static final _arrowTween = Tween(begin: 0.0, end: 0.25);
late final AnimationController _controller = AnimationController(
vsync: this,
duration: _unfoldDuration,
reverseDuration: _closeDuration,
value: 1,
);
late final CurvedAnimation _frame = CurvedAnimation(
parent: _controller,
curve: Interval(0, _frameSpan, curve: _frameSpring),
reverseCurve: _returnXSpring,
);
late final CurvedAnimation _reveal = CurvedAnimation(
parent: _controller,
curve: const Interval(0, 0.6, curve: Curves.easeOutCubic),
reverseCurve: const Interval(0.5, 1, curve: Curves.easeIn),
);
late final CurvedAnimation _unfold = CurvedAnimation(
parent: _controller,
curve: _unfoldSpring,
reverseCurve: _shrinkSpring,
);
final List<_MenuStep> _path = [];
bool _folding = false;
@override
void initState() {
super.initState();
_controller.addStatusListener(_handleStatusChanged);
}
@override
void dispose() {
_frame.dispose();
_reveal.dispose();
_unfold.dispose();
_controller.dispose();
super.dispose();
}
void _handleStatusChanged(AnimationStatus status) {
if (status != AnimationStatus.dismissed || !_folding || _path.isEmpty) {
return;
}
_folding = false;
setState(() {
_path.removeLast();
_controller.value = 1;
});
}
List<_MenuLevel> _resolveLevels() {
final rootWidth = math.min(widget.minWidth, widget.maxWidth);
final levels = [
_MenuLevel(
items: widget.items,
top: 0,
fromWidth: rootWidth,
minWidth: rootWidth,
maxWidth: widget.maxWidth,
),
];
for (final step in _path) {
final parent = levels.last;
if (step.index >= parent.items.length) {
break;
}
final item = parent.items[step.index];
if (item.subItems.isEmpty) {
break;
}
final fromWidth = math.max(step.cardWidth, parent.minWidth);
final minWidth = fromWidth * _levelWidthScale;
levels.add(
_MenuLevel(
items: item.subItems,
owner: item,
top: math.max(0, step.itemTop - _cardInset),
fromWidth: fromWidth,
minWidth: minWidth,
maxWidth: math.max(minWidth, widget.maxWidth),
),
);
}
return levels;
}
void _push(BuildContext itemContext, int index) {
final itemBox = itemContext.findRenderObject() as RenderBox?;
final stackBox = context.findRenderObject() as RenderBox?;
final placed =
itemBox != null &&
stackBox != null &&
itemBox.hasSize &&
stackBox.hasSize;
_folding = false;
const _PopupUnfoldNotification().dispatch(context);
setState(() {
_path.add(
_MenuStep(
index: index,
itemTop: placed
? itemBox.localToGlobal(Offset.zero, ancestor: stackBox).dy
: 0,
cardWidth: placed
? itemBox.size.width + 2 * _cardInset
: widget.minWidth,
),
);
});
_controller.forward(from: 0);
}
void _pop() {
if (_path.isEmpty || _folding) {
return;
}
_folding = true;
_controller.reverse();
}
void _select(VoidCallback onPressed) {
Navigator.of(context).pop();
onPressed();
}
Widget _buildRow(
BuildContext context, {
required CommonPopupMenuItem item,
required VoidCallback? onTap,
Animation<double>? arrowTurns,
}) {
final colorScheme = context.colorScheme;
final enabled = onTap != null;
final color = item.danger ? colorScheme.error : colorScheme.onSurface;
final foregroundColor = enabled ? color : color.opacity30;
Widget? arrow;
if (item.subItems.isNotEmpty) {
arrow = GlyphIcon(
AppGlyphs.chevronForward,
size: _submenuArrowSize,
color: foregroundColor,
);
if (arrowTurns != null) {
arrow = RotationTransition(turns: arrowTurns, child: arrow);
}
} else if (item.checked == true) {
arrow = GlyphIcon(
AppGlyphs.check,
size: _submenuArrowSize,
color: foregroundColor,
);
}
final child = InkWell(
customBorder: const RoundedSuperellipseBorder(
borderRadius: BorderRadius.all(Radius.circular(_itemRadius)),
),
onTap: onTap,
splashColor: Colors.transparent,
hoverColor: item.danger ? colorScheme.error.opacity10 : null,
child: Padding(
padding: arrow != null ? _itemArrowPadding : _itemPadding,
child: Row(
children: [
if (item.glyph case final glyph?) ...[
GlyphIcon(glyph, size: _itemIconSize, color: foregroundColor),
const SizedBox(width: 12),
],
Expanded(
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: foregroundColor,
),
),
),
if (arrow != null) ...[const SizedBox(width: 8), arrow],
],
),
),
);
return Semantics(
button: true,
enabled: enabled,
checked: item.checked,
child: child,
);
}
Widget _buildItem(BuildContext context, CommonPopupMenuItem item, int index) {
if (item.subItems.isNotEmpty) {
return Builder(
builder: (itemContext) => _buildRow(
itemContext,
item: item,
onTap: () => _push(itemContext, index),
),
);
}
final onPressed = item.onPressed;
return _buildRow(
context,
item: item,
onTap: onPressed == null ? null : () => _select(onPressed),
);
}
Widget _buildContent(
BuildContext context,
_MenuLevel level, {
required bool active,
}) {
final items = [
for (var index = 0; index < level.items.length; index++)
_buildItem(context, level.items[index], index),
];
final owner = level.owner;
if (owner == null) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: items,
);
}
final unfold = active ? _unfold : kAlwaysCompleteAnimation;
final reveal = active ? _reveal : kAlwaysCompleteAnimation;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildRow(
context,
item: owner,
onTap: _pop,
arrowTurns: unfold.drive(_arrowTween),
),
SizeTransition(
sizeFactor: unfold,
alignment: Alignment.topCenter,
child: FadeTransition(
opacity: reveal,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(height: _dividerHeight, thickness: 1),
...items,
],
),
),
),
],
);
}
Widget _buildLevel(
BuildContext context,
_MenuLevel level, {
required int depth,
required double pivot,
}) {
final receding = depth > 0;
final colorScheme = context.colorScheme;
final shape = AppShape.all(_cardRadius);
return IgnorePointer(
ignoring: receding,
child: ExcludeSemantics(
excluding: receding,
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _frame,
builder: (context, child) {
final frame = receding ? 1.0 : _frame.value;
final distance = receding ? depth - 1 + _frame.value : 0.0;
final scale = math.max(0.0, 1 - _levelScaleStep * distance);
final shadow = receding
? math.max(_minShadow, 1 - distance * _levelShadowStep)
: frame;
return Transform(
transform: Matrix4.diagonal3Values(scale, scale, 1),
alignment: Alignment.topRight,
origin: Offset(0, pivot),
child: DecoratedBox(
position: DecorationPosition.foreground,
decoration: ShapeDecoration(
color: colorScheme.surface.withValues(
alpha: math.min(0.4, _levelFadeStep * distance),
),
shape: shape,
),
child: DecoratedBox(
decoration: ShapeDecoration(
shape: shape,
shadows: _cardShadows(colorScheme, shadow),
),
child: Material(
type: MaterialType.card,
animationDuration: Duration.zero,
color: colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
shape: shape,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: lerpDouble(
level.fromWidth,
level.minWidth,
frame,
)!,
maxWidth: lerpDouble(
level.fromWidth,
level.maxWidth,
frame,
)!,
),
child: child,
),
),
),
),
);
},
child: Padding(
padding: const EdgeInsets.all(_cardInset),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: IntrinsicWidth(
child: _buildContent(context, level, active: !receding),
),
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final levels = _resolveLevels();
final topIndex = levels.length - 1;
return PopScope(
canPop: topIndex == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
_pop();
}
},
child: Stack(
alignment: Alignment.topRight,
children: [
for (var index = 0; index <= topIndex; index++)
Padding(
key: ValueKey(index),
padding: EdgeInsets.only(top: levels[index].top),
child: _buildLevel(
context,
levels[index],
depth: topIndex - index,
pivot: index < topIndex
? _path[index].itemTop - levels[index].top
: 0,
),
),
],
),
);
}
}