Flutter 自定义QQ聊天长按弹窗popup

500 阅读3分钟

在开发中,我们经常会遇到长按弹出框。这里我们自己实现一个。

自定义弹出框popup

完整代码

YML6AKSti5Dnepm.jpg

功能点

  1. 支持上下左右弹出
  2. 支持长按、点击弹出
  3. 箭头指示器与点击的widget居中对齐,弹出的pop自适应边界
  4. 与系统路由联动

相关参数

属性名类型默认值简介
contextBuildContext必填为了方便获取点击的widget的尺寸和位置
childWidget必填弹出的widget
marginEdgeInsetsEdgeInsets.zero弹窗边距
positionPopPositionPopPosition.top弹出的位置
arrowSizedouble10箭头的尺寸
arrowAspectRatiodouble0.5箭头高宽比
arrowColorColorColors.black箭头颜色
showArrowbooltrue是否显示箭头
spacedouble0点击的widget与弹窗箭头之间的间距
barrierColorColornull弹窗背景颜色

使用方法

使用Builder包裹需要点击的Widget


Builder _buildChild(PopPosition position) {
    var name = position.name.split(".").first;
    return Builder(builder: (context) {
      return GestureDetector(
        onLongPress: () {
          showPopup(
              position: position,
              context: context,
              margin: const EdgeInsets.symmetric(horizontal: 5),
              child: Container(
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(6),
                  color: Colors.black,
                ),
                padding: const EdgeInsets.all(4),
                child: Text(
                  List.generate(Random().nextInt(10) + 3, (index) => name).toString(),
                  style: const TextStyle(color: Colors.white),
                ),
              ));
        },
        child: Container(
          decoration: BoxDecoration(border: Border.all(color: Colors.red)),
          width: 100,
          height: 100,
          child: Center(
            child: Text(name),
          ),
        ),
      );
    });

代码实现

定义弹出方向

使用枚举定义上下左右四个方向,angle是不同方向对应箭头的旋转角度

enum PopPosition {
  top(0),
  bottom(pi),
  left(-pi / 2),
  right(pi / 2),
  ;

  final double angle;

  const PopPosition(this.angle);
}

定义箭头

使用CustomClipper裁剪出一个三角形

class Arrow extends StatelessWidget {
  final double size;
  final double aspectRatio;
  final Color color;

  const Arrow({super.key, required this.size, this.aspectRatio = 0.87, required this.color});

  @override
  Widget build(BuildContext context) {
    return ClipPath(
      clipper: _ArrowClipper(aspectRatio),
      child: Container(
        width: size,
        height: size,
        color: color,
      ),
    );
  }
}

class _ArrowClipper extends CustomClipper<Path> {
  final double aspectRatio;

  _ArrowClipper(this.aspectRatio);

  @override
  Path getClip(Size size) {
    var width = size.width;
    var arrowHeight = width * aspectRatio;
    Path path = Path();
    path.moveTo(0, 0);
    path.lineTo(width, 0);
    path.lineTo(width / 2, arrowHeight);
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) {
    return true;
  }
}

弹出

使用Navigator弹出自定义PopupRoute,点击返回按钮,空白地方自动隐藏弹窗

Navigator.push(context, _PopupRoute(child: layout, backColor: barrierColor));
class _PopupRoute extends PopupRoute {
  final Widget child;
  final Color? backColor;

  _PopupRoute({
    required this.child,
    this.backColor,
  });

  @override
  Color? get barrierColor => backColor;

  @override
  bool get barrierDismissible => true;

  @override
  String? get barrierLabel => null;

  @override
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
    return child;
  }

  @override
  Duration get transitionDuration => const Duration();
}

弹窗的边界约束

QQ截图20230917132425.png

主要逻辑

  1. 获取点击widget的边界rect,这里通过context找到RenderBox获取
  2. 确定箭头的位置,rect正上方
  3. 确定pop位置,我们需要计算出pop(上图红色矩形范围)的边界,然后保证pop不会超出这个边界
  4. 使用MultiChildLayoutDelegate放置箭头和pop

获取点击widget的边界rect

