As an Android developer, constantly searching for smoother, more straightforward solutions across platforms is an ongoing adventure. Recently while working on one application, I needed to apply multiple visibility animations on a widget.
Flutter boasts a rich set of Transition APIs, but achieving combined animations involves wrapping widgets in multiple transitions, and let’s be honest, it can get a tad messy. On the flip side, my exploration led me to Jetpack Compose, where achieving the same effect was straightforward. With just one composable and a few enter/exit effects, the task is accomplished seamlessly.
So, in the spirit of experimentation, I thought, “Why not bring a bit of that Compose coolness into my Flutter gig?”
This blog is on how we can implement the AnimatedVisibility
widget in Flutter along with keeping it simple & straightforward. AnimatedVisibity package is up on pub.dev.
What we’ll achieve at the end of this blog? — cool appearance and disappearance effects.
You can find the full source code on GitHub.
We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!
Let’s first add the data class, that we need to hold data for multiple transition effects.
@immutable
class TransitionData {
final Fade? fade;
final Slide? slide;
final ChangeSize? changeSize;
final Scale? scale;
const TransitionData({
this.fade,
this.slide,
this.changeSize,
this.scale,
});
}
@immutable
class Fade {
final Animatable<double> animation;
const Fade(this.animation);
}
@immutable
class Slide {
final Offset offset;
final Animatable<Offset> animation;
const Slide(this.offset, this.animation,);
}
@immutable
class ChangeSize {
final double sizeFactor;
final Animatable<double> animation;
final Axis axis;
final double axisAlignment;
const ChangeSize(
this.sizeFactor, this.animation,
this.axis, this.axisAlignment,
);
}
@immutable
class Scale {
final Animatable<double> animation;
final Alignment alignment;
const Scale(this.animation, this.alignment);
}
EnterTransition & ExitTransition defines how an AnimatedVisibility Widget appears and disappears on screen. The full source code of these transitions is available here.
We’ll add these Transitions:
fadeIn/Out
scaleIn/Out
slideIn/Out
, slideIn/OutHorizontally
, slideIn/OutVertically
expandHorizontally
, expandVertically
, shrinkHorizontally
, shrinkVertically
Let’s first add abstract classes for EnterTransition
& ExitTransition
.
@immutable
abstract class EnterTransition {
TransitionData get data;
/// Combines different enter transitions.
/// * [enter]- another EnterTransition to be combined
EnterTransition operator +(EnterTransition enter) {
return EnterTransitionImpl(
TransitionData(
fade: data.fade ?? enter.data.fade,
slide: data.slide ?? enter.data.slide,
changeSize: data.changeSize ?? enter.data.changeSize,
scale: data.scale ?? enter.data.scale,
),
);
}
}
@immutable
class EnterTransitionImpl extends EnterTransition {
@override
final TransitionData data;
EnterTransitionImpl(this.data);
}
//ExitTransition defines how an AnimatedVisibility Widget
// disappears from screen as it becomes invisible.
@immutable
abstract class ExitTransition {
TransitionData get data;
/// Combines different exit transitions.
ExitTransition operator +(ExitTransition exit) {
return ExitTransitionImpl(
TransitionData(
fade: data.fade ?? exit.data.fade,
slide: data.slide ?? exit.data.slide,
changeSize: data.changeSize ?? exit.data.changeSize,
scale: data.scale ?? exit.data.scale,
),
);
}
}
class ExitTransitionImpl extends ExitTransition {
@override
final TransitionData data;
ExitTransitionImpl(this.data);
}
Fade Effects
EnterTransition fadeIn({
double initialAlpha = 0.0,
Curve curve = Curves.linear,
}) {
final Animatable<double> fadeInTransition = Tween<double>(
begin: initialAlpha,
end: 1.0,
).chain(CurveTween(curve: curve));
return EnterTransitionImpl(
TransitionData(fade: Fade(fadeInTransition)),
);
}
This fades in the content of the transition, from the specified starting alpha (i.e. initialAlpha
) to 1f.
ExitTransition fadeOut({
double targetAlpha = 0.0,
Curve curve = Curves.linear,
}) {
final Animatable<double> fadeOutTransition = Tween<double>(
begin: 1.0,
end: targetAlpha,
).chain(CurveTween(curve: curve));
return ExitTransitionImpl(
TransitionData(fade: Fade(fadeOutTransition)),
);
}
This fades out the content of the transition, from full opacity to the specified target alpha (i.e. targetAlpha
).
Scale Effect
EnterTransition scaleIn({
double initialScale = 0.0,
Alignment alignment = Alignment.center,
Curve curve = Curves.linear,
}) {
final Animatable<double> scaleInTransition = Tween<double>(
begin: initialScale,
end: 1.0,
).chain(CurveTween(curve: curve));
return EnterTransitionImpl(
TransitionData(scale: Scale(scaleInTransition, alignment)),
);
}
This scales in the content of the transition, from the specified initial scale (i.e. initialScale
) to 1f. The scaling will change the visual of the content, but will not affect the layout size.
ExitTransition scaleOut({
double targetScale = 0.0,
Alignment alignment = Alignment.center,
Curve curve = Curves.linear,
}) {
final Animatable<double> scaleOutTransition = Tween<double>(
begin: 1.0,
end: targetScale,
).chain(CurveTween(curve: curve));
return ExitTransitionImpl(
TransitionData(scale: Scale(scaleOutTransition, alignment)),
);
}
This scales out the content of the transition, from full scale to the specified target scale (i.e. targetScale
).
Slide Effect
EnterTransition slideIn({
Offset initialOffset = const Offset(1.0, 1.0),
Curve curve = Curves.linear,
}) {
final slideTransition =
Tween(begin: initialOffset, end: const Offset(0.0, 0.0))
.chain(CurveTween(curve: curve));
return EnterTransitionImpl(
TransitionData(slide: Slide(initialOffset, slideTransition)),
);
}
EnterTransition slideInHorizontally({
double initialOffsetX = 1.0,
Curve curve = Curves.linear,
}) {
return slideIn(initialOffset: Offset(initialOffsetX, 0.0), curve: curve);
}
EnterTransition slideInVertically({
double initialOffsetY = 1.0,
Curve curve = Curves.linear,
}) {
return slideIn(initialOffset: Offset(0.0, initialOffsetY), curve: curve);
}
This slides in the content of the transition, from the specified initial offset (i.e. initialOffset
) to Offset.zero
. The offset is defined in terms of fractions of the transition’s size.
The direction of the slide can be controlled by configuring the initialOffset
.
x
value means sliding from right to left, whereas a negative x
value will slide the content to the right.y
values correspond to sliding up and down, respectively.ExitTransition slideOut({
Offset targetOffset = const Offset(1.0, 1.0),
Curve curve = Curves.linear,
}) {
final slideTransition =
Tween(begin: const Offset(0.0, 0.0), end: targetOffset)
.chain(CurveTween(curve: curve));
return ExitTransitionImpl(
TransitionData(slide: Slide(targetOffset, slideTransition)),
);
}
ExitTransition slideOutHorizontally({
double targetOffsetX = 1.0,
Curve curve = Curves.linear,
}) {
return slideOut(targetOffset: Offset(targetOffsetX, 0.0), curve: curve);
}
ExitTransition slideOutVertically({
double targetOffsetY = 1.0,
Curve curve = Curves.linear,
}) {
return slideOut(targetOffset: Offset(0.0, targetOffsetY), curve: curve);
}
This slides out the content of the transition, from an offset of IntOffset(0, 0)
to the target offset defined in targetOffset
.
Expand Effect — This expands the content of the transition, from the specified initial factor (i.e. initialFactor
) to 1f, in terms of the fraction of the transition’s height
EnterTransition expandVertically({
double initialFactor = 0.0,
double alignment = 0.0,
Curve curve = Curves.linear,
}) {
final Animatable<double> expandInTransition =
Tween<double>(begin: initialFactor, end: 1.0)
.chain(CurveTween(curve: curve));
return EnterTransitionImpl(
TransitionData(
changeSize: ChangeSize(initialFactor = initialFactor,
expandInTransition, Axis.vertical, alignment)),
);
}
EnterTransition expandHorizontally({
double initialFactor = 0.0,
double alignment = 0.0,
Curve curve = Curves.linear,
}) {
final Animatable<double> expandInTransition =
Tween<double>(begin: initialFactor, end: 1.0)
.chain(CurveTween(curve: curve));
return EnterTransitionImpl(
TransitionData(
changeSize: ChangeSize(initialFactor = initialFactor,
expandInTransition, Axis.horizontal, alignment)),
);
}
alignment
align the child along the axis that sizeFactor
is modifying.
- A value of -1.0
indicates the left. A value of 1.0
indicates the right.
- A value of 0.0
(the default) indicates the center.
Shrink Effect — This shrinks out the content of the transition, to targetFactor
defined in terms of a fraction of the transition’s height.
ExitTransition shrinkVertically({
double targetFactor = 0.0,
double alignment = 0.0,
Curve curve = Curves.linear,
}) {
final Animatable<double> shrinkTransition =
Tween<double>(begin: 1.0, end: targetFactor)
.chain(CurveTween(curve: curve));
return ExitTransitionImpl(
TransitionData(
changeSize: ChangeSize(
targetFactor, shrinkTransition, Axis.vertical, alignment)),
);
}
ExitTransition shrinkHorizontally({
double targetFactor = 0.0,
double alignment = 0.0,
Curve curve = Curves.linear,
}) {
final Animatable<double> shrinkTransition =
Tween<double>(begin: 1.0, end: targetFactor)
.chain(CurveTween(curve: curve));
return ExitTransitionImpl(
TransitionData(
changeSize: ChangeSize(
targetFactor, shrinkTransition, Axis.horizontal, alignment)),
);
}
Now that we’ve set up our basic transition effects, let’s dive into the main implementation. We’re going to blend those effects seamlessly for captivating animations.
For this, we’ll use DualTransitionBuilder. It’s our go-to tool for defining distinct enter and exit transitions for a child. It animates the child based on the animation’s status.
As the animation goes forward, our child gets its moves from the forwardBuilder of DualTransitionBuilder. On the other hand, when the animation reverses, it’s the reverseBuilder’s turn to shine. In simple terms, all our entry effects go in when moving forward, and the exit effects step up when we’re hiding the child.
Let’s make our animations pop — entry effects on the way forward and exit effects on the way back.
First, create a stateful widget called AnimatedVisibility
.
class AnimatedVisibility extends StatefulWidget {
static final defaultEnterTransition = fadeIn();
static final defaultExitTransition = fadeOut();
static const defaultEnterDuration = Duration(milliseconds: 500);
static const defaultExitDuration = defaultEnterDuration;
AnimatedVisibility({
super.key,
this.visible = true,
this.child = const SizedBox.shrink(),
EnterTransition? enter,
ExitTransition? exit,
Duration? enterDuration,
Duration? exitDuration,
}) : enter = enter ?? defaultEnterTransition,
exit = exit ?? defaultExitTransition,
enterDuration = enterDuration ?? defaultEnterDuration,
exitDuration = exitDuration ?? defaultExitDuration;
/// Whether the content should be visible or not.
final bool visible;
//// The widget to apply animated effects to.
final Widget child;
/// The enter transition to be used, [fadeIn] by default.
final EnterTransition enter;
/// The exit transition to be used, [fadeOut] by default.
final ExitTransition exit;
/// The duration of the enter transition, 500ms by default.
final Duration enterDuration;
/// The duration of the exit transition, 500ms by default.
final Duration exitDuration;
@override
State<AnimatedVisibility> createState() => _AnimatedVisibilityState();
}
Next, let’s set up an animation controller in _AnimatedVisibilityState.
class _AnimatedVisibilityState extends State<AnimatedVisibility>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
_controller = AnimationController(
value: widget.visible ? 1.0 : 0.0,
duration: widget.enterDuration,
reverseDuration: widget.exitDuration,
vsync: this,
)..addStatusListener((AnimationStatus status) {
setState(() {});
});
super.initState();
}
...
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
When our visibility changes, the didUpdateWidget
gets a call, and within it, we decide whether to move the animation forward or rewind it.
@override
void didUpdateWidget(AnimatedVisibility oldWidget) {
super.didUpdateWidget(oldWidget);
if (!oldWidget.visible && widget.visible) {
_controller.forward();
} else if (oldWidget.visible && !widget.visible) {
_controller.reverse();
}
}
Now let’s implement the build
method.
@override
Widget build(BuildContext context) {
return DualTransitionBuilder(
animation: _controller,
forwardBuilder: (
BuildContext context,
Animation<double> animation,
Widget? child,
) {
return _build(context, child, animation, widget.enter.data) ??
const SizedBox.shrink();
},
reverseBuilder: (
BuildContext context,
Animation<double> animation,
Widget? child,
) {
return _build(context, child, animation, widget.exit.data) ??
const SizedBox.shrink();
},
child: Visibility(
visible: _controller.status != AnimationStatus.dismissed,
child: widget.child,
),
);
}
In this widget’s build method, we use the DualTransitionBuilder
to handle animations based on the _controller
status. When moving forward, it calls _build
with entry data from widget.enter.data
, and when moving backward, it uses _build
with exit data from widget.exit.data
. The Visibility
widget ensures our child is visible when the animation is not dismissed. Simple and effective!
Here’s the _build
method,
Widget? _build(BuildContext context, Widget? child,
Animation<double> animation, TransitionData data) {
Widget? animatedChild = child;
if (data.scale != null) {
var scale = data.scale!.animation.animate(animation);
animatedChild = ScaleTransition(
scale: scale,
alignment: data.scale!.alignment,
child: animatedChild,
);
}
if (data.changeSize != null) {
final changeSize = data.changeSize!;
animatedChild = SizeTransition(
sizeFactor: changeSize.animation.animate(animation),
axis: changeSize.axis,
axisAlignment: changeSize.axisAlignment,
child: animatedChild,
);
}
if (data.slide != null) {
animatedChild = SlideTransition(
position: data.slide!.animation.animate(animation),
child: animatedChild,
);
}
if (data.fade != null) {
animatedChild = FadeTransition(
opacity: data.fade!.animation.animate(animation),
child: animatedChild,
);
}
return animatedChild;
}
The _build
method plays a crucial role in crafting our animated child widget. Depending on the provided TransitionData
, it applies various transformations to the child using different transition widgets.
The order of transitions within the _build
method is crucial because each subsequent transformation builds upon the previous ones. The transformations are applied in the order they appear in the method. Here's why the order matters:
data.scale
): Scaling modifies the size of the child. If applied first, subsequent transitions will adapt to the scaled size.data.changeSize
): Changing the size further modifies the child's dimensions. Applying this after scaling ensures a coordinated adjustment.data.slide
): Altering the position of the child comes next. This adjustment considers the scaled and resized dimensions.data.fade
): Lastly, fading modifies the opacity of the child. Applying this at the end ensures that the child's visibility smoothly transitions with all previous transformations.In essence, the order ensures that each transition operates on the modified state of the child from the previous transformation, leading to a coherent and visually appealing animation.
Checkout full source code of AnimatedVisibility
widget.
Now let’s use AnimatedVisibility
widget.
AnimatedVisibility(
visible: _isVisible,
enter: fadeIn() + scaleIn() + <more effects>,
exit: fadeOut() + shrinkVertically() + <more effects>,
child: < CHILD GOES HERE>,
);
Here’s the demo of all our implemented transition effects.
You can check the source code on GitHub.
And that’s a wrap on our journey from Flutter’s Transition APIs to the smooth simplicity of Jetpack Compose-inspired AnimatedVisibility in Flutter. Simplifying visibility animations can indeed be a game-changer.
Remember that the beauty lies in finding the right balance between simplicity and functionality.
So, here’s to clean code, slick animations, and the joy of discovering the best tools for the job. Until the next coding adventure, happy coding!