不走“寻常路”的优雅弹窗#
有时候产品不想用 showDialog
、showModalBottomSheet
那些“现成套餐”。想要任意位置弹、要自定义动画、要蒙层颜色可调、要点空白处就消失、还得和页面栈融为一体。这时,PopupRoute
就是“手巧活儿”的首选。
这篇就带你从底层把弹窗这件事梳一遍:PopupRoute
是个啥、关键覆写点在哪、如何做居中弹/跟手弹/自定义位置弹,再给你一套可立即抄走的封装。
PopupRoute 是谁?#
PopupRoute<T>
继承 ModalRoute<T>
,属于“路由级弹层”。它天然支持:
- 背景蒙层(
barrierColor
)和是否可点击关闭(barrierDismissible
) - 过渡时长(
transitionDuration
)和自定义转场(buildTransitions
) - 无侵入压栈(
Navigator.push
),完美融入页面返回键、路由观察
一句话:自己造个“弹窗路由”,你就能完全掌控弹窗的生命线和长相。
最小可用版:先把弹窗架起来#
下面这个是一个实用的 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('你好,我是一个弹窗'), ), ), ), ),);
要点小抄:
barrierColor
控蒙层,barrierDismissible
决定点外侧是否关闭(这比自己写大GestureDetector
更干净)。transitionDuration
不仅要给时长,还要配合buildTransitions
才有动画(只给时长不覆写动画=没特效)。barrierLabel
别忘了,给无障碍读屏一个“名字”。
跟手、锚点、居中:都得安排上#
很多时候我们需要“点哪从哪儿弹”。思路是:点下去记录全局坐标,把内容放进一个全屏 Stack
里,再用 Positioned
把弹窗定位到目标点。下面是一个整理后的“点击弹出”小控件,内置三种弹出方式:
screen_center
:屏幕居中child_lefttop
:以点击控件的左上角为锚点child_tapPosition
:精确跟随手指点击位置- 支持
offset
做微调,customOffset
彻底自定义位置
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('更多'), ),)
要点小抄:
- 坐标换算用的是
overlay.globalToLocal(...)
,这样Positioned(left/top)
就能对着全屏Stack
准确定位。 - 关闭策略交给
barrierDismissible
,外层不必再套一层大GestureDetector
;内部内容如果要“点击即关闭”,在内容的onTap
里maybePop()
即可。 - 如果弹窗要压在最外层(穿过 nested navigator),可以
Navigator.of(context, rootNavigator: true).push(...)
。
进阶:动画再讲究一点点#
如果你想把“跟手弹”的内容做成“从锚点处放大”的感觉,可以加上 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
,效果更“跟手”。
常见坑位与避雷#
- 动画没生效:只写了
transitionDuration
,但没覆写buildTransitions
。记得两个一起上。 - 坐标错位:注意用
Overlay.of(context)
的RenderBox
做坐标换算;不要直接用MediaQuery.padding
想当然减去状态栏。 - 双重关闭:既设置了
barrierDismissible: true
,又在外层GestureDetector
上onTap
执行pop
,可能会出现多次pop
或手势冲突。交给 barrier 更稳。 - 滚动里嵌弹窗:如果弹窗内容内部也滚动,记得给内部
ListView
限高或使用SizedBox
包裹,避免“无界约束”报错。 - 可访问性:
barrierLabel
别留空;读屏用户会谢谢你。
一个“从 0 到 1”的实战模版#
- 居中对话框:
WDPopupRoute + Center + Material + 圆角 + 阴影
- 气泡菜单:
WDCustomPop(child_tapPosition) + Positioned + 小箭头 + 阴影
- 引导遮罩:
WDPopupRoute(barrierColor: Colors.black54, dismissible: false) + 自定义高亮裁剪
小结#
- 核心机制:
PopupRoute
提供蒙层、压栈、动画的底座;你负责“长相”和“位置”。 - 动画要点:配合
buildTransitions
,淡入、缩放、滑入随便玩。 - 定位思路:点击点/控件左上角 → 转为 overlay 坐标 →
Stack + Positioned
精准摆位。 - 交互收尾:点空白关闭交给
barrierDismissible
;内容点击内部自定。 - 可直接用:
WDPopupRoute
+WDCustomPop
两段代码,拎走就能开工。
弹窗这件小事,用“路由级弹层”来干,省心又高级。写到这儿,我一般会顺手把项目里的“奇技淫巧弹窗”都收编成同一套 PopupRoute
体系,既统一体验,又方便后续加动画、改样式。你也试试,弹窗不再“东一榔头西一棒”,一把梭。