Flutter:如何在覆盖层中切出一个洞我们的目标概念实施

2025-06-05

Flutter:如何在覆盖层上切一个洞

我们的目标

概念

执行

很多应用通常会有一个简短的教程,引导用户了解应用的基本功能。教程中通常会有一个半透明的覆盖层,覆盖了除应用的讲解部分和需要用户交互的部分之外的所有内容。

有几种方法可以实现这一点。让我们来看看。

我们的目标

我们的目标

这就是我们的目标:一个应用程序,其背景为屏幕(在本例中为ListView),FloatingActionButton顶部为 ,由半透明覆盖层包围,旁边有解释文本。

概念

为了找到解决方案,让我们思考一下其背后的概念:

我们的小部件堆栈的透视图

我们希望将不同的层堆叠在一起:

  • 底部是需要解释的屏幕
  • 除此之外,还有一个覆盖层,使大多数 UI 元素变得模糊
  • 第二高级别是解释性文本
  • 最上层包含我们目前无法控制的所有内容,例如 FAB、应用栏、状态栏和操作系统控件

请注意,这仅适用于 FAB。如果我们要在教程中解释 aFlatButton或 a ,我们也会将此元素放在最底层。FAB 绘制在覆盖层上,该覆盖层不属于通常的画布。 目前,这无关紧要,因为无论如何,我们都会将孔洞放置在覆盖层上。因此,从上到下的角度来看,无法确定按钮在 z 轴上的位置。TextInput

执行

Flutter 提供了一个水平堆栈(称为Row),一个垂直堆栈(称为Column)以及一个 z 方向的堆栈(称为Stack)。
让我们用它来将上述概念变为现实。

Stack(children: <Widget>[
  _getContent(),
  _getOverlay(),
  _getHint()
]);
Enter fullscreen mode Exit fullscreen mode

这是主要的小部件。

  • _getContent()应该返回我们想要覆盖的任何内容(在上面提到的例子中是ListView
  • _getOverlay()返回右下角有一个洞的半透明覆盖层
  • _getHint()负责返回标记按钮的提示

FAB 是父级的一部分Scaffold,这就是它不在 Stack 小部件内的原因。

覆盖层

最棘手的部分是_getOverlay()方法。我想介绍几种可能性。

剪裁路径

文档说:

每当窗口小部件需要绘制时,都会在委托上调用回调。回调返回一个路径,窗口小部件会阻止子窗口小部件在路径之外进行绘制。

所以基本上我们有一个需要绘制的形状,然后ClipPath定义了绘制位置。只有一个问题:我们想要的是相反的结果。我们想要声明一个不绘制覆盖层的区域。
但在解决这个问题之前,让我们先看看它ClipPath通常是什么样子。
为了创建自定义裁剪器,我们需要从类扩展CustomClipper<T>,在我们的例子中,将类型设置为 Path,因为我们想要一个更复杂的形状(矩形内的倒置椭圆)。此外,还有一些预定义的基本形状。当裁剪比较简单(例如椭圆形或圆角矩形)时,可以使用这些形状,因为这样可以减少代码编写量,并且性能会更好一些。

class InvertedClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    return new Path();
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => true;
}
Enter fullscreen mode Exit fullscreen mode

我们需要重写以下两个方法:

  • getClip:输入是的大小RenderBox,所需的输出是表示给定的要绘制Path的空间RenderBox
  • shouldReclip:当有新的对象实例时,会调用此方法Path。输入是旧版本的裁剪器。通过布尔输出,您可以决定是否再次执行裁剪。实际上,出于开发目的,我们返回 true,因为我们希望热重载能够正常工作。在发布版本中,出于性能原因,我建议返回 false,并且裁剪器是静态的。

让我们getClip用实际路径填充该方法。

return Path()
  ..addRect(Rect.fromLTWH(0, 0, size.width, size.height))
  ..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
  ..fillType = PathFillType.evenOdd;
Enter fullscreen mode Exit fullscreen mode

根据你的理解程度,可能需要解释一些内容。
首先,关于语法:我使用了两个点,这在 Dart 中被称为级联符号。这只是一个语法糖,用于在同一个类实例上进行一系列方法调用时使用。

我们在路径中添加了两个形状: aRect和 an Oval。矩形的大小与整个 RenderBox 相同,因为它代表了整个覆盖层。椭圆的大小与 FAB 相同(加上一些填充),因为它是覆盖层中的“孔”。

最后一行实际上至关重要。如果我们省略它,我们会看到覆盖层,但看不到洞。但为什么会这样呢?记住,路径内的所有内容都会被绘制,而路径外的所有内容则不会被绘制。因此,我们必须创建一条路径,其中椭圆被视为外部,而周围的矩形被视为内​​部。

正是PathFillType决定了这一点。默认情况下,fillType 设置为PathFillType.nonZero。在这种情况下,如果满足以下条件,则认为给定点位于路径内:

从点到无穷远绘制的一条线与绕该点顺时针方向的线相交的次数与与绕该点逆时针方向的线相交的次数不同

当出现以下情况时,evenOdd 会将其视为内部:

从点到无穷远绘制的一条线与奇数条线相交

由于我们的椭圆不跨越任何线并且零被认为是偶数,因此椭圆被视为外部。

如果您想深入了解,您可以在维基百科上找到有关算法的更多信息:这里这里

这不是那么容易理解,所以我有一个更简单的方法给你:我们不使用子路径和操作 PathFillType,而是绘制周围的矩形,然后减去椭圆形:

Path.combine(
  PathOperation.difference,
  Path()..addRect(
      Rect.fromLTWH(0, 0, size.width, size.height)
  ),
  Path()
    ..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
    ..close(),
);
Enter fullscreen mode Exit fullscreen mode

