美文网首页
Flutter 弹窗菜单实现 2023-08-12 周六

Flutter 弹窗菜单实现 2023-08-12 周六

作者: 勇往直前888 | 来源:发表于2023-08-12 11:18 被阅读0次

    简介

    弹窗菜单用到的场景还是蛮多的,比如这样的:

    image.png

    实现方案

    • 采用PopupMenuButton组件,配合PopupMenuItem,可以非常方便地实现这种弹出菜单效果。

    • 实践下来,基本的功能实现是足够用了,但是如果需要自定义视图,就显得力不从心;

    • 弹窗菜单基本上是一种OverlayEntry,所以如果需要自定义,应该用这个;

    • OverlayEntry弹窗菜单与目标组件是紧挨着的,如何定位是个问题。

    • CompositedTransformFollower 与 CompositedTransformTarget的组合可以解决目标组件与弹出OverlayEntry的定位问题。

    封装

    • 模仿PopupMenuButton,定义接口参数。为了增加自定义的自由度,弹出部分也只是个Widget,而不是一个数组。
    /// 模仿PopupMenuButton写的弹窗菜单
    class PandaPopupMenu extends StatelessWidget {
      const PandaPopupMenu({
        Key? key,
        required this.targetWiget,
        required this.menuWiget,
      }) : super(key: key);
    
      final Widget targetWiget;
      final Widget menuWiget;
    
      @override
      Widget build(BuildContext context) {
        return const Center(
          child: Text(
            'PandaPopupMenu is working',
            style: TextStyle(fontSize: 20),
          ),
        );
      }
    }
    
    • 内部变量:组件对所需要的连线;弹窗用一个OverlayEntry
      /// 内部变量
      final LayerLink _layerLink = LayerLink();
      OverlayEntry? _overlayEntry;
    

    Target布局

    这个就是PopupMenuButton一直显示的部分,外面套一个Container,可以带来很大的方便。

      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: () {
            _showOverlay(context);
          },
          child: Container(
            margin: margin,
            padding: padding,
            color: color,
            width: width,
            height: height,
            decoration: decoration,
            child: CompositedTransformTarget(
              link: _layerLink,
              child: targetWidget,
            ),
          ),
        );
      }
    

    菜单代码

      /// 显示浮层
      void _showOverlay(BuildContext context) {
        /// 防止重复创建,不然失去句柄的OverlayEntry将无法消除
        if (_overlayEntry == null) {
          _overlayEntry = _createOverlayEntry();
          if (_overlayEntry != null) {
            Overlay.of(context).insert(_overlayEntry!);
          }
        }
      }
    
      /// 隐藏浮层
      void _hideOverlay() {
        /// 防止null调用异常
        if (_overlayEntry != null) {
          _overlayEntry?.remove();
          _overlayEntry = null;
        }
      }
    
      /// 创建浮层
      OverlayEntry _createOverlayEntry() {
        return OverlayEntry(
          builder: (BuildContext context) {
            return GestureDetector(
              onTap: () {
                _hideOverlay();
              },
              child: UnconstrainedBox(
                child: CompositedTransformFollower(
                  link: _layerLink,
                  targetAnchor: Alignment.bottomCenter,
                  followerAnchor: Alignment.topCenter,
                  offset: const Offset(0, 10),
                  child: Material(
                    child: menuWigdet,
                  ),
                ),
              ),
            );
          },
        );
      }
    
    • OverlayEntry默认是全屏充满的,PopupMenuButton就是这样的情况。不过,在很多时候我们不希望这样。比如,我们现在的设想是点击Target部分,显示菜单;点击菜单部分,隐藏菜单。要去掉全屏,只要在外面套一个UnconstrainedBox就可以了。

    • OverlayEntry不能反复创建,不然的话,丢失句柄的OverlayEntry会无法消除;所以创建和消除方法需要做好判空处理。

    • 默认锚点都在左上角,这样导致弹出的菜单盖住了Target,失去“跟随”的意义。这个只要改一下targetAnchor,followerAnchor取值就可以了。

    • 偏移量:Target和菜单是紧挨着的,如果需要间隔,那么只要修改offset参数就可以了。

    调用代码

    PandaPopupMenu(
      targetWigdet: Container(
        color: Colors.yellow,
        width: 30,
        height: 30,
      ),
      menuWigdet: Container(
        color: Colors.blue,
        width: 200,
        height: 300,
      ),
    ),
    

    代码很简单,就是两个不同颜色的矩形

    效果

    • 默认状态:就是一个黄色矩形
    image.png
    • 展开状态:点击黄色矩形,添加一个浮层,目前是一个蓝色矩形;跟随状态,并且有间隔。点击弹出的蓝色矩形,可以隐藏。
    image.png
    • 由于去掉了OverlayEntry的全屏属性,其他地方都能正常点击,响应也正常,页面跳转也不受影响。

    • 问题:由于这里页面的底部是Tab,切换Tab的时候,失去了“跟随”目标,所以弹出的蓝色就只能居中显示。

    ![image.png](https://img.haomeiwen.com/i1186939/35473caf95611854.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
    

    变换

    作为一个整体,变换之后,也能很好地跟随。

    • 旋转
    Transform(
      transform: Matrix4.rotationZ(-15 / 180 * 3.14),
      alignment: Alignment.center,
      child: PandaPopupMenu(
        targetWigdet: Container(
          color: Colors.yellow,
          width: 30,
          height: 30,
        ),
        menuWigdet: Container(
          color: Colors.blue,
          width: 200,
          height: 300,
        ),
      ),
    ),
    
    image.png
    • 缩放
    Transform(
      transform: Matrix4.diagonal3Values(0.5, 0.5, 1),
      alignment: Alignment.center,
      child: PandaPopupMenu(
        targetWigdet: Container(
          color: Colors.yellow,
          width: 30,
          height: 30,
        ),
        menuWigdet: Container(
          color: Colors.blue,
          width: 200,
          height: 300,
        ),
      ),
    ),
    
    image.png
    • 斜切
    Transform(
      transform: Matrix4.skewX(15 / 180 * 3.14),
      alignment: Alignment.center,
      child: PandaPopupMenu(
        targetWigdet: Container(
          color: Colors.yellow,
          width: 30,
          height: 30,
        ),
        menuWigdet: Container(
          color: Colors.blue,
          width: 200,
          height: 300,
        ),
      ),
    ),
    
    image.png
    • 平移
    Transform(
      transform: Matrix4.translationValues(30, 10, 0),
      alignment: Alignment.center,
      child: PandaPopupMenu(
        targetWigdet: Container(
          color: Colors.yellow,
          width: 30,
          height: 30,
        ),
        menuWigdet: Container(
          color: Colors.blue,
          width: 200,
          height: 300,
        ),
      ),
    ),
    
    image.png

    代码最后的样子

    // ignore_for_file: must_be_immutable
    
    import 'package:flutter/material.dart';
    
    /// 模仿PopupMenuButton写的弹窗菜单
    class PandaPopupMenu extends StatelessWidget {
      PandaPopupMenu({
        Key? key,
        required this.targetWigdet,
        required this.menuWigdet,
        this.margin,
        this.padding,
        this.color,
        this.width,
        this.height,
        this.decoration,
        this.offset = const Offset(0, 10),
        this.targetAnchor = Alignment.bottomCenter,
        this.followerAnchor = Alignment.topCenter,
      }) : super(key: key);
    
      final Widget targetWigdet;
      final Widget menuWigdet;
      final EdgeInsetsGeometry? margin;
      final EdgeInsetsGeometry? padding;
      final Color? color;
      final double? width;
      final double? height;
      final Decoration? decoration;
      final Offset offset;
      final Alignment targetAnchor;
      final Alignment followerAnchor;
    
      /// 内部变量
      final LayerLink _layerLink = LayerLink();
      OverlayEntry? _overlayEntry;
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: () {
            _showOverlay(context);
          },
          child: Container(
            margin: margin,
            padding: padding,
            color: color,
            width: width,
            height: height,
            decoration: decoration,
            child: CompositedTransformTarget(
              link: _layerLink,
              child: targetWigdet,
            ),
          ),
        );
      }
    
      /// 显示浮层
      void _showOverlay(BuildContext context) {
        /// 防止重复创建,不然失去句柄的OverlayEntry将无法消除
        if (_overlayEntry == null) {
          _overlayEntry = _createOverlayEntry();
          if (_overlayEntry != null) {
            Overlay.of(context).insert(_overlayEntry!);
          }
        }
      }
    
      /// 隐藏浮层
      void _hideOverlay() {
        /// 防止null调用异常
        if (_overlayEntry != null) {
          _overlayEntry?.remove();
          _overlayEntry = null;
        }
      }
    
      /// 创建浮层
      OverlayEntry _createOverlayEntry() {
        return OverlayEntry(
          builder: (BuildContext context) {
            return GestureDetector(
              onTap: () {
                _hideOverlay();
              },
              child: UnconstrainedBox(
                child: CompositedTransformFollower(
                  link: _layerLink,
                  targetAnchor: Alignment.bottomCenter,
                  followerAnchor: Alignment.topCenter,
                  offset: const Offset(0, 10),
                  child: Material(
                    child: menuWigdet,
                  ),
                ),
              ),
            );
          },
        );
      }
    }
    

    参考文章

    Flutter 组件 | 手牵手,一起走 CompositedTransformFollower 与 CompositedTransformTarget

    Flutter 带指示器的悬浮窗口

    CompositedTransformTarget + OverlayEntry实现悬浮窗

    相关文章

      网友评论

          本文标题:Flutter 弹窗菜单实现 2023-08-12 周六

          本文链接:https://www.haomeiwen.com/subject/bnjtmdtx.html