JacobHK
JacobHK

Reputation: 43

Flutter: Hero transition + widget animation at the same time?

So, I'm having a bit of an issue with Flutter in regards to a specific animation case.

Basically, what I'm trying to do is simultaneously have both a hero transition run for a route change and a custom animation on an adjacent widget.

Broken down, I have a custom InheritedWidget at my root which is fed an app state from a StatefulWidget parent. Nested within my InheritedWidget, I have a WidgetsApp and an adjacent sibling for a custom tab navigation. The tree looks something like this:

Root Widget (Stateful)
        |
        |__InheritedWidget
                   |
                   |__WidgetsApp (Handles routing)
                   |
                   |__Navigation Bar (Overlay)

My issue arises when I on my WidgetsApp perform a route change which uses a Hero transition. While this is happening, I'm trying to also animate the Navigation Bar to either be shown or hidden depending on what view the user is on. But, since I'm using a bool variable on my app state to either show or hide the Navigation Bar via an animation, the SetState call there 'overwrites' the hero transition since the tree is rebuilt in the process (is what I'm thinking).

My initial thought was that the InheritedWidget would catch the app state change and only rebuild the Navigation Bar via updateShouldNotify, but alas this isn't what I'm seeing as the desired effect :(

So - has anyone tried anything similar, or have an idea as to how this could be handled gracefully? :)

Upvotes: 4

Views: 5961

Answers (1)

rmtmckenzie
rmtmckenzie

Reputation: 40423

I have done something similar, but unfortunately my code also contains a bunch of other stuff & this is relatively convoluted to do, so I'd have to split things out to make an example which is a bit more than I can do right now. I'll explain the general concept of what I did though. There may also be better ways of doing this.

You want to write a StatefulWidget with a State that also extends NavigatorObserver (you may be able to use a stateless widget but I don't think so). I personally put this above the navigator in the tree (i.e. it builds the navigator in its' build function), but you could most likely also have it 'beside' the navigator.

Override the didPush, didRemove, didPop etc methods from NavigatorObserver. Within each of these, call a setState and save the animation & other paramters, something like this:

class NavigationFaderState extends State<NavigationFader> with NavigatorObserver {

  Animation _animation;
  // whatever else you need, maybe starting/finishing opacity or position etc.

  @override
  void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
    setState(() {
      _animation = route.animation;
    }
    route.animation.addStatusListener((status) {
      if (status = AnimationStatus.completed) {
        setState(() {
          _animation = null;
        });
      }
    });
  }

  ....
}

In your build function you'll want to check the _animation and animate based on whether it exists, and any other parameters you might want to set (i.e. a flag whether to animate, and whether the is going forward or backwards could be helpful - I believe the 'pop' animation have have started at 0 and gone to 1 the same as the push one but I could be wrong). You can then hook up this animation to however you want to animate your navigation bar, probably using an AnimatedBuilder or hooking up the animation directly, or something. If there are any specific questions about how this all works, comment and I'll add some comments etc.

Hope that helps =)

EDIT: With full code example. For the record, I don't propose that this code is all that good, or that this is something you should do. But it is a way of solving the problem. Before using it in a real app, it would be worth testing it and probably adding some assertions to check for states etc.

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  PushListener listener = new PushListener();

  @override
  Widget build(BuildContext context) {
    return new WidgetsApp(
      locale: new Locale("en"),
      navigatorObservers: [listener],
      builder: (context, child) {
        // this is here rather than outside the WidgetsApp so that it
        // gets access to directionality, text styles, etc
        return new Scaffold(
          body: child,
          bottomNavigationBar:
              new ColorChangingNavigationBar(key: listener.navBarKey),
        );
      },
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/':
            return new MaterialPageRoute(
              settings: settings,
              builder: (context) => Column(
                    children: <Widget>[
                      new Text(
                          "I have a green nav bar when you open me and blue when you come back"),
                      new RaisedButton(
                        onPressed: () {
                          Navigator.pushNamed(context, "/red");
                        },
                        child: new Text("Next"),
                      ),
                    ],
                  ),
            );
          case '/red':
            return new MaterialPageRoute(
              settings: settings,
              builder: (context) => Column(
                    children: <Widget>[
                      new Text("I have a red nav bar"),
                      new RaisedButton(
                        onPressed: () {
                          Navigator.pop(context);
                        },
                      )
                    ],
                  ),
            );
        }
      },
      color: Colors.blue,
    );
  }
}

