Files
2026-09-15 15:44:13 +07:00

119 lines
3.9 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
/// 「加入购物车」式飞行动画:把某个组件的副本从原位置缩小飞到目标位置。
///
/// 挂在 Overlay 上而不是弹窗内部——先测量、再关弹窗、最后飞,这样蒙层干净消失,
/// 飞行层浮在真实页面之上;放弹窗里做的话背景遮罩会一直压着,不像"收进去"。
class FlyToOverlay {
FlyToOverlay._();
/// [context] 调用方(弹窗)的 context,用来找根 Overlay —— 不能用 Get.overlayContext
/// 那拿到的是 Overlay 自身的 context,而 Overlay.of 只往祖先找,必然抛 "No Overlay widget found"
/// [sourceKey] 起飞组件(**必须在关闭弹窗前调用**,否则拿不到位置)
/// [target] 目标区域(屏幕坐标);[child] 飞行途中显示的内容,通常是起飞组件的副本
static void play({
required BuildContext context,
required GlobalKey sourceKey,
required Rect target,
required Widget child,
Duration duration = const Duration(milliseconds: 700),
}) {
final box = sourceKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return; // 量不到就不飞,不能因为动画报错
final begin = box.localToGlobal(Offset.zero) & box.size;
// rootOverlay:挂到最顶层,弹窗 pop 掉之后飞行层还得继续存活
final overlay = Overlay.maybeOf(context, rootOverlay: true);
if (overlay == null) return;
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _FlyView(
begin: begin,
end: target,
duration: duration,
// Overlay 被整体销毁时 entry 已不在树上,再 remove 会踩 assert
onDone: () {
if (entry.mounted) entry.remove();
},
child: child,
),
);
overlay.insert(entry);
}
}
class _FlyView extends StatefulWidget {
final Rect begin;
final Rect end;
final Duration duration;
final VoidCallback onDone;
final Widget child;
const _FlyView({
required this.begin,
required this.end,
required this.duration,
required this.onDone,
required this.child,
});
@override
State<_FlyView> createState() => _FlyViewState();
}
class _FlyViewState extends State<_FlyView> with SingleTickerProviderStateMixin {
late final AnimationController _ctr = AnimationController(vsync: this, duration: widget.duration);
// easeInOutCubic:起步慢、中段快、**末尾减速**。不用 easeIn 系是因为那样末尾越飞越快,
// 到落点时一闪而过,用户来不及把弹窗和浮窗对应起来
late final Animation<Rect?> _rect = RectTween(begin: widget.begin, end: widget.end)
.animate(CurvedAnimation(parent: _ctr, curve: Curves.easeInOutCubic));
@override
void initState() {
super.initState();
_ctr.addStatusListener((s) {
if (s == AnimationStatus.completed) widget.onDone();
});
_ctr.forward();
}
@override
void dispose() {
_ctr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctr,
builder: (_, child) {
final r = _rect.value ?? widget.begin;
// 全程基本保持不透明,只在最后 12% 收尾淡出:太早淡出会让人看不清落到哪儿了
final t = _ctr.value;
final opacity = t < 0.88 ? 1.0 : (1 - (t - 0.88) / 0.12).clamp(0.0, 1.0);
return Positioned(
left: r.left,
top: r.top,
width: r.width,
height: r.height,
child: IgnorePointer(
child: Opacity(opacity: opacity, child: child),
),
);
},
// 内容按起飞尺寸渲染一次,交给 FittedBox 等比缩放,避免每帧重新布局图片
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox(
width: widget.begin.width,
height: widget.begin.height,
child: widget.child,
),
),
);
}
}