Animating Widgets in Flutter Grids

Part II: tab transitions using AnimatedPositioned

Danielle H
8 min readSep 22, 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?

In the previous article, we explored transitioning between different pages using the Hero widget. However, what if you need to switch between tabs instead of pages, like this:

In this scenario, the Hero widget won’t do the trick because it’s triggered by page push and pop actions, not tab changes.

To achieve the desired animated transition between tabs, we’ll need to animate each widget individually to its new position and size. The simplest way to accomplish this is by utilizing the AnimatedPositioned widget:

AnimatedPositioned

AnimatedPositioned is a built-in automatic animation that can smoothly transition both the position and size of a widget. However, it has one caveat: you must know in advance the exact positions where each widget should start and end. This is in contrast to the Hero widget, which could be placed in various layouts without needing precise calculations of row and column positions.

Additionally, AnimatedPositioned works only when it’s a child of a Stack widget.

Despite these constraints, the results are awesome, and and offer more fine-tuning options than the Hero widget. Here is one option for the transition using AnimatedPositioned (there will be another at the end):

See how the yellow image appears and disappears so nicely?

Now, let’s dive into the code.

Original unanimated code

The original, unanimated code is in no_anim.dart:

import 'package:flutter/material.dart';
import 'package:gridview_transitions/widgets/photo_widget.dart';

class NoAnimationExample extends StatefulWidget {
const NoAnimationExample({super.key});

@override
State<NoAnimationExample> createState() => _NoAnimationExampleState();
}

class _NoAnimationExampleState extends State<NoAnimationExample>
with SingleTickerProviderStateMixin {
//tab stuff
late TabController _controller;
int _currentIndex = 0;


@override
void initState() {
// two tabs in our example
_controller = TabController(length: 2, vsync: this);

_controller.addListener(() {
setState(() {
//update our index. Though we can also use controller.animation.value,
//animatedPositioned doesn't need the intermediate values
_currentIndex = _controller.index;
});
});
super.initState();
}

Widget getItem(int index) {
//see photowidget in previous article, basically shows the given image
return PhotoWidget(onTap: () {}, name: "tree$index");
}

@override
void dispose() {
//must dispose of a tab controller...
_controller.dispose();
super.dispose();
}

// first grid
Widget firstGrid() {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: 4,
itemBuilder: (BuildContext context, int index) {
String name = "tree${index + 2}";
return PhotoWidget(
onTap: () {},
name: name,
);
},
);
}

// second grid
Widget secondGrid() {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: 8,
itemBuilder: (BuildContext context, int index) {
String name = "tree${index + 1}";
return PhotoWidget(
onTap: () {},
name: name,
);
},
);
}

@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: const Text("No animation"),
bottom: TabBar(controller: _controller, tabs: const [
Tab(icon: Icon(Icons.home)),
Tab(icon: Icon(Icons.star)),
]),
),
body: _currentIndex == 0 ? firstGrid() : secondGrid()),
//switch widgets on tab change
);
}
}

In this code, we set up a tab controller and two grids, depending on the active tab. As you can see, I’m cheating a bit, as the built-in TabBarView provides a much smoother transition than the one shown above. But it doesn’t provide the animation I want where each widget moves to its place.

Animation with AnimatedPositioned

To use AnimatedPositioned, all the widgets must be the child of a stack and not a GridView. Also, there will no longer be two separate widgets with one widget for each tab, there will be one widget with an animation fueled by the tab.

Here’s the framework for our AnimatedPosExample:

import 'package:flutter/material.dart';
import 'package:gridview_transitions/widgets/photo_widget.dart';

class AnimatedPosExample extends StatefulWidget {
const AnimatedPosExample({super.key});

@override
State<AnimatedPosExample> createState() => _AnimatedPosExampleState();
}

class _AnimatedPosExampleState extends State<AnimatedPosExample>
with SingleTickerProviderStateMixin {//<--need this for the TabController
//tab stuff
late TabController _controller;
int _currentIndex = 0;


@override
void initState() {
// two tabs in our example
_controller = TabController(length: 2, vsync: this);

_controller.addListener(() {
setState(() {
//update our index. Though we can also use controller.animation.value,
//this is more convenient
_currentIndex = _controller.index;
});
});
super.initState();
}

Widget getItem(int index) {
//see photowidget in previous article, basically shows the given image
return PhotoWidget(onTap: () {}, name: "tree$index");
}

@override
void dispose() {
//must dispose of a tab controller...
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
//get dimension of one item
_dimension = MediaQuery.of(context).size.width / _crossCount;

return SafeArea(
child: Scaffold(
appBar: AppBar(
title: const Text("Animated positioned"),
bottom: TabBar(controller: _controller, tabs: const [
Tab(icon: Icon(Icons.home)),
Tab(icon: Icon(Icons.star)),
]),
),
body: Stack(
children: [], //<-- this is where we put our AnimatedPositioned widgets
),
),
);
}
}

Above, we set up the tab controller and the Stack.

Now, for each widget, we need its size, top and left in both tabs. So to make calculation easier, we define two additional variables:

crossCount — this is the number of widgets in the cross axis. In the Home tab this is 2, in the Star tab this is 3.

dimension — this is the size of each square widget. We will calculate it using MediaQuery.of(context).size.width / _crossCount (For the purposes of this example, I’m assuming that the app will only be in portrait mode).

