Animating Widgets in Flutter Grids
Part III: tab transitions using Flow widget
Sometimes, you need to transition between two different kinds of grids in your app. For example, you have a grid of images and the user wants to see only the user’s favorites. Some widgets may stay the same, while others change. How can you animate this transition, making existing widgets smoothly move and resize within the grid?
Full code with all the examples is on GitHub.
In the first article of the series, we explored transitioning between different pages using the Hero widget. However, Hero widget is only triggered by push and pop actions, so it isn’t suitable using another method e.g. a TabBar.
In the second article, we transitioned between tabs using AnimatedPositioned widget. It is more complex to use, but works with TabBars and allows more fine-tuning in the animation parameters.
In this article, we will explore the Flow widget:
In the link above, there is an excellent example of how to use the Flow widget. There is a more in-depth explanation here.
The Flow widget itself only holds the children. The class that really does the heavy lifting is the FlowDelegate, which repaints the children using the given animation.
First, we will recreate the animation we did before (with a small change). Then we will explore additional options available when using the Flow widget.
Unanimated code:
And one option for animation using Flow:
So let’s look at the code.
FlowExample
The FlowExample
code is deceptively simple:
class FlowExample extends StatefulWidget {
const FlowExample({super.key});
@override
State<FlowExample> createState() => _FlowExampleState();
}
class _FlowExampleState extends State<FlowExample>
with SingleTickerProviderStateMixin { //<-- need this for TabController
late TabController _controller;
int crossCount = 2; // number of columns in each tab
@override
void initState() {
//animation controller for our tabs
_controller = TabController(
length: 2, vsync: this, animationDuration: const Duration(seconds: 1));
//update crosscount in different tabs
_controller.addListener(() {
setState(() {
crossCount = _controller.index == 0 ? 2 : 3;
});
super.initState();
}
// return the photowidget, basically shows a photo
Widget getItem(int index) {
return PhotoWidget(onTap: () {}, name: "tree$index");
}
@override
void dispose() {
// don't forget to dispose of the controller
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: const Text("Flow widget"),
//tab bar
bottom: TabBar(controller: _controller, tabs: const [
Tab(icon: Icon(Icons.home)),
Tab(icon: Icon(Icons.science)),
]),
),
body: Flow( //Flow widget
//this will actually do the animation
delegate: GridFlowDelegate(animation: _controller.animation!),
//all the children in both tabs
children: [
getItem(1),
getItem(2),
getItem(3),
getItem(4),
getItem(5),
getItem(6),
getItem(7),
getItem(8),
],
),
),
);
}
}
The FlowDelegate widget only needs an animation. We are using the animation from the TabController, however you can create your own animation like this:
_myAnimation = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
FlowDelegate
Let’s start with the FlowDelegate
framework:
Class declaration and constructor
class GridFlowDelegate extends FlowDelegate {
GridFlowDelegate({required this.animation}) : super(repaint: animation);
final Animation<double> animation;//this will animate the flow
//variables for internal use
double crossCount = 0; //how many columns in grid
double dimension = 1; //what is the size of each element
final int startCrossCount = 2; //crossCount in home page
final int endCrossCount = 3;// crossCount in star page
double animValue = 0; //the current animation value - for readability.
}
GridFlowDelegate
is a class that extendsFlowDelegate
.- It has a constructor that takes an animation as a required parameter and initializes some member variables.
animValue
is simply animation.value. It’s easier to define a variable for it because it makes it easier to modify, see below. In this case (with two tabs only)animValue
is between 0 and 1.
shouldRepaint method
There are two methods that must be overridden when creating a FlowDelegate. The first is the shouldRepaint
method — when do we want to repaint?
@override
bool shouldRepaint(GridFlowDelegate oldDelegate) {
return animation != oldDelegate.animation;
}
It compares the animation of the current delegate with the animation of the old delegate to determine whether the delegate should repaint. This is standard code in all the examples I’ve seen. In other words, repaint every time the animation value changes.
paintChildren method
This is the second method that must be overridden in FlowDelegate. This is where the magic takes place. In it, you call the method paintChild
which receives the child index and a transform matrix.
Yes, unfortunately using the Flow widget requires some linear algebra. However, fear not! Flutter makes it relatively easy to figure out with easy to use constructors, and online calculators can do the rest.
First, we need to define the start and end positions for each element in the widget:
//crossCount will change gradually to make a smooth transition
crossCount = animation.value + startCrossCount;
//the size of each element given the crossCount
dimension = context.size.width / crossCount;
for (int i = 0; i < context.childCount; ++i) {
// Is it invisible in the home page?
bool notVisible = i == 0 || i == 5 || i == 6 || i == 7;
// what is the start row and column (home page)?
startRow = notVisible ? -1 : ((i - 1) / startCrossCount).floor();
startCol = notVisible ? -1 : (i - 1) % startCrossCount;
// what is the end row and column (star page)?
endRow = ((i) / endCrossCount).floor();
endCol = (i) % endCrossCount;
//what are the start and end points in pixels?
startX = dimension * startCol;
startY = dimension * startRow;
endX = dimension * endCol;
endY = dimension * endRow;
}
- The
context
variable has the size of the Flow widget and the size of each of its children, among other things. - For the purposes of this example, I assume the app will only be in portrait mode.
- First we define what isn’t visible in the home page, because then its position is constant and off-screen. Therefore, the
startRow
andstartCol
of all invisible widgets is(-1,-1)
. - Then we define the startRow and startCol of visible widgets:
In the table, it can be seen that the startRow
corresponds to floor((index-1)/2
and startCol
corresponds to (index-1)%2
. So we’ve finished the startRow
and startCol
for all elements.
- Now we define the end positions:
Using this table in can be seen that endRow
corresponds to floor(index/3)
and endCol
corresponds to index%3
.
- Then we can multiply the rows and columns by
dimension
and get thetop,left
position for all elements.
Transform matrix
Now we can define the transform matrix:
import 'package:vector_math/vector_math_64.dart' as vect;
context.paintChild(i,
//compose splits the matrix into translate, rotate, scale elements
transform: Matrix4.compose(
// translate
vect.Vector3(
startX * (1 - animValue) + endX * animValue,
startY * (1 - animValue) + endY * animValue,
0),
// rotate
vect.Quaternion.axisAngle(vect.Vector3(0, 0, 1), 0.1 * animValue),
// scale
vect.Vector3(
notVisible ? 1 / 3 * animValue : -1 / 6 * animValue + 1 / 2,
notVisible ? 1 / 3 * animValue : -1 / 6 * animValue + 1 / 2,
0)));
Matrix4.compose
allows us to define translation, rotation and scaling separately.
Translation
We want the startPositions
to be active when animValue
is 0 (that is in home page, or the first tab) and endPositions
when animValue
is 1 (second tab). By multiplying the startPositions
by (1-animValue)
we make sure that it is canceled when animValue = 1
and shown when animValue = 0
.
In the same way, by multiplying the endValues
by animValue
we ensure that this part of the function is 0 when animValue=0
and endPosition
when animValue=1
.
Rotation
Using the Quaternion.axisAngle
function we can rotate by 0.1 radians around the Z axis, resulting in the angle you see above.
Scaling
We must scale the elements so that they fit within the width of the screen when crossCount
changes. The default size (corresponding to a scale of 1) is the full width of the screen, as we did not limit PhotoWidget
in any way.
If the element wasn’t visible in the first screen, the scale should go from 0 to 1/3 of the screen width: 1/3* animValue
.
If the element was visible, then it starts at 1/2 scale and ends at 1/3 scale:
Using rusty linear algebra skills or any online calculator such as this one returns the needed formula:
scale = -1/6*animValue + 1/2
And therefore the scaling part is
vect.Vector3(
notVisible ? 1 / 3 * animValue : -1 / 6 * animValue + 1 / 2,
notVisible ? 1 / 3 * animValue : -1 / 6 * animValue + 1 / 2,
0)));
So the entire FlowDelegate code becomes:
class GridFlowDelegate extends FlowDelegate {
GridFlowDelegate({required this.animation}) : super(repaint: animation);
final Animation<double> animation;//this will animate the flow
//variables for internal use
double crossCount = 0; //how many columns in grid
double dimension = 1; //what is the size of each element
final int startCrossCount = 2; //crossCount in home page
final int endCrossCount = 3;// crossCount in star page
double animValue = 0; //the current animation value - for readability.
@override
bool shouldRepaint(GridFlowDelegate oldDelegate) {
return animation != oldDelegate.animation;
}
@override
void paintChildren(FlowPaintingContext context) {
double endX = 0.0;
double endY = 0.0;
int endRow = 0;
int startRow = 0;
int startCol = 0;
int endCol = 0;
double startX = 0;
double startY = 0;
crossCount = animation.value + startCrossCount;
dimension = context.size.width / crossCount;
//use this if you want a special curve:
//animValue = Curves.elasticIn.transform(animation.value);
animValue = animation.value;
for (int i = 0; i < context.childCount; ++i) {
// Is it invisible in the home page?
bool notVisible = i == 0 || i == 5 || i == 6 || i == 7;
//starting row and column (when in home page)
startRow = notVisible ? -1 : ((i - 1) / startCrossCount).floor();
startCol = notVisible ? -1 : (i - 1) % startCrossCount;
//end row and column (when in star page)
endRow = ((i) / endCrossCount).floor();
endCol = (i) % endCrossCount;
//position at the start in pixels
startX = dimension * startCol;
startY = dimension * startRow;
//position at the end in pixels
endX = dimension * endCol;
endY = dimension * endRow;
//painting each child
context.paintChild(i,
transform: Matrix4.compose(
vect.Vector3(
startX * (1 - animValue) + endX * animValue,
startY * (1 - animValue) + endY * animValue,
0),
//small rotation:
vect.Quaternion.axisAngle(vect.Vector3(0, 0, 1), 0.1 * animValue),
//scaling
vect.Vector3(
notVisible ? 1 / 3 * animValue : -1 / 6 * animValue + 1 / 2,
notVisible ? 1 / 3 * animValue : -1 / 6 * animValue + 1 / 2,
0)));
}
}
}
What for?
But Danielle, I can already hear you. That’s a lot of effort for something you showed us how to do much more easily!
True. As this is a lot of effort and calculation and linear algebra, when should you use Flow instead of one of the other options?
- If you want to rotate, Flow is the best option.
AnimatedPositioned
does not rotate. - Flow is more efficient as it doesn’t rebuild the entire layout, just repaints the children. In our example the children are most of the layout, but if you had additional widgets on screen it would not need to re-layout the entire screen.
- You can control all aspects of the animation. For example, if you don’t want the widgets to move linearly? Add a parabola to your translation:
vect.Vector3(
startX * (1 - animValue) + endX * animValue
//you can add your own "curve"
+
(-40 * animValue * animValue +
40 * animValue)
,
startY * (1 - animValue) + endY * animValue
//you can add your own "curve"
+
(-40 * animValue * animValue +
40 * animValue)
,
0),
Want to use one of the built-in curves? No problem:
animValue = Curves.elasticIn.transform(animation.value);
You can create an animation over three tabs that stops on the second tab. You can, in short, do what you want to your heart's content, and the sky is the limit.
Summary
In this series, we explored 3 methods to animate within a grid in Flutter.
Hero
Use this when your animation doesn’t require rotation or special customization and needs to be activated on page push and pop.
AnimatedPositioned
Use this when your animation doesn’t require rotation or fine-grained customization, your animation is most of your screen, it can be activated automatically, and the layout isn’t complex (or for another reason optimization isn’t an issue).
Flow
Use this when your animation requires rotation, or you want complete control over all aspects of your animation, or optimization is critical.
I hope you enjoyed the series!
If you have another solution, or another animation you like, let me know in the comments. See you next time!
Image by bess.hamiti@gmail.com from Pixabay. Hue adjustments made using Gimp. Screen gifs created and edited using Screen2Gif