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.
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.
[...]flutter: uses-material-design: true assets: - assets/Now all that’s left is creating a HomePage widget with a GridView:
[...]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.
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
isPageViewVisibleto true. - When a user presses the back button, if the pageView is visible, we set the flag to false.
PageViewis always on top of theGridView.- If
isPageViewVisibleis false, thenPageViewis 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
GridViewclick, the corresponding image inPageViewshould be displayed. - When
PageViewgets hidden, it should scrollGridViewto 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:
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:
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:
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:
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:
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:
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:
@overridevoid 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:
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