Blog · · 7 min read

Shared Element Transition in Flutter

Hello there! Some time ago a friend of mine showed me this article (preview below) and asked me if I can do Shared Element Transitions in Flutter. The first thought that came into my mind was “Simple, Heroes!” but when I looked closely, I’ve noticed that the transition in that article occurs in one page, so Hero transitions wouldn’t work in that case. In this post I will use AnimationControllers, Overlays and Rects to get the same result as below:

Expected result

Setup and GridView

Let’s start with adding images to the project. All we need to do is copy the images into assets directory. You can find all of them here.

main.dart
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
List<String> images = [
'animal.jpg',
'beetle.jpg',
'bug.jpg',
'butterfly_1.jpg',
'butterfly_dolls.jpg',
'dragonfly_1.jpg',
'dragonfly_2.jpg',
'dragonfly_3.jpg',
'grasshopper.jpg',
'hover_fly.jpg',
'hoverfly.jpg',
'insect.jpg',
'morpho.jpg',
'nature.jpg'
];
[...]

After that, let’s update pubspec.yaml file, so that Flutter will be able to find those images.

pubspec.yaml
[...]
flutter:
uses-material-design: true
assets:
- assets/

Now all that’s left is creating a HomePage widget with a GridView:

main.dart
[...]
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Grid to Pager')),
body: Stack(
children: <Widget>[
_buildGridView(),
],
),
);
}
Widget _buildGridView() {
return GridView.count(
crossAxisCount: 2,
children: images.map((imageName) {
return Card(
child: Center(
child: Image.asset("assets/$imageName"),
),
);
}).toList(),
);
}
}

The app should look like this:


Adding a PageView

Now we need to add a PageView. Since we need to keep track of pages, I want the page view to be rendered all the time. On the other hand, I want it to be visible on top of the GridView only when the GridView element was clicked. To achieve such a result, we need to use a combination of Stack, Opacity and IgnorePointer widgets.

main.dart
class _MyHomePageState extends State<MyHomePage> {
bool isPageViewVisible = false; // <--- add flag telling us if we should draw pageview
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async { // <--- on back press
if (isPageViewVisible) { // <--- if page view is visible
_setPageViewVisible(false); // <--- hide page view
return false; // <--- and don't close app
}
return true; // <--- otherwise close the app
},
child: Scaffold(
appBar: AppBar(title: Text('Grid to Pager')),
body: Stack(
children: <Widget>[
_buildGridView(),
_buildPageView(), // <--- build PageView ON TOP of GridView
],
),
),
);
}
Widget _buildGridView() {
return GridView.count(
crossAxisCount: 2,
children: images.map((imageName) {
return GestureDetector(
onTap: () => _setPageViewVisible(true), //<--- on GridView item click, show PageView
child: Card(
child: Center(
child: Image.asset("assets/$imageName"),
),
),
);
}).toList(),
);
}
Widget _buildPageView() {
PageView pageView = PageView.builder(
itemCount: images.length,
itemBuilder: (context, index) {
return Center(
child: Image.asset("assets/${images[index]}"),
);
},
);
return Opacity(
opacity: isPageViewVisible ? 1 : 0, // <--- make PageView transparent
child: IgnorePointer(
ignoring: !isPageViewVisible, // <--- make PageView ignore the cliks
child: pageView,
),
);
}
void _setPageViewVisible(bool visible) {
setState(() => isPageViewVisible = visible);
}
}

So what is happening now:

  • When a user clicks a GridView card, we set isPageViewVisible to true.
  • When a user presses the back button, if the pageView is visible, we set the flag to false.
  • PageView is always on top of the GridView.
  • If isPageViewVisible is false, then PageView is transparent (Opacity) and unclickable (IgnorePointer) so it behaves the same as it wasn’t even there.

Let’s see how it looks:


Syncing PageView and GridView

Right now we can show and hide PageView but it has nothing to do to on what picture we pressed or when we pressed back. We need to have 2 way syncing:

  • On GridView click, the corresponding image in PageView should be displayed.
  • When PageView gets hidden, it should scroll GridView to the last displayed image.

The first part is simple, all we need to do is add PageController, which has method jumpTo. We can use this method to set the page we want, just like this:

