Skip to content

Flutter基于PopupRoute的弹窗实现

· 9 min

不走“寻常路”的优雅弹窗#

有时候产品不想用 showDialogshowModalBottomSheet 那些“现成套餐”。想要任意位置弹、要自定义动画、要蒙层颜色可调、要点空白处就消失、还得和页面栈融为一体。这时,PopupRoute 就是“手巧活儿”的首选。

这篇就带你从底层把弹窗这件事梳一遍:PopupRoute 是个啥、关键覆写点在哪、如何做居中弹/跟手弹/自定义位置弹,再给你一套可立即抄走的封装。


PopupRoute 是谁?#

PopupRoute<T> 继承 ModalRoute<T>,属于“路由级弹层”。它天然支持:

一句话:自己造个“弹窗路由”,你就能完全掌控弹窗的生命线和长相。


最小可用版:先把弹窗架起来#

下面这个是一个实用的 PopupRoute 封装:支持蒙层、可点空白关闭、自定义动画(淡入+轻缩放),可以直接丢进项目用。

import 'package:flutter/material.dart';
class WDPopupRoute<T> extends PopupRoute<T> {
WDPopupRoute({
required this.child,
this.duration = const Duration(milliseconds: 300),
this.bgColor,
this.dismissible = true,
this.curve = Curves.easeOutCubic,
this.reverseCurve = Curves.easeInCubic,
this.barrierText = '关闭弹窗',
});
final Widget child;
final Duration duration;
final Color? bgColor;
final bool dismissible;
final Curve curve;
final Curve reverseCurve;
final String barrierText;
@override
Color? get barrierColor => bgColor ?? Colors.black.withOpacity(0.35);
@override
bool get barrierDismissible => dismissible;
@override
String? get barrierLabel => barrierText;
@override
Duration get transitionDuration => duration;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return child;
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final curved = CurvedAnimation(
parent: animation,
curve: curve,
reverseCurve: reverseCurve,
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: 0.95, end: 1).animate(curved),
child: child,
),
);
}
}

用法很直白:

Navigator.of(context).push(
WDPopupRoute(
child: Center(
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('你好,我是一个弹窗'),
),
),
),
),
);

要点小抄:


跟手、锚点、居中:都得安排上#

很多时候我们需要“点哪从哪儿弹”。思路是:点下去记录全局坐标,把内容放进一个全屏 Stack 里,再用 Positioned 把弹窗定位到目标点。下面是一个整理后的“点击弹出”小控件,内置三种弹出方式:

import 'package:flutter/material.dart';
enum WDCustomPopTypes {
screen_center,
child_lefttop,
child_tapPosition,
}
class WDCustomPop extends StatelessWidget {
const WDCustomPop({
super.key,
required this.child,
required this.contentChild,
this.popType = WDCustomPopTypes.child_lefttop,
this.offset = Offset.zero,
this.customOffset,
this.onTapContent,
this.isTapClose = true,
this.bgColor,
this.duration = const Duration(milliseconds: 300),
});
final Widget child; // 被点击的“触发器”
final Widget contentChild; // 真正显示的弹出内容
final WDCustomPopTypes popType;
final Offset offset; // 在基础锚点上的微调
final Offset? customOffset; // 完全自定义位置(直接 left/top)
final VoidCallback? onTapContent;
final bool isTapClose; // 点击蒙层是否关闭
final Color? bgColor;
final Duration duration;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (details) {
final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox;
final box = context.findRenderObject() as RenderBox;
// 点击点的全局坐标 -> 转为 overlay 局部坐标
final tapInOverlay = overlay.globalToLocal(details.globalPosition);
// 触发器左上角的全局坐标 -> 转为 overlay 局部坐标
final childTopLeft = overlay.globalToLocal(box.localToGlobal(Offset.zero));
Offset anchor;
if (customOffset != null) {
anchor = customOffset!;
} else {
switch (popType) {
case WDCustomPopTypes.screen_center:
// 用不到 anchor,下面直接 Center
anchor = Offset.zero;
break;
case WDCustomPopTypes.child_lefttop:
anchor = childTopLeft + offset;
break;
case WDCustomPopTypes.child_tapPosition:
anchor = tapInOverlay + offset;
break;
}
}
Navigator.of(context).push(
WDPopupRoute(
bgColor: bgColor,
dismissible: isTapClose,
duration: duration,
child: Material(
color: Colors.white.withOpacity(0),
child: Stack(
children: [
// 空白区域交给 ModalRoute 的 barrier 去处理是否可关闭
if (customOffset != null || popType != WDCustomPopTypes.screen_center)
Positioned(
left: anchor.dx,
top: anchor.dy,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (onTapContent != null) {
Navigator.of(context).maybePop();
onTapContent!();
}
},
child: contentChild,
),
)
else
Center(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (onTapContent != null) {
Navigator.of(context).maybePop();
onTapContent!();
}
},
child: contentChild,
),
),
],
),
),
),
);
},
child: child,
);
}
}

示例用法(点按钮,在手指处弹出一个菜单):

WDCustomPop(
popType: WDCustomPopTypes.child_tapPosition,
offset: const Offset(8, 8),
contentChild: Material(
elevation: 6,
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 160,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(leading: const Icon(Icons.edit), title: const Text('编辑'), onTap: () {}),
ListTile(leading: const Icon(Icons.delete), title: const Text('删除'), onTap: () {}),
],
),
),
),
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.more_vert),
label: const Text('更多'),
),
)

要点小抄:


进阶:动画再讲究一点点#

如果你想把“跟手弹”的内容做成“从锚点处放大”的感觉,可以加上 ScaleTransition 的“变换原点”感(简单法:在 contentChild 外包一层小容器,配上 Alignment.topLeft 或合适的 Alignment):

class AnchorPopupWrapper extends StatelessWidget {
const AnchorPopupWrapper({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final route = ModalRoute.of(context)! as WDPopupRoute;
return AnimatedBuilder(
animation: route.animation!,
builder: (_, __) {
final a = CurvedAnimation(
parent: route.animation!,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeIn,
);
return FadeTransition(
opacity: a,
child: ScaleTransition(
alignment: Alignment.topLeft, // 让锚点附近更有“冒出来”的感觉
scale: Tween(begin: 0.9, end: 1.0).animate(a),
child: child,
),
);
},
);
}
}

把上面 contentChild 再包一层 AnchorPopupWrapper,效果更“跟手”。


常见坑位与避雷#


一个“从 0 到 1”的实战模版#


小结#

弹窗这件小事,用“路由级弹层”来干,省心又高级。写到这儿,我一般会顺手把项目里的“奇技淫巧弹窗”都收编成同一套 PopupRoute 体系,既统一体验,又方便后续加动画、改样式。你也试试,弹窗不再“东一榔头西一棒”,一把梭。