Marching Ants Effect in Flutter
Some of you might find yourself in a situation where you have to enable your users the selection of specific lines, paths or shapes within your Flutter application. When you think about how to highlight them, you could just increase the stroke thickness or change the color of the selection. But in some situations this might be not enough to make the selection standing out to the user. Especially in situations where multiple lines and shapes are intersecting. What about animating the selection?
Graphics tools nowadays use kind of a moving stroked line animation effect, also called “marching ants” to help the user keeping track of his selection. In this article, I’m going to show you a way how to implement that effect in Flutter.
1. CustomPaint & CustomPainter
Let’s start with the basics of how to draw custom graphics in Flutter. To achieve that, we can use the CustomPaint widget in combination with a CustomPainter. While CustomPainter is the component which helps you to to draw your graphics directly to a canvas, the CustomPaint is just a wrapper around it which delegates the drawing of the widget to your CustomPainter and can be mounted in your widget tree.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CustomPaint(
painter: MyCustomPaint(),
child: Center(
child: Container(
width: 20,
height: 20,
color: Colors.blue,
),
),
),
);
}
}
class MyCustomPaint extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..color = Colors.red);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
In the code above you will see a simple CustomPainter that draws a red coloured rectangle over the whole canvas. The painter method of our CustomPainter will be called on every setState if shouldRepaint returns true. The shouldRepaint method indicates whether the CustomPainter needs to be repainted or not when a new instance is created. This helps the framework to optimise the painting.
The parameter of type canvas provides you with methods to draw your geometry while size indicates the size of the drawing area. This area starts at the left top of your screen where the coordinate XY (0, 0) is located. The paint parameter of those drawing methods lets you define how the different graphics are drawn in terms of color and style. Throughout the shouldRepaint method you should indicate whether the CustomPainter needs to be repainted or not when a new instance is created. Changed member values of CustomPainter could lead to a need for a repaint for example. This mechanism helps to optimise performance by the framework.
To use our own CustomPainter within a widget tree, we need to instantiate it and hand over an instance of CustomPainter for the painter argument. CustomPaint also takes a child parameter. This child is always drawn above the painter as you can see with the blue rectangle in the center in the image below.
2. Draw Paths
Since we don’t want to draw just simple rectangles or lines, we need to deal with the concept of drawing paths. Paths are made of multiple segments and enable us to draw complex shapes on our canvas, like polygons, for example. Every path starts with an instantiation of a path object. The starting point of a path segment can be set by calling the void moveTo(double x, double y) method. In case we want to connect this point to another one with a line segment, we need to call the void lineTo(double x, double y) method of the path. We can repeat that process as much as we like to create a more complex path. After we have finished defining our path, we need to call its close method. Finally, we can draw it by using the drawPath method of the canvas instance.
import 'package:flutter/material.dart';
import 'dart:math' as math;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CustomPaint(
painter: MyCustomPaint([
const Offset(100, 100),
const Offset(200, 100),
const Offset(200, 200),
const Offset(300, 200),
], Colors.red, 40.0),
),
);
}
}
class MyCustomPaint extends CustomPainter {
final List<Offset> points;
final Color color;
final double strokeWidth;
MyCustomPaint(this.points, this.color, this.strokeWidth)
: assert(points.length > 1, 'At least two points are needed.');
@override
void paint(Canvas canvas, Size size) {
final pPaint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final p = Path();
var aX = points.first.dx;
var aY = points.first.dy;
p.moveTo(aX, aY);
for (var i = 1; i < points.length; i++) {
var bX = points[i].dx;
var bY = points[i].dy;
final lenAB = math.sqrt(math.pow(aX - bX, 2.0) + math.pow(aY - bY, 2.0));
final bxExt = bX + (bX - aX) / lenAB * (strokeWidth / 2);
final byExt = bY + (bY - aY) / lenAB * (strokeWidth / 2);
// extend line by (strokewidth / 2) to overlap ends
p.lineTo(bxExt, byExt);
p.moveTo(bX, bY);
aX = bX;
aY = bY;
}
p.close();
canvas.drawPath(p, pPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
With the code above, we should be able to draw custom paths by providing points of type Offset to our CustomPainter.
The part where we extend the end point (B) of a segment by the half of strokeWidth when calling void lineTo(double x, double y) is just optional. It prevents unsightly path segment endings as you can see below.
3. Stroked Paths
Since Flutter doesn’t support stroked paths natively at the moment, we have to write our own method to convert normal paths into stroked ones.
Path _dashPath(
Path source,
List<double> dash, {
double offset = 0.5,
}) {
final dest = Path();
for (final metric in source.computeMetrics()) {
var distance = offset;
var draw = true;
for (var i = 0; distance < metric.length; i = (i + 1) % dash.length) {
final len = dash[i];
if (draw) {
dest.addPath(
metric.extractPath(distance, distance + len), Offset.zero);
}
distance += len;
draw = !draw;
}
}
return dest;
}
The _dashPath method above allows us to do so. It splits up the source path into its segments where for each segment a dashed equivalent according to the specified dash pattern and offset is created. Finally, the resulting stroked path will be returned. Run the code below to see the method in action.
import 'package:flutter/material.dart';
import 'dart:math' as math;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CustomPaint(
painter: MyCustomPaint([
const Offset(100, 100),
const Offset(200, 100),
const Offset(200, 200),
const Offset(300, 200),
], Colors.red, 2.0),
),
);
}
}
class MyCustomPaint extends CustomPainter {
final List<Offset> points;
final Color color;
final double strokeWidth;
MyCustomPaint(this.points, this.color, this.strokeWidth)
: assert(points.length > 1, 'At least two points are needed.');
@override
void paint(Canvas canvas, Size size) {
final pPaint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final p = Path();
var aX = points.first.dx;
var aY = points.first.dy;
p.moveTo(aX, aY);
for (var i = 1; i < points.length; i++) {
var bX = points[i].dx;
var bY = points[i].dy;
final lenAB = math.sqrt(math.pow(aX - bX, 2.0) + math.pow(aY - bY, 2.0));
final bxExt = bX + (bX - aX) / lenAB * (strokeWidth / 2);
final byExt = bY + (bY - aY) / lenAB * (strokeWidth / 2);
// extend line by (strokewidth / 2) to overlap ends
p.lineTo(bxExt, byExt);
p.moveTo(bX, bY);
aX = bX;
aY = bY;
}
p.close();
canvas.drawPath(_dashPath(p, [10, 5]), pPaint);
}
Path _dashPath(
Path source,
List<double> dash, {
double offset = 0.5,
}) {
final dest = Path();
for (final metric in source.computeMetrics()) {
var distance = offset;
var draw = true;
for (var i = 0; distance < metric.length; i = (i + 1) % dash.length) {
final len = dash[i];
if (draw) {
dest.addPath(
metric.extractPath(distance, distance + len), Offset.zero);
}
distance += len;
draw = !draw;
}
}
return dest;
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
We now should be able to create a stroked path as seen below.
4. Animate the Path
Last but not least, we need to move our strokes, respectively make our ants march. For the sake of reusability and readability, we wrap up everything into a widget.
import 'package:flutter/material.dart';
import 'dashed_path_painter.dart';
class MarchingAntsPathWidget extends StatefulWidget {
final List<Offset> points;
final Duration duration;
final double dashWidth;
final double dashSpace;
final double strokeWidth;
final Color strokeColor;
const MarchingAntsPathWidget(
{required this.points,
this.dashWidth = 10.0,
this.dashSpace = 5.0,
this.strokeWidth = 2.0,
this.strokeColor = Colors.black,
this.duration = const Duration(milliseconds: 500),
super.key});
@override
State<MarchingAntsPathWidget> createState() => _MarchingAntsPathWidgetState();
}
class _MarchingAntsPathWidgetState extends State<MarchingAntsPathWidget>
with SingleTickerProviderStateMixin {
late final Animation<double> _animation;
late final AnimationController _controller;
late final Tween<double> _tween;
@override
void initState() {
super.initState();
_tween =
Tween<double>(begin: 0.0, end: widget.dashWidth + widget.dashSpace);
_controller = AnimationController(
vsync: this,
duration: widget.duration,
);
_animation = _tween.animate(_controller)
..addListener(() {
setState(() {});
})
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_controller.reset();
} else if (status == AnimationStatus.dismissed) {
_controller.forward();
}
});
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: DashedPathPainter(
widget.points,
_animation.value,
widget.dashWidth,
widget.dashSpace,
widget.strokeColor,
widget.strokeWidth,
),
);
}
}
Our animation is initialised inside the initState method of the newly created widget. An AnimationController is used together with a Tween<double> whose value starts at 0.0 and ends at the sum of the width of a single dash stroke and the space between each dash stroke.
The animation value of the double tween we have just created we are going to use as the parameter of our CustomPainter’s _dashPath function we have implemented previously. You may remember that it had an offset parameter to shift the strokes along the path segments.
If we play that in an endless loop, it looks like the strokes a.k.a. ants are marching indefinitely along the path. To update the UI on every animation change we need to add a listener to our tween animation and call setState on call. By adding a status listener to the animation we reset and replay the animation cycle over and over again until the AnimationController gets disposed. Now, our CustomPainter class should look like below.
import 'package:flutter/material.dart';
import 'dart:math' as math;
class DashedPathPainter extends CustomPainter {
final List<Offset> points;
final double offset;
final double dashWidth;
final double dashSpace;
final Color color;
final double strokeWidth;
DashedPathPainter(this.points, this.offset, this.dashWidth, this.dashSpace,
this.color, this.strokeWidth)
: assert(points.length > 1, 'At least two points are needed.');
@override
void paint(Canvas canvas, Size size) {
final pPaint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final p = Path();
var aX = points.first.dx;
var aY = points.first.dy;
p.moveTo(aX, aY);
for (var i = 1; i < points.length; i++) {
var bX = points[i].dx;
var bY = points[i].dy;
final lenAB = math.sqrt(math.pow(aX - bX, 2.0) + math.pow(aY - bY, 2.0));
final bxExt = bX + (bX - aX) / lenAB * (strokeWidth / 2);
final byExt = bY + (bY - aY) / lenAB * (strokeWidth / 2);
// extend line by (strokewidth / 2) to overlap ends
p.lineTo(bxExt, byExt);
p.moveTo(bX, bY);
aX = bX;
aY = bY;
}
p.close();
canvas.drawPath(
_dashPath(p, [dashWidth, dashSpace], offset: offset), pPaint);
}
Path _dashPath(
Path source,
List<double> dash, {
double offset = 0.5,
}) {
final dest = Path();
for (final metric in source.computeMetrics()) {
var distance = offset;
var draw = true;
for (var i = 0; distance < metric.length; i = (i + 1) % dash.length) {
final len = dash[i];
if (draw) {
dest.addPath(
metric.extractPath(distance, distance + len), Offset.zero);
}
distance += len;
draw = !draw;
}
}
return dest;
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
If you have put everything together in the right way, the result should look similar to this.
Summary
Thanks for reading the article. If you have any ideas for improvement or any questions, let me know in the comments. The full source code with some small extensions you will find on my GitHub.