main.dart
class _MyHomePageState extends State<MyHomePage> {
bool isPageViewVisible = false;
PageController _pageController = PageController(); //<-- add controller
Widget _buildGridView() {
return GridView.count(
crossAxisCount: 2,
children: images.map((imageName) {
int index = images.indexOf(imageName);
return GestureDetector(
onTap: () => _showPageView(index), //<--replace old method
...
);
}).toList(),
);
}
Widget _buildPageView() {
PageView pageView = PageView.builder(
controller: _pageController, //<-- Use controller
itemCount: images.length,
itemBuilder: (context, index) {
return Center(
child: Image.asset("assets/${images[index]}"),
);
},
);
void _showPageView(int index) {
_pageController.jumpToPage(index); //<--- set PageView's page
_setPageViewVisible(true);
}
}

From now on, on every click, PageView starts from clicked image:

Now it’s time for the harder part: scrolling to the picture on PageView closed. Similarly to the previous example, we will use ScrollController which can be attached to the GridView. Unfortunately, ScrollControllers don’t have jumpTo(index) method, you can only pass the scroll offset. To overcome this, we need to calculate the offset at which the specific picture is displayed. Let’s take a look at the following method:

main.dart
void _scrollToIndex(int index) {
//find card height
double deviceWidth = MediaQuery.of(context).size.width;
double cardHeight = deviceWidth / 2;
//find row we are looking for
int row = index ~/ 2;
row -= 1; // subtract 1 to have target row in the center of screen
//calculate target offset
double target = row * cardHeight;
//normalize target
target = math.max(
_scrollController.position.minScrollExtent,
math.min(
target,
_scrollController.position.maxScrollExtent,
),
);
//jump to target
_scrollController.jumpTo(target);
}

Luckily, we know the cards’ height as it is half of the screen width. Having that, we can easily deduct how much we should scroll.

To use that method, all we need to do is add a new ScrollController to our state class:

main.dart
class _MyHomePageState extends State<MyHomePage> {
bool isPageViewVisible = false;
PageController _pageController = PageController();
ScrollController _scrollController = ScrollController(); //<-- add controller
int get currentIndex => _pageController.page.round(); //<-- add getter for current page
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (isPageViewVisible) {
_hidePageView(currentIndex); //<-- replace old method
return false;
}
return true;
},
...
);
}
Widget _buildGridView() {
return GridView.count(
crossAxisCount: 2,
controller: _scrollController, //<-- attach the controller
...
);
}
void _hidePageView(int index) {
_scrollToIndex(index); //<-- scroll to index before hiding PageView
_setPageViewVisible(false);
}
}

The effect? Hiding the PageView causes GridView to scroll:


Transition

Getting Rect objects

Now it’s time to work on the transition from GridView image to PageView image and backward. To do that, we need to know the position of both Widgets. We could use context.findRenderObject method and figure it out, but because I am a lazy man, I am going to use DebuggerX‘s awesome rect_getter package from Pub which gives easy access to Widgets’ Rects.

The usage of this package is very simple, we wrap with RectGetter widget, pass a key and then use the same key to get a Rect object describing the size and position of the wrapped widget. Let’s put it into our code:

main.dart
List gridKeys = List.generate(images.length, (i) => RectGetter.createGlobalKey());
List pageKeys = List.generate(images.length, (i) => RectGetter.createGlobalKey());
...
Widget _buildGridView() {
...
child: Center(
child: RectGetter(
key: gridKeys[index],
child: Image.asset("assets/$imageName"),
),
),
...
}
Widget _buildPageView() {
...
return Center(
child: RectGetter(
key: pageKeys[index],
child: Image.asset("assets/${images[index]}"),
),
);
...
}
void _showPageView(int index) async {
_pageController.jumpToPage(index);
await Future.delayed(Duration(milliseconds: 100));
_startTransition(true);
_setPageViewVisible(true);
}
void _hidePageView(int index) async {
_scrollToIndex(index);
await Future.delayed(Duration(milliseconds: 100));
_startTransition(false);
_setPageViewVisible(false);
}
void _startTransition(bool toPageView) {
Rect gridRect = RectGetter.getRectFromKey(gridKeys[currentIndex]);
Rect pageRect = RectGetter.getRectFromKey(pageKeys[currentIndex]);
//TODO: Make the transition
}

What we’ve done here is creating 2 lists of keys for grid images and page images. Each key was passed to specific RectGetter. We also started the implementation of the transition method, where we need to access both gridRect and pageRect. Notice that I added Future.delayed before starting transitions. The problem is that if the transition starts just after jumpTo methods, it may not get the latest position of images. This solution is more a workaround, if you know a better way to do it, please let me know! 🙂

Animated image

To create the animation we will need two main components: Animations and Overlays. Let’s start with the animation:

