Animating Widgets in Flutter Grids

Part III: tab transitions using Flow widget

Danielle H
9 min readSep 29, 2023

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 cool

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 extends FlowDelegate.
  • 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 and startCol 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 the top,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),
Compare to the one above. See the curve?

Want to use one of the built-in curves? No problem:

animValue = Curves.elasticIn.transform(animation.value);
Not something I would recommend, but you see the point.

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

--

--

Danielle H

I started programming in LabVIEW and Matlab, and quickly expanded to include Android, Swift, Flutter, Web(PHP, HTML, Javascript), Arduino and Processing.