119 lines
3.9 KiB
Dart
119 lines
3.9 KiB
Dart
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|