We also need for each AnimatedPositioned a duration argument stating how long the animation should take. So we will create a variable animatedDuration with the Duration for all the widgets.

class _AnimatedPosExampleState extends State<AnimatedPosExample>
with SingleTickerProviderStateMixin {
//tab stuff
late TabController _controller;
int _currentIndex = 0;
//the number of items in the cross axis
int _crossCount = 2;
//the duration of the animation
Duration animationDuration = const Duration(milliseconds: 300);
//the size of each item, will be calcula ted in the build function
double _dimension = 0;

We want the crossCount to change when the tabs are pressed, so we update it in the listener in initState:

@override
void initState() {
_controller = TabController(length: 2, vsync: this);

_controller.addListener(() {
setState(() {
_currentIndex = _controller.index;
_crossCount = _currentIndex == 0 ? 2 : 3; //<--this
});
});
super.initState();
}

So if the tab is Home (i.e. 0) the crossCount is 2, otherwise it’s 3.

In build, we need to calculate dimension and use it for each item, like this:

@override
Widget build(BuildContext context) {
//get dimension of one item
_dimension = MediaQuery.of(context).size.width / _crossCount;

return SafeArea(
child: Scaffold(
appBar: AppBar(
title: const Text("Animated positioned"),
bottom: TabBar(controller: _controller, tabs: const [
Tab(icon: Icon(Icons.home)),
Tab(icon: Icon(Icons.star)),
]),
),
body: Stack(
children: [
//first item - doesn't appear in home (size 0), appears in
//star (size dimension)
AnimatedPositioned(
height: _currentIndex == 0 ? 0 : _dimension,
top: 0,
left: 0,
duration: animationDuration,
child: getItem(1)),
//second item - is first in home, second in star
AnimatedPositioned(
height: _dimension,
top: 0,
left: _currentIndex == 0 ? 0 : _dimension,
duration: animationDuration,
child: getItem(2)),
//third item - is second in home, third in star
AnimatedPositioned(
height: _dimension,
top: 0,
left: _currentIndex == 0 ? _dimension : _dimension * 2,
duration: animationDuration,
child: getItem(3)),
//fourth item - is first in the second row in both
AnimatedPositioned(
height: _dimension,
top: _dimension,
left: 0,
duration: animationDuration,
child: getItem(4)),
//fifth item - is second in the second row in both
AnimatedPositioned(
height: _dimension,
top: _dimension,
left: _dimension,
duration: animationDuration,
child: getItem(5)),
//sixth item - is not in home, third in second row in star
AnimatedPositioned(
height: _currentIndex == 0 ? 0 : _dimension,
top: _dimension,
left: _dimension * 2,
duration: animationDuration,
child: getItem(6)),
//seventh item - is not in home, first in third row in star
AnimatedPositioned(
height: _currentIndex == 0 ? 0 : _dimension,
top: _dimension * 2,
left: 0,
duration: animationDuration,
child: getItem(7)),
//eighth item - is not in home, second in third row in star
AnimatedPositioned(
height: _currentIndex == 0 ? 0 : _dimension,
top: _dimension * 2,
left: _dimension,
duration: animationDuration,
child: getItem(8)),
],
),
),
);
}

For example, the first item doesn’t appear in Home at all, so its height will be 0 if currentIndex==0, and dimension otherwise. And if its size is not 0, then it should be at position (0,0) at the top left of the screen. So we are only animating the height property, while top and left remain constant.

On the other hand, the second item appears in both tabs, so we need to animate both the height (between the two cross axis dimensions) and the position (between first in home screen and second in star). First position is (0,0); second position is (0, dimension); First in second row would be (dimension, 0).

In effect, we are building our own grid, where each item is of size dimension x dimension.

We repeat this process for each widget, adjusting their positions and sizes based on the current tab index. Because we connect the parameters to _currentIndex, it will animate whenever _currentIndex changes.

Advantages of AnimatedPositioned

  • You can easily adjust the animation duration, making it faster or slower. In Hero, it is more complex to do this.
  • You can play with the type of animation. AnimatedPositioned uses Curves.linear by default, that moves each widget directly where it needs to go. But you can play with different types of movement by changing the curve parameter.
  • Widgets that don’t appear in the first screen can be manipulated creatively. In the example above, they simply shrink to nothing, but you can also move them off-screen in one direction, or move each widget to a different point, and make the animation more interesting. Stack widget does not show what is below position (0,0), so you can move to (-dimension, -dimension) and it will move off-screen.

Here is an example that moves the widgets off-screen to (0,0), takes 500 ms instead of 300, and uses Curves.easeInCirc instead of Curves.linear:

Weird but cool.

Disadvantages of AnimatedPositioned

  • It is triggered every time the tab index changes. If you want to trigger it at another time, or not trigger it, or stop it in the middle, this is not possible with AnimatedPositioned widgets.
  • You must know the position of every widget in advance and calculate both start and end points.
  • You need to specify the curve and duration parameters for each widget separately, and each widget is not aware of the others.

In the next article, we will explore another method for achieving this animation: the Flow widget.

All the code is here:

Image by bess.hamiti@gmail.com from Pixabay. Hue adjustments made using Gimp. Screen gifs 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.