Flutter 中使用 OverlayPortal 创建自定义下拉菜单

效果展示

在写Flutter时发现并没有图中这种效果,所以自己写了一个:

需要实现的功能如下

  • 支持列表展开动画
  • 支持背景fade动画
  • 切换时禁止收起
  • 菜单切换

使用OverlayEntry自定义一个浮窗

因为需要OverlayEntry跟随点击的按钮,所以需要使用CompositedTransformTargetCompositedTransformFollower关联。

  • 自定义Model存放OverlayEntry
dart 复制代码
class FilterDropDownModel {
  final LayerLink layerLink;
  final String name;
  FilterDropDownModel({required this.name, required this.layerLink});
}

// ...
// 初始化菜单
final List<FilterDropDownModel> _filterList = [
    FilterDropDownModel(name: '类别', layerLink: LayerLink()),
    FilterDropDownModel(name: '排序', layerLink: LayerLink()),
    FilterDropDownModel(name: '区域', layerLink: LayerLink()),
];

LayerLink用来关联CompositedTransformTargetCompositedTransformFollower

dart 复制代码
CompositedTransformTarget(
  link: _filterList[index].layerLink,
  child: GestureDetector(
    onTap: () {
      RenderBox? renderBox;
      if (_buttonRowKey.currentContext != null) {
        renderBox = _buttonRowKey.currentContext?.findRenderObject() as RenderBox;
      }

      Overlay.of(context).insert(
        _overlayEntry = OverlayEntry(
          builder: (context) {
            return CompositedTransformFollower(
              link: _filterList[index].layerLink,
              offset: Offset(0, renderBox!.size.height),
              child: Container(
                color: Colors.red,
                child: ListView(
                  shrinkWrap: true,
                  children: [
                    Text('dataasdasdasd2======$index'),
                    const Text('dataasdasdasd3======'),
                    const Text('dataasdasdasd3======'),
                    const Text('dataasdasdasd=qe====='),
                    const Text('dataasdasdasd======'),
                    const Text('dataasdasdasd======'),
                  ],
                ),
              ),
            );
          },
        ),
      );
    },
    
    child: Container(
      color: Colors.amber,
      height: 30,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            _filterList[index].name,
            style: const TextStyle(fontSize: 14, color: AppColors.black85),
          ),
          const Icon(
            Icons.keyboard_arrow_down_rounded,
            size: 20,
            color: Colors.black26,
          ),
        ],
      ),
    ),
  ),
),
  • 由于CompositedTransformFollower跟随的坐标为CompositedTransformTarget的左下角位置

这里计算了Offse的位置,设置CompositedTransformFollower值为offset:Offset(left, renderBox!.size.height)即可解决坐标的问题

dart 复制代码
double left = -(renderBox!.size.width / _filterList.length) * index;

多个tab切换

使用OverlayEntry向页面中添加时,需要先清除之前的OverlayEntry,否则会叠加,当切换tab时调用_overlayEntryremove方法进行清空。

dart 复制代码
_overlayEntry!.remove();
_overlayEntry = null;

菜单高度自适应时的动画

图中红色菜单菜单使用了Container进行包裹并没有设置高度,但如果我们需要使用高度动画,就需要利用Tween动画begin/end的值。

此时可以使用AnimatedBuilder配合SizeTransition进行动画,SizeTransition可以实现高度自适应时的收起展开动画效果:

存储一个_isExpanded变量,用来记录收起展开的状态,该状态用于控制AnimatedBuilder的动画触发时机:

dart 复制代码
_isExpanded = !_isExpanded;

// ...
if (_isExpanded) {
  _animationController.forward();
} else {
  _animationController.reverse();
}

收起展开的效果已经实现,但是收起展开的方向是由中间开始,不是很符合预期,所以这里我们需要稍微改造一下,在SizeTransition内嵌套一个SlideTransition组件并设置position,具体代码如下:

dart 复制代码
AnimatedBuilder(
  animation: _animationController,
  builder: (context, child) {
    return SizeTransition(
      sizeFactor: _animation,
      child: SlideTransition(
        position: Tween<Offset>(
          begin: const Offset(0, -1), // 重点
          end: Offset.zero,
        ).animate(_animation),
        child: Container(
          color: Colors.red,
          child: ListView(
            shrinkWrap: true,
            children: [
              Text('dataasdasdasd2======$index'),
              const Text('dataasdasdasd3======'),
              const Text('dataasdasdasd3======'),
              const Text('dataasdasdasd=qe====='),
              const Text('dataasdasdasd======'),
              const Text('dataasdasdasd======'),
            ],
          ),
        ),
      ),
    );
  },
),

最终的收起展开动画如下图:

添加透明遮照层

到这里其实就比较常规,用Stack组件将CompositedTransformFollowerchild进行包裹,并创建一个AnimatedBuilder作为透明遮照层,

dart 复制代码
Stack(
    children: [
      AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) {
          return FadeTransition(
            opacity: _animation,
            child: Container(color: _maskColor),
          );
        },
      ),
      
      // ...
   ],
),

总结

菜单容器使用OverlayPortal CompositedTransformTarget CompositedTransformFollower三者结合;

收起展开动画使用AnimatedBuilder SizeTransition SlideTransition 三者结合;

渐显的动画直接使用FadeTransition;

相关推荐
拾年27535 分钟前
React Hooks 进阶篇:useMemo / useCallback / useReducer / useImperativeHandle,用「算账 +
前端·javascript·react.js
Jackson__41 分钟前
面试官:如何在老项目中使用新框架,并实现通信?
前端·javascript·面试
拾年27544 分钟前
React Hooks 保姆级教程:把组件想象成失忆的打工人,4 个 Hook 带你原地起飞
前端·javascript·react.js
mmsx1 小时前
一个 Bug 修了 12 轮,根因只有一句话:AI 编程时代,日志才是唯一的真相
前端
默_笙1 小时前
😝 5 亿次循环卡死页面?我用 Web Worker 把它扔进后台线程,页面丝滑如初
前端·javascript
CAD老兵1 小时前
一行代码集成 DWG/DXF 图纸查看:测量批注,数据不出站
前端·javascript·github
小林ixn1 小时前
单例模式:从弹窗管理到全局状态,一个模式搞定
前端·javascript·设计模式
AI编程实验室1 小时前
Node.js Agent Handoff 仓库扫描 MVP:忽略规则、include 通配与稳定输出实现
前端·ai编程
猫不易1 小时前
从 Virtual DOM 到 Vapor:Vue 3.6 的另一条路
前端·vue.js
光影少年1 小时前
react navite性能优化 & 常见坑
前端·react native·掘金·金石计划