“Camera Aperture Animation” - Flutter ft. CustomPainter, AnimatedBuilder, & ClipOval

Alfonso Cejudo
Jul 18, 2019 · 7 min read
Screen recording of the widget animating.
Screen recording of the widget animating.
The Aperture widget in action

Today the Flutter Mixtape series keeps the hits coming at 60 frames per second. [Hip hop airhorn sound effect]

Some people may find programming animations daunting; things will seemingly just fly off in any direction whenever they feel like, leaving you alone and wounded, like a wolf injured in a territorial battle, now left behind by her pack. I’ve seen it a hundred times. This article aims to show you that Flutter has recognized any pain points you’ve experienced in the past and now gives us easy-to-use tools so you can instead focus on unleashing your creativity.

So fear not this aperture animation, for it is actually deceptively simple:

  1. Draw a triangle in a custom class that utilizes a Canvas.
  2. Position six instances of the triangle in a Stack.
  3. With one AnimationController, run SlideTransitions on all six triangles simultaneously, each one moving in a deliberate direction.
  4. Mask the triangle Stack into a circular shape with a ClipOval.
  5. Place the final Aperture widget in a SizedBox.

If you’ve legit worked with all of these before, you can compare your answer with the final code: https://github.com/alfonsocejudo/aperture_demo. Now take your hackathon trophy and get out of here. For everyone else… strap in.

Step 1.

We begin with the triangle. But what is a triangle? No one knows. Scholars maintain that you can draw one fairly easily on a Flutter Canvas. This drawing will become one of six blades of our aperture — the thing in a camera lens that opens to varying degrees so that light can enter and hit the photographic film or image sensor.

For this widget, we’re going to need an equilateral triangle (all sides an equal length, as per, uhh, childhood). Notice that the triangle in the screenshot doesn’t extend upwards to fill the debug square. If it did, the left and right sides would be longer than the bottom, making it isosceles. Anypants, create a class that extends CustomPainter so we can draw a Path that travels through the three corners of our triangle.

I made a triangle with the border Paint, then inscribed a smaller triangle with the fill Paint. I’m wondering now if I could’ve just used some stroke paint for the border in order to simply have one triangle. Maybe you can try that optimization if you want some homework.

Speaking of homework, what’s going on with those maths and pows and sqrts? Well, an equilateral triangle is actually just two right triangles back to back, and the hypotenuse of one of the right triangles must equal size.width, so the height is then just b from the equation + = that you cherished growing up. Nerd.

Step 2.

Screenshot of six triangles in position.
Screenshot of six triangles in position.
From one, six

Now delete triangle and replace with umbrella. SIKE. It’s actually just six of the thing you just created rofl. You can achieve this pattern with Positioned widgets as children of a Stack:

