Issue
How can animate the border of a widget from 0 to 100% (similar to the Trim Path effects one can create in Adobe AfterEffects)? I want to apply this to widgets that have a rectangular or rounded rectangle shape.
Here's an example of the effect I am trying to achieve:
Solution
The big picture:
Let's wrap the widget in a CustomPaint
. Since the CustomPaint
takes its child's size, we don't have to worry about painting at the correct position.
We can further take this wonderful answer for generic path animations as a starting point and tweak the code so that our AnimatedBorderPainter
can paint paths for rectangles, rounded rectangles and circles.
Finally, we create an AnimationController
and define the duration, curve and all other properties that we need.
The details:
In the paint
method of the AnimatedBorderPainter
we first create the _originalPath
(i.e. the complete path) when the animation starts and then subsequently (re)draw the currentPath
based on the animation's progress. The _createAnimatedPath
method is taken from the above-mentioned answer where it is described in greater detail.
late Path _originalPath;
late Paint _paint;
@override
void paint(Canvas canvas, Size size) {
final animationPercent = _animation.value;
// Construct original path once when animation starts
if (animationPercent == 0.0) {
_originalPath = _createOriginalPath(size);
_paint = Paint()
..strokeWidth = _strokeWidth
..style = PaintingStyle.stroke
..color = _strokeColor;
}
final currentPath = _createAnimatedPath(
_originalPath,
animationPercent,
);
canvas.drawPath(currentPath, _paint);
}
Let's focus on creating the original path for our shapes. We can use addRect
(rectangle), addRRect
(rounded rectangle) and addOval
(circle) to create the respective shapes:
Path _createOriginalPath(Size size) {
switch (_pathType) {
case PathType.rect:
return _createOriginalPathRect(size);
case PathType.rRect:
return _createOriginalPathRRect(size);
case PathType.circle:
return _createOriginalPathCircle(size);
}
}
Path _createOriginalPathRect(Size size) {
Path originalPath = Path()
..addRect(
Rect.fromLTWH(0, 0, size.width, size.height),
)
..lineTo(0, -(_strokeWidth / 2));
if (_startingPercentage > 0 && _startingPercentage < 100) {
return _createPathForStartingPercentage(
originalPath, PathType.rect, size);
}
return originalPath;
}
Path _createOriginalPathRRect(Size size) {
Path originalPath = Path()
..addRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
_radius,
),
);
if (_startingPercentage > 0 && _startingPercentage < 100) {
return _createPathForStartingPercentage(originalPath, PathType.rRect);
}
return originalPath;
}
Path _createOriginalPathCircle(Size size) {
Path originalPath = Path()
..addOval(
Rect.fromLTWH(0, 0, size.width, size.height),
);
if (_startingPercentage > 0 && _startingPercentage < 100) {
return _createPathForStartingPercentage(originalPath, PathType.circle);
}
return originalPath;
}
Since we also want to define where our path animation starts (using the startingPercentage
parameter), we have to cut and rejoin our originally constructed path based on the input:
Path _createPathForStartingPercentage(Path originalPath, PathType pathType,
[Size? size]) {
// Assumes that original path consists of one subpath only
final pathMetrics = originalPath.computeMetrics().first;
final pathCutoffPoint = (_startingPercentage / 100) * pathMetrics.length;
final firstSubPath = pathMetrics.extractPath(0, pathCutoffPoint);
final secondSubPath =
pathMetrics.extractPath(pathCutoffPoint, pathMetrics.length);
if (pathType == PathType.rect) {
Path path = Path()
..addPath(secondSubPath, Offset.zero)
..lineTo(0, -(_strokeWidth / 2))
..addPath(firstSubPath, Offset.zero);
switch (_startingPercentage) {
case 25:
path.lineTo(size!.width + _strokeWidth / 2, 0);
break;
case 50:
path.lineTo(size!.width - _strokeWidth / 2, size.height);
break;
case 75:
path.lineTo(0, size!.height + _strokeWidth / 2);
break;
default:
}
return path;
}
return Path()
..addPath(secondSubPath, Offset.zero)
..addPath(firstSubPath, Offset.zero);
}
Although there is still a little more to it (see full code below), we can then basically use our AnimatedBorderPainter
as follows, defining things like the startingPercentage
, the animationDirection
and the radius
(the latter being only relevant for rounded rectangles):
CustomPaint(
foregroundPainter: AnimatedBorderPainter(
animation: _controller1,
strokeColor: Colors.black,
pathType: PathType.rRect,
animationDirection: AnimationDirection.clockwise,
startingPercentage: 40,
radius: const Radius.circular(12),
),
child: ElevatedButton(
child: const Text('Click me also!'),
onPressed: _startAnimation1,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
Full code including example animations you can run in DartPad:
import 'dart:ui';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Border Animation',
home: Scaffold(body: ExampleAnimatedBorderPainter()));
}
}
// Example code including two animations
class ExampleAnimatedBorderPainter extends StatefulWidget {
@override
State<ExampleAnimatedBorderPainter> createState() =>
_ExampleAnimatedBorderPainterState();
}
class _ExampleAnimatedBorderPainterState
extends State<ExampleAnimatedBorderPainter> with TickerProviderStateMixin {
late AnimationController _controller1;
late AnimationController _controller2;
@override
void initState() {
super.initState();
_controller1 = AnimationController(
vsync: this,
duration: const Duration(
milliseconds: 2000,
),
);
_controller2 = AnimationController(
vsync: this,
duration: const Duration(
milliseconds: 1500,
),
);
}
@override
void dispose() {
_controller1.dispose();
_controller2.dispose();
super.dispose();
}
void _startAnimation1() {
_controller1.reset();
_controller1.animateTo(1.0, curve: Curves.easeInOut);
}
void _startAnimation2() {
_controller2.reset();
_controller2.animateTo(1.0, curve: Curves.easeInOut);
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CustomPaint(
foregroundPainter: AnimatedBorderPainter(
animation: _controller1,
strokeColor: Colors.black,
pathType: PathType.rRect,
animationDirection: AnimationDirection.clockwise,
startingPercentage: 40,
radius: const Radius.circular(12),
),
child: ElevatedButton(
child: const Text('Click me also!'),
onPressed: _startAnimation1,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(
height: 20,
),
CustomPaint(
foregroundPainter: AnimatedBorderPainter(
animation: _controller2,
strokeColor: Colors.deepOrange,
pathType: PathType.rRect,
animationDirection: AnimationDirection.counterclockwise,
),
child: ElevatedButton(
child: const Text('Click me also!'),
onPressed: _startAnimation2,
),
),
],
),
);
}
}
class AnimatedBorderPainter extends CustomPainter {
final Animation<double> _animation;
final PathType _pathType;
final double _strokeWidth;
final Color _strokeColor;
final Radius _radius;
final int _startingPercentage;
final AnimationDirection _animationDirection;
AnimatedBorderPainter({
required animation,
PathType pathType = PathType.rect,
double strokeWidth = 2.0,
Color strokeColor = Colors.blueGrey,
Radius radius = const Radius.circular(4.0),
int startingPercentage = 0,
AnimationDirection animationDirection = AnimationDirection.clockwise,
}) : assert(strokeWidth > 0, 'strokeWidth must be greater than 0.'),
assert(startingPercentage >= 0 && startingPercentage <= 100,
'startingPercentage must lie between 0 and 100.'),
_animation = animation,
_pathType = pathType,
_strokeWidth = strokeWidth,
_strokeColor = strokeColor,
_radius = radius,
_startingPercentage = startingPercentage,
_animationDirection = animationDirection,
super(repaint: animation);
late Path _originalPath;
late Paint _paint;
@override
void paint(Canvas canvas, Size size) {
final animationPercent = _animation.value;
// Construct original path once when animation starts
if (animationPercent == 0.0) {
_originalPath = _createOriginalPath(size);
_paint = Paint()
..strokeWidth = _strokeWidth
..style = PaintingStyle.stroke
..color = _strokeColor;
}
final currentPath = _createAnimatedPath(
_originalPath,
animationPercent,
);
canvas.drawPath(currentPath, _paint);
}
@override
bool shouldRepaint(AnimatedBorderPainter oldDelegate) => true;
Path _createOriginalPath(Size size) {
switch (_pathType) {
case PathType.rect:
return _createOriginalPathRect(size);
case PathType.rRect:
return _createOriginalPathRRect(size);
case PathType.circle:
return _createOriginalPathCircle(size);
}
}
Path _createOriginalPathRect(Size size) {
Path originalPath = Path()
..addRect(
Rect.fromLTWH(0, 0, size.width, size.height),
)
..lineTo(0, -(_strokeWidth / 2));
if (_startingPercentage > 0 && _startingPercentage < 100) {
return _createPathForStartingPercentage(
originalPath, PathType.rect, size);
}
return originalPath;
}
Path _createOriginalPathRRect(Size size) {
Path originalPath = Path()
..addRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
_radius,
),
);
if (_startingPercentage > 0 && _startingPercentage < 100) {
return _createPathForStartingPercentage(originalPath, PathType.rRect);
}
return originalPath;
}
Path _createOriginalPathCircle(Size size) {
Path originalPath = Path()
..addOval(
Rect.fromLTWH(0, 0, size.width, size.height),
);
if (_startingPercentage > 0 && _startingPercentage < 100) {
return _createPathForStartingPercentage(originalPath, PathType.circle);
}
return originalPath;
}
Path _createPathForStartingPercentage(Path originalPath, PathType pathType,
[Size? size]) {
// Assumes that original path consists of one subpath only
final pathMetrics = originalPath.computeMetrics().first;
final pathCutoffPoint = (_startingPercentage / 100) * pathMetrics.length;
final firstSubPath = pathMetrics.extractPath(0, pathCutoffPoint);
final secondSubPath =
pathMetrics.extractPath(pathCutoffPoint, pathMetrics.length);
if (pathType == PathType.rect) {
Path path = Path()
..addPath(secondSubPath, Offset.zero)
..lineTo(0, -(_strokeWidth / 2))
..addPath(firstSubPath, Offset.zero);
switch (_startingPercentage) {
case 25:
path.lineTo(size!.width + _strokeWidth / 2, 0);
break;
case 50:
path.lineTo(size!.width - _strokeWidth / 2, size.height);
break;
case 75:
path.lineTo(0, size!.height + _strokeWidth / 2);
break;
default:
}
return path;
}
return Path()
..addPath(secondSubPath, Offset.zero)
..addPath(firstSubPath, Offset.zero);
}
Path _createAnimatedPath(
Path originalPath,
double animationPercent,
) {
// ComputeMetrics can only be iterated once!
final totalLength = originalPath
.computeMetrics()
.fold(0.0, (double prev, PathMetric metric) => prev + metric.length);
final currentLength = totalLength * animationPercent;
return _extractPathUntilLength(originalPath, currentLength);
}
Path _extractPathUntilLength(
Path originalPath,
double length,
) {
var currentLength = 0.0;
final path = Path();
var metricsIterator = _animationDirection == AnimationDirection.clockwise
? originalPath.computeMetrics().iterator
: originalPath.computeMetrics().toList().reversed.iterator;
while (metricsIterator.moveNext()) {
var metric = metricsIterator.current;
var nextLength = currentLength + metric.length;
final isLastSegment = nextLength > length;
if (isLastSegment) {
final remainingLength = length - currentLength;
final pathSegment = _animationDirection == AnimationDirection.clockwise
? metric.extractPath(0.0, remainingLength)
: metric.extractPath(
metric.length - remainingLength, metric.length);
path.addPath(pathSegment, Offset.zero);
break;
} else {
// There might be a more efficient way of extracting an entire path
final pathSegment = metric.extractPath(0.0, metric.length);
path.addPath(pathSegment, Offset.zero);
}
currentLength = nextLength;
}
return path;
}
}
enum PathType {
rect,
rRect,
circle,
}
enum AnimationDirection {
clockwise,
counterclockwise,
}
Answered By - hnnngwdlch
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.