You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

Flutter 下拉菜单项弹出效果实现方案

参考UI效果图

实现思路

要实现图中点击触发后菜单平滑弹出的效果,核心是组合透明度渐变+位移动画,菜单从触发控件下方滑入的同时淡入,背景可叠加半透明遮罩增强层级感。

方案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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.25 08:06:03