Positioned(
left: centerX + (boxWidth / 2),
top: centerY + heightDelta,
child: FittedBox(
fit: BoxFit.none,
child: SizedBox(
width: boxWidth,
height: boxWidth,
child: CustomPaint(
painter: ApertureBladePainter(
borderWidth: bladeBorderWidth,
rotationRadians: math.pi),
),
),
),
...Positioned(
left: centerX - boxWidth + bladeBorderWidth,
top: centerY - heightDelta - bladeBorderWidth + (bladeBorderWidth / 2),
child: FittedBox(
fit: BoxFit.none,
child: SizedBox(
width: boxWidth,
height: boxWidth,
child: CustomPaint(
painter:
ApertureBladePainter(borderWidth: bladeBorderWidth),
),
),
),

If it looks convoluted, just know that you have to take into account the fact that the triangle heights are shorter than those of the SizedBoxes (because of maths, remember?) and we also want want the triangles to overlap along their borders (and not have borders adjacent to each other, where the triangles then appear to be double in thickness).

Also, because we need three of the triangles to be flipped upside down, ApertureBladePainter now takes an optional rotationRadians parameter so that our Canvas can rotate 180° before drawing the paths.

Step 3.

With our triangles in place, we can now tell each of them to simultaneously slide in distinct, purposeful directions, creating an opening for the lens through which light can pass. Take it one at a time, starting with the upside down triangle at 12 o’clock. That sucker’s just moving straight to the right. The next one counterclockwise is headed southeast, and the following southwest. The remaining three are doing the exact opposite of the previous three. With all this in mind, let’s wrap each of the aperture blades with an Animation<Offset>.

static const open1 = 0.78;
static const open2 = 0.33;
Animation<Offset> _slide1;
...
Animation<Offset> _slide6;
_slide1 = Tween(begin: Offset(0.0, 0.0), end: Offset(open1, 0.0))
.animate(animationController);
_slide2 = Tween(begin: Offset(0.0, 0.0), end: Offset(open2, open1))
.animate(animationController);
_slide3 = Tween(begin: Offset(0.0, 0.0), end: Offset(-open2, open1))
.animate(animationController);
_slide4 = Tween(begin: Offset(0.0, 0.0), end: Offset(-open1, 0.0))
.animate(animationController);
_slide5 = Tween(begin: Offset(0.0, 0.0), end: Offset(-open2, -open1))
.animate(animationController);
_slide6 = Tween(begin: Offset(0.0, 0.0), end: Offset(open2, -open1))
.animate(animationController);

The Offset class describes a delta in terms of the widget’s size that it is offsetting. Taking an example in the context of a Tween animation, a value of Offset(1.0, 1.0) means a position that is one full widget’s width away in the x direction and one full widget’s height away in the y direction. Conversely, Offset(0.0, 0.0) means leaving the triangle exactly where you placed it with Positioned, which is why all six Tweens start with zeros, when the aperture is fully closed. I experimented with the numbers and found that the open1 and open2 values above work well for the aperture in the fully open position.

A gif of the rapper Offset.
A gif of the rapper Offset.
Offset

The triangles can now be wrapped in AnimationBuilder widgets so they know to be redrawn in updated positions as the Tween values go from 0.0 to open1/open2.

Positioned(
left: centerX + (boxWidth / 2),
top: centerY + heightDelta,
child: AnimatedBuilder(
animation: animationController,
child: FittedBox(
fit: BoxFit.none,
child: SizedBox(
width: boxWidth,
height: boxWidth,
child: CustomPaint(
painter: ApertureBladePainter(
borderWidth: apertureBorderWidth,
rotationRadians: math.pi),
),
),
),
builder: (context, child) => SlideTransition(
position: _slide1,
child: child,
),
),
),
...

Step 4.

Assemble your triangles into one widget so they can be wrapped inside an overarching Aperture widget one level up. This is where they’ll be masked by a circle so that they’ll finally look as if they’re inside of a lens.

Masking in Flutter can be accomplished with various widgets prefixed with Clip. Naturally, we’ll want to use a ClipOval and set as the child the newly-animating ApertureBlades widget. A bonus step is to set the clip inside a Stack and have an optional child for the Aperture that appears as the blades are opening (the Aperture must be positioned above the child, i.e. after the child’s definition in the Stack).

Stack(
alignment: Alignment.center,
children: <Widget>[
if (child != null)
ClipOval(
clipBehavior: Clip.antiAlias,
child: Container(
alignment: Alignment.center,
child: child,
),
),
ClipOval(
clipBehavior: Clip.antiAlias,
child: ApertureBlades(),
),
],
);

Last step.

Tekken fight animation.
Tekken fight animation.

We’ll go yet another level up so that we can take our new Aperture widget and actually use it in a Scaffold app. To give the Aperture a position on the screen as well as an actual size, we can use a SizedBox of a width and height of your choosing, and place it within a layout widget also in accordance with your fanciful whims. Furthermore, use of a SizedBox now means we can enhance the build methods of Aperture and ApertureBlades with a LayoutBuilder so that the sizes and coordinates can trickle down to the triangles and everything scales in harmonious glory.

Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Stack(
alignment: Alignment.center,
children: <Widget>[
Container(
color: Colors.black,
),
Image.asset(
'images/in_the_background.png',
fit: BoxFit.contain,
),
SizedBox(
width: 300.0,
height: 300.0,
child: Aperture(
child: Image.asset('images/in_the_lens.png'),
),
),
],
),
));

Anything we put “behind” the Aperture will be visible when the blades are not fully closed. If we pass a child, that too will be hidden by the blades and, additionally, won’t be seen outside of the bounds of the circular lens. Neato, gang.

Other fun stuff to add: Spice things up with a CurvedAnimation for the Tween instances. The sample repo (and the header gif of this article) shows off the Curves.easeInOutBack option.

With Flutter, you can take a simple shape that you first drew in preschool, multiply it to more shapes, combine them, and then animate them to come up with something that is objectively awesomer than the sum of its parts.

Hopefully, this article has inspired you to get creative with basic building blocks and turn them into something cool. I look forward to stealing it.

Aperture demo app repo: https://github.com/alfonsocejudo/aperture_demo

Jet Set Digital

…and you will know us by the trail of read.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store