Hey there 😊

If you like what you read, feel free to …

🥗Buy me a salad
Share this 💬

How to cut a hole in an overlay

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

A list view with a half-transparent overlay on top with a hole where the fab is
That’s 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 a stack in Flutter
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.

1Stack(children: <Widget>[
2  _getContent(),
3  _getOverlay(),
4  _getHint()
5]);

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 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.

1class InvertedClipper extends CustomClipper<Path> {
2  @override
3  Path getClip(Size size) {
4    return new Path();
5  }
6
7  @override
8  bool shouldReclip(CustomClipper<Path> oldClipper) => true;
9}

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.

1return Path()
2  ..addRect(Rect.fromLTWH(0, 0, size.width, size.height))
3  ..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
4  ..fillType = PathFillType.evenOdd;

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:

1Path.combine(
2  PathOperation.difference,
3  Path()..addRect(
4      Rect.fromLTWH(0, 0, size.width, size.height)
5  ),
6  Path()
7    ..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
8    ..close(),
9);

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.

1Widget _getOverlay() {
2  return ClipPath(
3    clipper: InvertedClipper(),
4      child: Container(
5        color: Colors.black54,
6      ),
7  );
8}

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.

 1class OverlayWithHolePainter extends CustomPainter {
 2  @override
 3  void paint(Canvas canvas, Size size) {
 4    final paint = Paint()
 5      ..color = Colors.black54;
 6
 7    canvas.drawPath(
 8      Path.combine(
 9        PathOperation.difference,
10        Path()..addRect(
11          Rect.fromLTWH(0, 0, size.width, size.height)
12        ),
13        Path()
14          ..addOval(Rect.fromCircle(center: Offset(size.width -44, size.height - 44), radius: 40))
15          ..close(),
16      ),
17      paint
18    );
19  }
20
21  @override
22  bool shouldRepaint(CustomPainter oldDelegate) {
23    return false;
24  }
25}

If you understand what we have done with the CustomClipper then this should be easy to grasp. Instead of showing only parts of a given widget (the overlay), we have the overlay as a part of the drawing. That’s why the color Colors.black54 is part of the paint here.

1Widget _getOverlay(BuildContext context) {
2  return CustomPaint(
3    painter: HolePainter()
4  );
5}

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

 1Widget _getOverlay() {
 2  return ClipPath(
 3    clipper: InvertedClipper(),
 4      child: Container(
 5        color: Colors.black54,
 6      ),
 7  );
 8}
 9
10class InvertedClipper extends CustomClipper<Path> {
11  @override
12  Path getClip(Size size) {
13    
14  }
15
16  @override
17  bool shouldReclip(CustomClipper<Path> oldClipper) => true;
18}
 1Widget _getOverlay(BuildContext context) {
 2  return CustomPaint(
 3    size: MediaQuery.of(context).size,
 4    painter: HolePainter()
 5  );
 6}
 7
 8class OverlayWithHolePainter extends CustomPainter {
 9  @override
10  void paint(Canvas canvas, Size size) {
11    final paint = Paint()
12      ..color = Colors.black54;
13
14    canvas.drawPath(
15      ,
16      paint
17    );
18  }
19
20  @override
21  bool shouldRepaint(CustomPainter oldDelegate) {
22    return false;
23  }
24}

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

Flutter screen with overlay having a hole at the bottom right
ClipPath method
Flutter screen with overlay having a hole at the bottom right
CustomPainter method

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.

 1Widget _getOverlay() {
 2  return ColorFiltered(
 3    colorFilter: ColorFilter.mode(
 4      Colors.black54,
 5      BlendMode.srcOut
 6    ),
 7    child: Stack(
 8      children: [
 9        Container(
10          decoration: BoxDecoration(
11            color: Colors.transparent,
12          ),
13          child: Align(
14            alignment: Alignment.bottomRight,
15            child: Container(
16              margin: const EdgeInsets.only(right: 4, bottom: 4),
17              height: 80,
18              width: 80,
19              decoration: BoxDecoration(
20                color: Colors.black, // Color does not matter but should not be transparent
21                borderRadius: BorderRadius.circular(40),
22              ),
23            ),
24          ),
25        ),
26      ],
27    ),
28  );
29}

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.

Flutter overlay colorfiltered
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:

 1Positioned _getHint() {
 2  return Positioned(
 3    bottom: 26,
 4    right: 96,
 5    child: Container(
 6      padding: EdgeInsets.all(8),
 7      decoration: BoxDecoration(
 8        color: Colors.white,
 9        borderRadius: BorderRadius.all(
10          Radius.circular(4)
11        )
12      ),
13      child: Row(
14        children: [
15          Text("You can add news pages with a tap"),
16          Padding(
17            padding: EdgeInsets.only(left: 8),
18            child: Icon(Icons.arrow_forward, color: Colors.black54,)
19          )
20        ]
21      ),
22    )
23  );
24}

Summary

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 this gist:

Comments (13) ✍️

Santiago Rodríguez Matiz

Great post! Thanks for all the information you are sharing. I’ve learned so much from your post.
Reply to Santiago Rodríguez Matiz

Marc
In reply to Santiago Rodríguez Matiz's comment

You’re welcome! :)
Reply to Marc

Rishabh

Can we write on that hole …i mean what if i have to put a Text on top of that hole ?
Reply to Rishabh

Marc
In reply to Rishabh's comment

Sure. Everything you put on top of the layer that has the hole, will be display as usual. This includes every type of widget, also text.
Reply to Marc

Rishabh

In my use case i am using a transparent Container as a destination hole and that container is a Chat Bubble also so i want to show Text also in that container . But that text color is inhereting the Properties of the bottom most image or layer so the color is not what i want Colors.black or Colors.white wont work in Text widget . Blending value is deciding the color of my Text widget which is the child of my hole or destination Container . Please if you anything related to it you can tell . I can share you the links of Code pics and Simulator pic , if you want to understand it further .
Reply to Rishabh

Marc
In reply to Rishabh's comment

Hello Rishabh. You might as well send me the code and I will have a look at it :).
Reply to Marc

ian

DOH ! 1st option does not create the hole; i.e. fillType = PathFillType.evenOdd has no effect !

Summary - so far no way to create a hole in an overlay for web. Looking fwd to you cracking this one ;-)

Reply to ian

Marc
In reply to ian's comment

Does it also happen in the newest version of Flutter and Dart? This seems related and was fixed: https://github.com/flutter/flutter/issues/49534
Reply to Marc

Odyssey

Color filter approach save me, thanks! The others do not compatible with dynamic position elements such as custom localizations text bound with buttons (but mb thats wrong architecture).
Reply to Odyssey

Iqbal Husain

Hey Marc, Really loved the detailed explanation behind every solution and step. I just wanted to know if I wanted to add an animation, to show the transition from one tutorial to another, is there a way to implement it with your code or using a package is better in that scenario?
Reply to Iqbal Husain

Marc
In reply to Iqbal Husain's comment

Hey Iqbal,

Since animations in Flutter are very generic, I guess you could either use this tutorial and then animate it yourself or combine this with a more high-level animation package. You’re free to do both. But I tend to think that a package that offers this hole-transparency thing and the animations you want to have, does probably not exist, so you need to connect these two things by yourself. But I’m sure you’re capable of doing this 🙂.

Reply to Marc

Comment this 🤌

You are replying to 's commentRemove reference