main.dart
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
bool isPageViewVisible = false;
PageController _pageController = PageController();
ScrollController _scrollController = ScrollController();
AnimationController _animationController; //<-- Add AnimationController
Animation<Rect> rectAnimation; //<-- Add RectAnimation
int get currentIndex => _pageController.page.round();
@override
void initState() {
super.initState();
_animationController = AnimationController( //<-- init AnimationController
vsync: this,
duration: Duration(milliseconds: 400),
);
}
@override
void dispose() {
_animationController.dispose(); //<-- Remember to dispose the controller
super.dispose();
}
void _startTransition(bool toPageView) {
Rect gridRect = RectGetter.getRectFromKey(gridKeys[currentIndex]);
Rect pageRect = RectGetter.getRectFromKey(pageKeys[currentIndex]);
rectAnimation = RectTween( //<-- create an animation between gridRect and pageRect
begin: gridRect,
end: pageRect,
).animate(_animationController);
//TODO: add overlay
if (toPageView) {
_animationController.forward(); //<--run the animation
} else {
_animationController.reverse();
}
}
...
}

We have our AnimationController and rectAnimation which will animate from the selected grid image to the same page image. Depending on the direction (from the grid to a page or from page to grid) we decide if we should run forward() or reverse(). But animation itself won’t make any difference if we don’t use it to build Widgets. To do that we will use Overlay.

The overlay is a handy Widget if we want our Widgets to float on top of others. It has its own Stack, in which we can put OverlayEntries. Our OverlayEntry will be simple widget wrapped in AnimatedBuilder so that it is built every time our animation changes. Inside that, we will have an Image wrapped in Positioned. We only need to also make sure that the image’s position and size are dependent on the current rectAnimation value. The code looks like this:

main.dart
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
OverlayEntry transitionOverlayEntry; //<--add OverlayEntry field
int get currentIndex => _pageController.page.round();
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 400),
);
transitionOverlayEntry = _createOverlayEntry(); //<--create OverlayEntry
}
OverlayEntry _createOverlayEntry() {
return OverlayEntry(
builder: (context) {
return AnimatedBuilder( //<-- rebuild when animation changes
animation: rectAnimation,
builder: (context, child) {
return Positioned(
top: rectAnimation.value.top,
left: rectAnimation.value.left,
child: Image.asset(
"assets/${images[currentIndex]}",
height: rectAnimation.value.height,
width: rectAnimation.value.width,
),
);
},
);
},
);
}
...
}

The only thing left that keeps us from seeing beautiful shared element transition is using that overlay entry. All we need to do is add it do Overlay when the transition is about to start and remove it when it’s finished:

main.dart
@override
void initState() {
super.initState();
transitionOverlayEntry = _createOverlayEntry();
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 400),
);
_animationController.addStatusListener((status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
transitionOverlayEntry.remove(); //<-- remove Entry from the Overlay
}
if (status == AnimationStatus.completed) {
_setPageViewVisible(true);
} else if (status == AnimationStatus.reverse) {
_setPageViewVisible(false);
}
});
}
void _startTransition(bool toPageView) {
Rect gridRect = RectGetter.getRectFromKey(gridKeys[currentIndex]);
Rect pageRect = RectGetter.getRectFromKey(pageKeys[currentIndex]);
rectAnimation = RectTween(
begin: gridRect,
end: pageRect,
).animate(_animationController);
Overlay.of(context).insert(transitionOverlayEntry); //<-- Add Entry to the Overlay
if (toPageView) {
_animationController.forward();
} else {
_animationController.reverse();
}
}

We’ve also added state management to be depended on Animation status, if AnimatedController is completed, we set isPageViewVisible to true, when it starts to go back, we change it to false.

It’s time to see how it all looks like:

Animated curtain

The last part is to make the GridView fade into white, while the animation progresses. Luckily, we are already using Stack, so it won’t be a problem:

main.dart
child: Scaffold(
appBar: AppBar(title: Text('Grid to Pager')),
body: Stack(
children: <Widget>[
_buildGridView(),
_buildWhiteCurtain(), //<-- add white curtain between GridView and PageView
_buildPageView(),
],
),
),
...
AnimatedBuilder _buildWhiteCurtain() {
return AnimatedBuilder( //<-- rebuild on animation changes
animation: _animationController,
builder: (context, child) {
return _animationController.isDismissed
? Container() //<--replace the curtain with empty container if controller is dismissed.
: Positioned.fill(
child: Opacity(
opacity: _animationController.value,
child: Container(color: Colors.white),
),
);
},
);
}

And that’s it!

I hope you enjoyed this post and from now on the shared element transitions won’t be a problem for you. 🙂

You can see the full code here.

Cheers