Flutter FAB Navigation animation to next screen

Francesco Gatti
Flutter Community
Published in
5 min readApr 16, 2019
Ain’t it awesome?

In this post we are going to implement this transition from a Floating Action Button to the next navigation screen. In this case we are using a search FAB and a search page but it would work just as fine with any other action.

Note: we have removed the elevation from every component and set some high contrast colours for clarity. If your background is white everywhere, you may want to play around adding subtle borders or shadows to the transition.

(full code below)

Our setup consists of two very simple pages, HomePage and SearchPage.

What we are going to do is to build a transition consisting of a Stack which contains the page we are transitioning to and a temporary FAB as children.

We have a very simple layout for the HomePage with a basic Scaffold, AppBar and FloatingActionButton:

GlobalKey _fabKey = GlobalKey();
bool _fabVisible = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(elevation: 0, backgroundColor: Colors.yellow.shade600),
bottomNavigationBar: Container(height: 92, color: Colors.yellow.shade600),
body: Container(color: Colors.yellow),
floatingActionButton: Visibility(
visible: _fabVisible,
child: _buildFAB(context, key: _fabKey),
),
);
}

Widget _buildFAB(context, {key}) => FloatingActionButton(
elevation: 0,
backgroundColor: Colors.pink,
key: key,
onPressed: () => _onFabTap(context),
child: Icon(Icons.search),
);

We need to wrap our FAB in a Visibility widget since we need to hide it when the animation begins. We also need to provide a GlobalKey to the FAB so we can get its RenderBox afterwards to calculate the initial position of the animation:

_onFabTap(BuildContext context) {  // Hide the FAB on transition start
setState(() => _fabVisible = false);

final RenderBox fabRenderBox = _fabKey.currentContext.findRenderObject();
final fabSize = fabRenderBox.size;
final fabOffset = fabRenderBox.localToGlobal(Offset.zero);

Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: duration,
pageBuilder: (context, animation, secondaryAnimation) => SearchPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) => _buildTransition(child, animation, fabSize, fabOffset),
),
);
}

The animation

There is a Navigator feature that allows us to push page transitions instead of a standard PageRoute called PageRouteBuilder. In this utility class, we can set a duration and we can build the next route using the pageBuilder. Then we define our transition in transitionBuilder, which we have abstracted into a different method. Our goal is to provide a widget (a stack containing the second page and a temporary FAB in our case) for every value of the animation. We use a temporary FAB only for the duration of the animation.

_buildTransition(
Widget page,
Animation<double> animation,
Size fabSize,
Offset fabOffset,
) {
if (animation.value == 1) return page; // Animation is over

..........

Let’s see what goes in place of those ......

Our animation morphs a FAB into a full screen page. Remember that we are hiding the original FAB when we begin animating, so we need another one we can move around and transform while the transition lasts.

The transition result is a Stack that contains the transformed result page and the temporary FAB.

Short recap if you are not familiar with Animations in Flutter. You need a Tween that creates intermediate values between two given ones, and an Animation, that defines the curve along the specified duration that the values in the Tween form.

  final borderTween = BorderRadiusTween(
begin: BorderRadius.circular(fabSize.width / 2),
end: BorderRadius.circular(0.0),
);
final sizeTween = SizeTween(
begin: fabSize,
end: MediaQuery.of(context).size,
);
final offsetTween = Tween<Offset>(
begin: fabOffset,
end: Offset.zero,
);

In our case we have three tweens for the three different properties of the second page we are going to animate:

  • The Offset (the location) from the original FAB location to 0,0.
  • The border radius from half the FAB width to 0,0.
  • The size from the original FAB size to the full size of the screen we get with a MediaQuery.

The animation that is provided by the transitionBuilder (animation in the code) is a linear one. We are going to be using two different curves to achieve the “shot” effect on the morphing FAB. We keep the linear animation for the offset but apply and easeIn curve to the radius and size.

We use an additional easeOut animation for the transition FAB transparency, since we want to make it disappear quick. If we used a linear curve for the three values we are animating we would get a pretty dull animation.

final easeInAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeIn,
);
final easeOutAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
);

final offset = offsetTween.evaluate(animation);
final radius = borderTween.evaluate(easeInAnimation);
final size = sizeTween.evaluate(easeInAnimation);

final transitionFab = Opacity(
opacity: 1 - easeOutAnimation.value,
child: _buildFAB(context),
);

We also make the FAB fade away while the animation lasts.

At this point we just need to apply the values we have calculated and create a stack of position and clipped elements where we add the final page and the transition FAB:

  ........
Widget positionedClippedChild(Widget child) => Positioned(
width: size.width,
height: size.height,
left: offset.dx,
top: offset.dy,
child: ClipRRect(
borderRadius: radius,
child: child,
));

return Stack(
children: [
positionedClippedChild(page),
positionedClippedChild(transitionFab),
],
);
}

So far we have hidden the original FAB and created the animation, but we still need to get the FAB back when we return from the second screen to the original one. In order to do so we need a routeObserver that tells us when the top screen:

The routeObserver

We create the routeObserver in the global scope:

final routeObserver = RouteObserver<PageRoute>();
final duration = const Duration(milliseconds: 300);

void main() => runApp(MaterialApp(
home: HomePage(),
navigatorObservers: [routeObserver],
));

Then we need to add the RouteAware mixin to our _HomePageState, subscribe to the routeObserver and implement didPopNext

class _HomePageState extends State<HomePage> with RouteAware {
@override
didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context));
}

@override
dispose() {
super.dispose();
routeObserver.unsubscribe(this);
}

@override
didPopNext() {
// Show back the FAB on transition back end
Timer(duration, () {
setState(() => _fabVisible = true);
});
}
.........

We set a timer for the duration of the transition back and we restore the original FAB when it’s completed. This tweak hides the FAB during the transition but forces us to use State in the HomePage to handle the visibility.

I personally think this solution for hiding the FAB is ugly so if you have a better approach please let me know in the comments!

The full code:

--

--