Flutter: How to cut a hole in an overlay

flutterclutter

flutter-clutter

Posted on June 28, 2020

Flutter: How to cut a hole in an overlay

In many apps it's common to have a short tutorial that guides the user through the basic functions of the app. There you often find a semi-transparent overlay that covers everything except the part of the app that is being explained and which you are supposed to interact with.

There are several approaches to achieve this. Let's look at them.

Our goal

What we're aiming for

Let that be our goal: an app with a screen in the background (in this case a ListView) and a FloatingActionButton on top that is enclosed by a half-transparent overlay and has an explanation text next to it.

The concept

To approach the solution, let's think of the concept behind this:

A perspective illustration of our widget stack

We want to have different layers stacked on top of each other:

  • At the bottom we have the screen that is to be explained
  • On top of that there is the overlay that defocusses most of the UI elements
  • The second-hightest level is occupied by an explanation text
  • The uppermost layer contains everything that is out of our control (for now) like the FAB, the app bar, the status bar and the OS controls

Note that this is only the case for FABs. If we were to explain a FlatButton or a TextInput in our tutorial, we'd have this element on the lowest layer as well. FABs are drawn onto an overlay that is not part of the usual canvas.
For now, that's irrelevant because that's where we put our hole anyways. So from a top-down perspective it's impossible to say where the button is positioned on the z-axis.

Implementation

Flutter provides a horizontal stack (called Row), a vertical Stack (called Column) and a stack in z-direction called Stack.
Let's use it to bring the above mentioned concept to life.

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

That's the main widget.

  • _getContent() is supposed to return whatever content we want to cover (in the above mentioned example it's the ListView)
  • _getOverlay() returns the half-transparent overlay that has a hole in the bottom right corner
  • _getHint() is responsible for returning the hint that labels the button

The FAB is part of the parent Scaffold, that's why it's not inside the Stack widget.

The overlay

The trickiest part is the _getOverlay() method. There are multiple possibilites I want to introduce.

ClipPath

The docs say:

Calls a callback on a delegate whenever the widget is to be painted. The callback returns a path and the widget prevents the child from painting outside the path.

So basically we have a shape that is to be painted and then a ClipPath that definies where to draw. There is only one problem: we want the opposite. We want to declare an area in which the overlay is not drawn.
But before we handle that problem, let's see what the ClipPath should generally look like.
In order to create a custom clipper, we need to extend from the class CustomClipper<T> and in our case set the type to Path because we want a more complex shape (inverted oval inside a rectangle). There are predefined basic shapes as well. These can be used when the clipping is simple (e. g. oval or rectangle with rounded corners) because it's less code to write and the performance is a little bit better.

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

We are required to override the following two methods:

  • getClip: the input is the size of the RenderBox and the required output is a Path representing the space inside the given RenderBox that is to be drawn
  • shouldReclip: this method is called when there is new instance of the Path object. Input is the old version of the clipper. By the boolean output you decide whether the clipping is performed again. Actually, for development purpose we return true because we want hot reload to work. In release version I recommend to return false because of performance reasons and the clipper is static

Let's fill the getClip method with an actual path.

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

Depending on your level of understanding, there might be a bit to be explained.
First, something about the syntax: I used the two dots, which is called cascade notation in Dart. This is just syntactic sugar in case you have a sequence of method calls on the same class instance.

We add two shapes to our path: a Rect and an Oval. The rectangle has the size of the whole RenderBox as it represents the whole overlay. The oval has the size of the FAB (plus a little padding) since it's meant to be the "hole" in the overlay.

The last line is actually crucial. If we omitted it, we would see the overlay but not the hole. But why is that? Remember, everything inside the path is drawn, everything outside the path is not. So we must create a path in which the oval is considered outside whereas the surrounding rectangle is considered inside.

The PathFillType determines exactly that. Per default the fillType is set to PathFillType.nonZero. In that case, a given point is considered inside the path if:

a line drawn from the point to infinity crosses lines going clockwise around the point a different number of times than it crosses lines going counter-clockwise around that point

evenOdd sees it as inside when:

a line drawn from the point to infinity crosses an odd number of lines

Since our oval does not cross any lines and zero is considered an even number, the oval is seen as outside.

If you want to go deeper, you can find out more about the algorithms on Wikipedia: here and here.

This is not so easy to understand so I have a simpler apporach for you: instead of working with subpaths and manipulating the PathFillType we just draw the surrounding rectangle and then substract the oval:

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

I don't know about performance, but in terms of readability and clarity, I would say that the latter approach is better, but that might be only my personal view.
The only thing left to do is to decide which widget to clip. In this case we want a screen-filling container that is half-transparent.

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

CustomPainter

Using a ClipPath we take a semi-transparent overlay and decide to draw everything except for a hole. How about the alternative of using a CustomPainter to only draw what we want?
The good thing: we can reuse almost every part of the above code so I will instantly show the result.

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

But wait, if we compile that, don't see an overlay. Why is that?
Well, if a CustomPaint's child property (which defines everything being drawn underneath) is not set, the size defaults to zero. In this case we have to set the size property manually like this: size: MediaQuery.of(context).size.

Comparing the two approaches

ClipPath

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

CustomPainter

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

In terms of LOC the clipper approach is ahead. Regarding readability I prefer the CustomPainter way. Anyways, both ways produce the exact same result:

Looking great

Note that if you still want underlying widgets to receive gesture events of the user, you need to wrap everything inside an IgnorePointer.

ColorFiltered

There is a third approach I want to introduce. The interesting thing about it is that you don't have to interact with Paint at all. You can just stay in the widget tree. Sounds promosing doesn't it?
What we're going to use is the ColorFiltered widget. Like the name implies, this widget applies a ColorFilter to its child.

A color filter is a function that takes two colors, and outputs one color

The two colors are firstly the one you specify (the ColorFilter.mode constructor has a color property) and secondly the color of the respective pixel of the child that is specified. As BlendMode we use BlendMode.srcOut. It has the following effect:

Show the source image, but only where the two images do not overlap. The destination image is not rendered, it is treated merely as a mask. The color channels of the destination are ignored, only the opacity has an effect

Source image in our case is the color Colors.black54 and destination is whatever we provide as the child argument. So basically a half-transparent overlay is drawn and every pixel with opacity greater than zero in the child widget produces a hole because the source image is not drawn where they overlap. Essentially we have an alpha mask now.

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

We let the overlay be drawn by using a Container with a transparent background color. There is no overlapping pixel so Colors.black54 is drawn on the whole screen. We create the hole by putting a oval-shaped Container into the other Container. It's important that this widget has a non-transparent background color as this produces an overlap which then causes the mask not to draw the shape of it.
A effect is that we can put whatever widget into that Container which will lead to a hole. This can be text, images or anything else.

Overlay built using ColorFiltered widget

The text hint

Now what's left is to show a hint that describes the purpose of the FAB. We do that by implementing the _getHint method as follows:

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

What we have learned in this tutorial: there are numerous ways to prevent Flutter from displaying parts of a widget. I have introduced ways involving ClipPath, CustomPainter and ColorFiltered. Depending on the personal preferences and the use case one or another widget might.

The complete code can be viewed in my gist.

πŸ’– πŸ’ͺ πŸ™… 🚩
flutterclutter
flutter-clutter

Posted on June 28, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related