class PushListener extends NavigatorObserver {
  GlobalKey<ColorChangingNavigationBarState> navBarKey = new GlobalKey();

  @override
  void didPop(Route route, Route previousRoute) {
    if (route is ModalRoute && navBarKey.currentState != null) {
      var name = route.settings.name;
      var color = name == "/" ? Colors.red.shade500 : Colors.blue.shade500;
      var animation = new ReverseAnimation(route.animation);
      print("Popping & changing color to: ${name == "/" ? "red" : "blue"}");

      navBarKey.currentState.setAnimating(animation, color);
    }
  }

  @override
  void didPush(Route route, Route previousRoute) {
    if (route is ModalRoute && navBarKey.currentState != null) {
      var name = route.settings.name;
      var color = name == "/" ? Colors.blue.shade500 : Colors.red.shade500;
      print("Pushing & changing color to: ${name == "/" ? "red" : "blue"}");
      var animation = route.animation;
      navBarKey.currentState.setAnimating(animation, color);
    }
  }

  @override
  void didRemove(Route route, Route previousRoute) {
    // probably don't need
  }

  @override
  void didStartUserGesture() {
    // might want to do if gestures are supported with whichever type of
    // route you're using.
  }

  @override
  void didStopUserGesture() {
    // if you implement didStartUserGesture
  }
}

class ColorChangingNavigationBar extends StatefulWidget {
  final Color startColor;

  ColorChangingNavigationBar(
      {Key key, this.startColor = const Color.fromRGBO(0, 255, 0, 1.0)})
      : super(key: key);

  @override
  State<StatefulWidget> createState() => new ColorChangingNavigationBarState();
}

class _ColorAnimationInfo {
  final Animation animation;
  final Tween<Color> colorTween;
  final AnimationStatusListener statusListener;

  _ColorAnimationInfo(this.animation, this.colorTween, this.statusListener);
}

class ColorChangingNavigationBarState
    extends State<ColorChangingNavigationBar> {
  @override
  void initState() {
    _toColor = widget.startColor;
    super.initState();
  }

  Color _toColor;
  _ColorAnimationInfo _colorAnimationInfo;

  void setAnimating(Animation animation, Color to) {
    var fromColor;
    if (_colorAnimationInfo != null) {
      fromColor = _colorAnimationInfo.colorTween
          .lerp(_colorAnimationInfo.animation.value);
      _colorAnimationInfo.animation
          .removeStatusListener(_colorAnimationInfo.statusListener);
    } else {
      fromColor = _toColor;
    }

    var statusListener = (state) {
      if (state == AnimationStatus.completed ||
          state == AnimationStatus.dismissed) {
        setState(() {
          _colorAnimationInfo = null;
        });
      }
    };

    animation.addStatusListener(statusListener);

    setState(() {
      _toColor = to;
      Tween<Color> colorTween = new ColorTween(begin: fromColor, end: to);

      _colorAnimationInfo =
          new _ColorAnimationInfo(animation, colorTween, statusListener);
    });
  }

  @override
  Widget build(BuildContext context) {
    if (_colorAnimationInfo != null) {
      return new AnimatedBuilder(
          animation: _colorAnimationInfo.animation,
          builder: (context, child) {
            return new Container(
              color: _colorAnimationInfo.colorTween
                  .lerp(_colorAnimationInfo.animation.value),
              height: 30.0,
            );
          });
    } else {
      return new Container(
        color: _toColor,
        height: 30.0,
      );
    }
  }

  @override
  void dispose() {
    if (_colorAnimationInfo != null) {
      _colorAnimationInfo.animation.removeStatusListener(_colorAnimationInfo.statusListener);
    }
    _colorAnimationInfo = null;
    super.dispose();
  }
}

Upvotes: 4

Related Questions