void showPopup({
  required BuildContext context,
  required Widget child,
  EdgeInsets margin = EdgeInsets.zero,
  PopPosition position = PopPosition.top,
  double arrowSize = 10,
  double arrowAspectRatio = 0.5,
  Color arrowColor = Colors.black,
  bool showArrow = true,
  double space = 0,
  Color? barrierColor,
}) {
  var rendBox = context.findRenderObject() as RenderBox?;
  if (rendBox != null) {
    var size = rendBox.size;
    var offset = rendBox.localToGlobal(Offset.zero);
    var rect = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
    var layout = CustomMultiChildLayout(
      delegate: _MultiChildLayoutDelegate(
        rect: rect,
        position: position,
        margin: margin,
        showArrow: showArrow,
        arrowSize: arrowSize,
        space: space,
      ),
      children: [
        LayoutId(id: _Id.pop, child: Material(child: child)),
        if (showArrow)
          LayoutId(
            id: _Id.arrow,
            child: Transform.rotate(
              angle: position.angle,
              child: Arrow(size: arrowSize, aspectRatio: arrowAspectRatio, color: arrowColor),
            ),
          ),
      ],
    );
    Navigator.push(context, _PopupRoute(child: layout, backColor: barrierColor));
  }
}

使用自定义MultiChildLayoutDelegate重写performLayout方法使用positionChild控制位置,对应的child定义两个id,分别为箭头和弹窗

enum _Id {
  pop,
  arrow,
}

class _MultiChildLayoutDelegate extends MultiChildLayoutDelegate {
  final Rect rect;
  final PopPosition position;
  final EdgeInsets margin;
  final bool showArrow;
  final double space;
  final double arrowSize;

  _MultiChildLayoutDelegate({
    required this.rect,
    required this.position,
    required this.margin,
    required this.showArrow,
    required this.arrowSize,
    required this.space,
  });

  @override
  void performLayout(Size size) {
    Rect layoutRect = Rect.fromLTRB(margin.left, margin.top, size.width - margin.right, size.height - margin.bottom);
    Rect outRect = getOutRect(layoutRect);
    var popSize = layoutChild(_Id.pop, BoxConstraints.loose(Size(outRect.width, outRect.height)));
    var popRect = getRect(popSize, dis);
    //适配边界
    popRect = keepInside(outRect, popRect);
    //定位pop
    positionChild(_Id.pop, popRect.topLeft);

    if (showArrow) {
      var arrowSize = layoutChild(_Id.arrow, BoxConstraints.loose(Size(outRect.width, outRect.height)));
      var arrowRect = getRect(arrowSize, space);
      positionChild(_Id.arrow, arrowRect.topLeft);
    }
  }

  @override
  bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) {
    return false;
  }

  ///点击的widget与pop之间的间距
  double get dis => (showArrow ? arrowSize : 0) + space;

  ///获取目标方块的位置
  Rect getRect(Size size, double space) {
    var dy = (rect.height + size.height) / 2 + space;
    var dx = (rect.width + size.width) / 2 + space;
    Offset offset;
    switch (position) {
      case PopPosition.top:
        offset = Offset(0, -dy);
        break;
      case PopPosition.bottom:
        offset = Offset(0, dy);
        break;
      case PopPosition.left:
        offset = Offset(-dx, 0);
        break;
      case PopPosition.right:
        offset = Offset(dx, 0);
        break;
    }
    Offset center = rect.center.translate(offset.dx, offset.dy);
    return Rect.fromCenter(center: center, width: size.width, height: size.height);
  }

  ///获取pop所在的边界范围
  Rect getOutRect(Rect layoutRect) {
    switch (position) {
      case PopPosition.top:
        return Rect.fromLTRB(layoutRect.left, layoutRect.top, layoutRect.right, rect.top - dis);
      case PopPosition.bottom:
        return Rect.fromLTRB(layoutRect.left, rect.bottom + dis, layoutRect.right, layoutRect.bottom);
      case PopPosition.left:
        return Rect.fromLTRB(layoutRect.left, layoutRect.top, rect.left - dis, layoutRect.bottom);
      case PopPosition.right:
        return Rect.fromLTRB(rect.right + dis, layoutRect.top, layoutRect.right, layoutRect.bottom);
    }
  }

  ///保持b在a范围内
  Rect keepInside(Rect a, Rect b) {
    // 判断 b 是否在 a 范围内
    if (!a.contains(b.topLeft) || !a.contains(b.bottomRight)) {
      // 计算需要移动的距离
      double dx = 0;
      double dy = 0;

      if (b.left < a.left) {
        dx = a.left - b.left;
      } else if (b.right > a.right) {
        dx = a.right - b.right;
      }

      if (b.top < a.top) {
        dy = a.top - b.top;
      } else if (b.bottom > a.bottom) {
        dy = a.bottom - b.bottom;
      }
      return b.shift(Offset(dx, dy));
    } else {
      return b;
    }
  }
}