I really love how Flutter animations can be used to improve usability.
Here's an example showing a Text
widget that shakes when some error occurs.
The Flutter animation APIs make it very easy to implement this. Here's a step-by-step guide. 👇
1. Create a custom sine curve
The effect is accomplished using an AnimationController
and a custom curve based on the sine function.
First of all, here's a SineCurve
that repeats the sine function count
times over a 2 * pi
period.
// 1. custom Curve subclass
class SineCurve extends Curve {
SineCurve({this.count = 3});
final double count;
// 2. override transformInternal() method
@override
double transformInternal(double t) {
return sin(count * 2 * pi * t);
}
}
Since
SineCurve
is aCurve
subclass, it can be given as an argument to any implicitly animated widget.
2. Abstract the AnimationController boilerplate code
We'll need an AnimationController
to get the effect we want. To reduce the boilerplate code, let's define a State
subclass:
abstract class AnimationControllerState<T extends StatefulWidget>
extends State<T> with SingleTickerProviderStateMixin {
AnimationControllerState(this.animationDuration);
final Duration animationDuration;
late final animationController = AnimationController(
vsync: this,
duration: animationDuration
);
@override
void dispose() {
animationController.dispose();
super.dispose();
}
}
For more details about this technique, see: How to reduce AnimationController boilerplate code: Flutter Hooks vs extending the State class
3. Create a custom ShakeWidget
Let's define a StatefulWidget
subclass that takes a child widget along with some customizable properties:
class ShakeWidget extends StatefulWidget {
const ShakeWidget({
Key? key,
required this.child,
required this.shakeOffset,
this.shakeCount = 3,
this.shakeDuration = const Duration(milliseconds: 500),
}) : super(key: key);
// 1. pass a child widget
final Widget child;
// 2. configurable properties
final double shakeOffset;
final int shakeCount;
final Duration shakeDuration;
// 3. pass the shakeDuration as an argument to ShakeWidgetState. See below.
@override
ShakeWidgetState createState() => ShakeWidgetState(shakeDuration);
}
4. Create a custom CurvedAnimation
Let's define the ShakeWidgetState
class with a custom animation:
// note: ShakeWidgetState is public
class ShakeWidgetState extends AnimationControllerState<ShakeWidget> {
ShakeWidgetState(Duration duration) : super(duration);
// 1. create a Tween
late Animation<double> _sineAnimation = Tween(
begin: 0.0,
end: 1.0,
// 2. animate it with a CurvedAnimation
).animate(CurvedAnimation(
parent: animationController,
// 3. use our SineCurve
curve: SineCurve(count: widget.shakeCount.toDouble()),
));
}
5. Use the animation with AnimatedBuilder and Transform.translate
Let's define a build()
method with a custom AnimatedBuilder
:
@override
Widget build(BuildContext context) {
// 1. return an AnimatedBuilder
return AnimatedBuilder(
// 2. pass our custom animation as an argument
animation: _sineAnimation,
// 3. optimization: pass the given child as an argument
child: widget.child,
builder: (context, child) {
return Transform.translate(
// 4. apply a translation as a function of the animation value
offset: Offset(_sineAnimation.value * widget.shakeOffset, 0),
// 5. use the child widget
child: child,
);
},
);
}
Note how we're passing
_sineAnimation
as an argument to theAnimatedBuilder
, and also use it to calculate the offset value. See below for an alternative approach.
6. Add a status listener to reset the animation when complete
As we want to "play" the animation many times, we need to reset the AnimationController
when the animation is complete:
@override
void initState() {
super.initState();
// 1. register a status listener
animationController.addStatusListener(_updateStatus);
}
@override
void dispose() {
// 2. dispose it when done
animationController.removeStatusListener(_updateStatus);
super.dispose();
}
void _updateStatus(AnimationStatus status) {
// 3. reset animationController when the animation is complete
if (status == AnimationStatus.completed) {
animationController.reset();
}
}
7. Add a shake() method
Our ShakeWidgetState
class needs a shake()
method that we can call from outside to start the animation:
// note: this method is public
void shake() {
animationController.forward();
}
8. Control the ShakeWidget with a GlobalKey
In the parent widget we can declare a GlobalKey<ShakeWidgetState>
, and use it to call shake()
when a button is pressed.
class MyHomePage extends StatelessWidget {
// 1. declare a GlobalKey
final _shakeKey = GlobalKey<ShakeWidgetState>();
@override
Widget build(BuildContext context) {
return Column(
children: [
// 2. shake the widget via the GlobalKey when a button is pressed
ElevatedButton(
child: Text('Sign In', style: TextStyle(fontSize: 20)),
onPressed: () => _shakeKey.currentState?.shake(),
),
// 3. Add a parent ShakeWidget to the child widget we want to animate
ShakeWidget(
// 4. pass the GlobalKey as an argument
key: _shakeKey,
// 5. configure the animation parameters
shakeCount: 3,
shakeOffset: 10,
shakeDuration: Duration(milliseconds: 400),
// 6. Add the child widget that will be animated
child: Text(
'Invalid credentials',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.red, fontSize: 20, fontWeight: FontWeight.bold),
),
),
],
);
}
}
Here's the final result (with a slightly more interesting UI):
Final Notes: implicit vs explicit animations
The SineCurve
class we created is a Curve
subclass, so it can be given as an argument to any implicitly animated widget.
In this example, we used it to create a custom CurvedAnimation
that is passed as an argument to our AnimatedBuilder
.
But since we're using an explicit animation, we don't need the SineCurve
or even the _sineAnimation
to start with. In fact, we can get the same result by calculating the sine value directly inside the AnimatedBuilder
code:
@override
Widget build(BuildContext context) {
// 1. return an AnimatedBuilder
return AnimatedBuilder(
// 2. pass the AnimationController as an argument
animation: animationController,
// 3. optimization: pass the given child as an argument
child: widget.child,
builder: (context, child) {
// 4. calculate the sine value directly
final sineValue =
sin(widget.shakeCount * 2 * pi * animationController.value);
return Transform.translate(
// 5. apply a translation as a function of the animation value
offset: Offset(sineValue * widget.shakeOffset, 0),
// 6. use the child widget
child: child,
);
},
);
}
Here's the full source code for this example:
That's it. Now go ahead and shake your widgets! 😎
Happy coding!