Flutter 如何实现 iOS 风格的下拉菜单项弹出框效果
Flutter 下拉菜单项弹出效果实现方案

实现思路
要实现图中点击触发后菜单平滑弹出的效果,核心是组合透明度渐变+位移动画,菜单从触发控件下方滑入的同时淡入,背景可叠加半透明遮罩增强层级感。
方案1:基于官方PopupMenuButton改造(最简实现)
直接复用Flutter自带的PopupMenuButton组件,仅需调整样式和动画参数即可快速匹配效果:
final List<String> _menuItems = ['选项1', '选项2', '选项3', '选项4']; PopupMenuButton<String>( // 自定义触发按钮 child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Text('选择选项'), SizedBox(width: 4), Icon(Icons.arrow_drop_down, size: 18), ], ), ), // 菜单样式 shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), elevation: 4, // 弹出动画时长 duration: const Duration(milliseconds: 200), // 菜单项构建 itemBuilder: (context) => _menuItems.map((item) { return PopupMenuItem( value: item, child: Text(item), ); }).toList(), // 选中回调 onSelected: (value) { debugPrint('选中了$value'); }, )
方案2:自定义Overlay实现(高度还原设计稿)
如果需要完全匹配动效曲线、弹出位置、遮罩样式等细节,可通过Overlay自定义悬浮层实现。
核心实现步骤
- 点击触发按钮时,通过
RenderBox计算按钮在屏幕中的坐标,确定菜单弹出的起始位置 - 初始化
AnimationController,同时驱动透明度和位移动画:初始状态透明度为0、Y轴偏移-10dp,动画结束时透明度为1、Y轴偏移0 - 点击遮罩/菜单项时执行反向动画,动画结束后移除Overlay层
核心代码片段
// 显示自定义菜单,buttonKey为触发按钮绑定的GlobalKey void _showCustomMenu(BuildContext context, GlobalKey buttonKey) async { // 获取触发按钮位置 final RenderBox renderBox = buttonKey.currentContext!.findRenderObject() as RenderBox; final offset = renderBox.localToGlobal(Offset.zero); final menuTop = offset.dy + renderBox.size.height + 4; final menuLeft = offset.dx; // 创建动画控制器 final animationController = AnimationController( vsync: Navigator.of(context).overlay!, duration: const Duration(milliseconds: 200), ); final animation = Tween<double>(begin: 0, end: 1).animate( CurvedAnimation(parent: animationController, curve: Curves.easeOut), ); // 插入Overlay OverlayEntry? overlayEntry = OverlayEntry(builder: (context) { return GestureDetector( onTap: () async { // 点击遮罩关闭菜单 await animationController.reverse(); overlayEntry?.remove(); overlayEntry = null; }, behavior: HitTestBehavior.translucent, child: Stack( children: [ // 半透明遮罩 Container(color: Colors.black12), Positioned( left: menuLeft, top: menuTop, child: AnimatedBuilder( animation: animation, builder: (context, child) { return Opacity( opacity: animation.value, child: Transform.translate( offset: Offset(0, (1 - animation.value) * -10), child: child, ), ); }, // 菜单容器 child: Container( width: renderBox.size.width, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2), ) ], ), child: Column( mainAxisSize: MainAxisSize.min, children: _menuItems.map((item) { return InkWell( onTap: () async { debugPrint('选中了$item'); await animationController.reverse(); overlayEntry?.remove(); overlayEntry = null; }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [Text(item)], ), ), ); }).toList(), ), ), ), ), ], ), ); }); Navigator.of(context).overlay?.insert(overlayEntry!); animationController.forward(); }
注意事项
- 自定义实现时要做边界判断:如果菜单弹出后超出屏幕底部,需要动态调整top值,避免菜单被截断
- 可根据设计稿调整动画曲线、圆角、阴影、分割线等属性,完全匹配UI要求
- 页面销毁时要注意销毁动画控制器,避免内存泄漏
内容的提问来源于stack exchange,提问作者maheswari
相关产品推荐
相关产品推荐