我不确定性能如何,但就可读性和清晰度而言,我认为后一种方法更好,但这或许只是我个人的看法。
剩下唯一要做的就是决定要裁剪哪个 Widget。在本例中,我们需要一个半透明的、占满整个屏幕的容器。

Widget _getOverlay() {
  return ClipPath(
    clipper: InvertedClipper(),
      child: Container(
        color: Colors.black54,
      ),
  );
}
Enter fullscreen mode Exit fullscreen mode

定制画家

我们使用ClipPath一个半透明的覆盖层,并决定绘制除洞之外的所有东西。那么,使用一个CustomPainter只绘制我们想要的部分怎么样?
好处是:我们几乎可以重复使用上述代码的每个部分,所以我会立即展示结果。

class OverlayWithHolePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.black54;

    canvas.drawPath(
      Path.combine(
        PathOperation.difference,
        Path()..addRect(
          Rect.fromLTWH(0, 0, size.width, size.height)
        ),
        Path()
          ..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
          ..close(),
      ),
      paint
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

但是等等,如果我们编译它,却看不到覆盖层。这是为什么呢?
因为如果 aCustomPaint的 child 属性(它定义了在其下方绘制的所有内容)未设置,则其大小默认为零。在这种情况下,我们必须手动设置 size 属性,如下所示:size MediaQuery.of(context).size:。

比较两种方法

剪裁路径

Widget _getOverlay() {
  return ClipPath(
    clipper: InvertedClipper(),
      child: Container(
        color: Colors.black54,
      ),
  );
}

class InvertedClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => true;
}
Enter fullscreen mode Exit fullscreen mode

定制画家

Widget _getOverlay(BuildContext context) {
  return CustomPaint(
    size: MediaQuery.of(context).size,
    painter: HolePainter()
  );
}

class OverlayWithHolePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.black54;

    canvas.drawPath(
      ,
      paint
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

就代码行数而言,裁剪器方法更胜一筹。至于可读性,我更喜欢CustomPainter裁剪器。总之,两种方法的结果完全相同:

看起来很棒

请注意,如果您仍然希望底层小部件接收用户的手势事件,则需要将所有内容包装在IgnorePointer.

颜色过滤

我想介绍第三种方法。这种方法的有趣之处在于,你完全不需要与 Paint 交互。你只需留在 Widget 树中即可。听起来很诱人,不是吗?
我们要使用的ColorFilteredWidget 就是它。顾名思义,这个 Widget 会将 应用于ColorFilter它的子级。

彩色滤光片是一种采用两种颜色并输出一种颜色的功能

这两个颜色首先是你指定的颜色(ColorFilter.mode 构造函数有一个 color 属性),其次是指定子级相应像素的颜色。我们使用 作为 BlendMode BlendMode.srcOut。它的效果如下:

显示源图像,但仅限于两幅图像不重叠的部分。目标图像不会被渲染,仅被视为蒙版。目标图像的颜色通道会被忽略,只有不透明度会产生效果。

在我们的例子中,源图像是颜色Colors.black54,而目标是我们作为 child 参数提供的任何内容。因此,基本上会绘制一个半透明的覆盖层,并且子窗口小部件中每个不透明度大于零的像素都会产生一个洞,因为源图像不会在它们重叠的地方绘制。本质上,我们现在有一个 alpha 遮罩。

Widget _getOverlay() {
  return ColorFiltered(
    colorFilter: ColorFilter.mode(
      Colors.black54,
      BlendMode.srcOut
    ),
    child: Stack(
      children: [
        Container(
          decoration: BoxDecoration(
            color: Colors.transparent,
          ),
          child: Align(
            alignment: Alignment.bottomRight,
            child: Container(
              margin: const EdgeInsets.only(right: 4, bottom: 4),
              height: 80,
              width: 80,
              decoration: BoxDecoration(
                color: Colors.black, // Color does not matter but should not be transparent
                borderRadius: BorderRadius.circular(40),
              ),
            ),
          ),
        ),
      ],
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

我们使用具有透明背景色的 来绘制覆盖层Container。由于没有重叠像素,因此Colors.black54会绘制在整个屏幕上。我们通过将一个椭圆形Container放入另一个 中来创建空洞Container。此小部件具有非透明背景色非常重要,因为这会产生重叠,从而导致遮罩无法绘制其形状。
效果是,我们可以将任何会导致空洞的小部件放入该容器中。这可以是文本、图像或其他任何内容。

使用 ColorFiltered 小部件构建的覆盖层

文字提示

现在剩下的就是显示一个描述 FAB 用途的提示。我们通过_getHint如下方法实现:

Positioned _getHint() {
  return Positioned(
    bottom: 26,
    right: 96,
    child: Container(
      padding: EdgeInsets.all(8),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.all(
          Radius.circular(4)
        )
      ),
      child: Row(
        children: [
          Text("You can add news pages with a tap"),
          Padding(
            padding: EdgeInsets.only(left: 8),
            child: Icon(Icons.arrow_forward, color: Colors.black54,)
          )
        ]
      ),
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

我们在本教程中学习了:有很多方法可以阻止 Flutter 显示 Widget 的某些部分。我已经介绍了涉及ClipPathCustomPainter和 的方法ColorFiltered。根据个人喜好和用例,可能会使用某个 Widget。

完整代码可以在我的gist中查看。

文章来源:https://dev.to/flutterclutter/flutter-how-to-cut-a-hole-in-an-overlay-a0
PREV
React/js 实用技巧和窍门 - 第一部分
NEXT
理解 JavaScript 中的绑定、调用和应用