flutterflutter-animationflutter-navigationflutter-pageviewpage-transition

Flutter: How to animate the background color while page transition


I would like to animate between the background colors of two pages in flutter. I am talking about page transitions, but instead of transitioning the whole page I just want to change the background color of the new page (from the previous page's bg color to a new color), while the rest of the foreground content fades in (or any other type of transition).

If you want more clarification, I want something like a Hero transition, but with background color of the pages.

The background color need not be just a color property of some container.

edit: to answer easily, let say my page is callable by specifying a color to its constructor. So the page is something like the following,

class MyWidget extends StatelessWidget {
  final Color bgColor;

  const MyWidget(this.bgColor);

  @override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: [
        Container(color: bgColor),
        // foreground widgets...
      ],
    );
  }
}

What should be my approach? Should I create something like a custom transition? In that case, how can I animate the background color?


Solution

  • Modify Hero with ColoredBox, plus add new NavigatorObserver to navigatorObservers of MaterialApp

    enter image description here

    import 'package:flutter/foundation.dart';
    
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(App());
    }
    
    class App extends StatelessWidget {
      const App({Key key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          navigatorObservers: [ColorHeroController()],
          home: Screen1(),
        );
      }
    }
    
    class Screen1 extends StatelessWidget {
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('This is color hero'),
                Padding(
                  padding: EdgeInsets.only(bottom: 50, left: 50),
                  child: ColorHero(
                    color: Colors.blue,
                    tag: 'tag',
                    child: Container(
                      height: 50,
                      width: 100,
                    ),
                  ),
                ),
                RaisedButton(
                  onPressed: () {
                    Navigator.of(context).push(
                      MaterialPageRoute(
                        builder: (context) => Screen2(),
                      ),
                    );
                  },
                  child: Text('go'),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class Screen2 extends StatelessWidget {
      const Screen2({Key key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text('page2')),
          body: Center(
            child: Row(
              children: [
                Text('Page2'),
                ColorHero(
                  tag: 'tag',
                  color: Colors.red,
                  child: Container(
                    height: 100,
                    width: 100,
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    typedef CreateRectTween = Tween<Rect> Function(Rect begin, Rect end);
    
    typedef HeroPlaceholderBuilder = Widget Function(
      BuildContext context,
      Size heroSize,
      Widget child,
    );
    
    typedef HeroFlightShuttleBuilder = Widget Function(
      BuildContext flightContext,
      Animation<double> animation,
      HeroFlightDirection flightDirection,
      BuildContext fromHeroContext,
      BuildContext toHeroContext,
    );
    
    typedef _OnFlightEnded = void Function(_HeroFlight flight);
    
    enum HeroFlightDirection { push, pop }
    
    Rect _boundingBoxFor(BuildContext context, [BuildContext ancestorContext]) {
      final RenderBox box = context.findRenderObject() as RenderBox;
      assert(box != null && box.hasSize);
      return MatrixUtils.transformRect(
        box.getTransformTo(ancestorContext?.findRenderObject()),
        Offset.zero & box.size,
      );
    }
    
    class ColorHero extends StatefulWidget {
      const ColorHero({
        @required this.color,
        Key key,
        @required this.tag,
        this.createRectTween,
        this.flightShuttleBuilder,
        this.placeholderBuilder,
        this.transitionOnUserGestures = false,
        @required this.child,
      })  : assert(tag != null),
            assert(transitionOnUserGestures != null),
            assert(child != null),
            super(key: key);
    
      final Object tag;
    
      final Color color;
    
      final CreateRectTween createRectTween;
    
      final Widget child;
    
      final HeroFlightShuttleBuilder flightShuttleBuilder;
    
      final HeroPlaceholderBuilder placeholderBuilder;
    
      final bool transitionOnUserGestures;
    
      static Map<Object, _ColorHeroState> _allHeroesFor(
        BuildContext context,
        bool isUserGestureTransition,
        NavigatorState navigator,
      ) {
        assert(context != null);
        assert(isUserGestureTransition != null);
        assert(navigator != null);
        final Map<Object, _ColorHeroState> result = <Object, _ColorHeroState>{};
    
        void inviteHero(StatefulElement hero, Object tag) {
          assert(() {
            if (result.containsKey(tag)) {
              throw FlutterError.fromParts(<DiagnosticsNode>[
                ErrorSummary(
                    'There are multiple heroes that share the same tag within a subtree.'),
                ErrorDescription(
                    'Within each subtree for which heroes are to be animated (i.e. a PageRoute subtree), '
                    'each Hero must have a unique non-null tag.\n'
                    'In this case, multiple heroes had the following tag: $tag\n'),
                DiagnosticsProperty<StatefulElement>(
                    'Here is the subtree for one of the offending heroes', hero,
                    linePrefix: '# ', style: DiagnosticsTreeStyle.dense),
              ]);
            }
            return true;
          }());
          final ColorHero heroWidget = hero.widget as ColorHero;
          final _ColorHeroState heroState = hero.state as _ColorHeroState;
          if (!isUserGestureTransition || heroWidget.transitionOnUserGestures) {
            result[tag] = heroState;
          } else {
            heroState.ensurePlaceholderIsHidden();
          }
        }
    
        void visitor(Element element) {
          final Widget widget = element.widget;
          if (widget is ColorHero) {
            final StatefulElement hero = element as StatefulElement;
            final Object tag = widget.tag;
            assert(tag != null);
            if (Navigator.of(hero) == navigator) {
              inviteHero(hero, tag);
            } else {
              final ModalRoute<dynamic> heroRoute = ModalRoute.of(hero);
              if (heroRoute != null &&
                  heroRoute is PageRoute &&
                  heroRoute.isCurrent) {
                inviteHero(hero, tag);
              }
            }
          }
          element.visitChildren(visitor);
        }
    
        context.visitChildElements(visitor);
        return result;
      }
    
      @override
      _ColorHeroState createState() => _ColorHeroState();
    
      @override
      void debugFillProperties(DiagnosticPropertiesBuilder properties) {
        super.debugFillProperties(properties);
        properties.add(DiagnosticsProperty<Object>('tag', tag));
      }
    }
    
    class _ColorHeroState extends State<ColorHero> {
      final GlobalKey _key = GlobalKey();
      Size _placeholderSize;
    
      bool _shouldIncludeChild = true;
    
      void startFlight({bool shouldIncludedChildInPlaceholder = false}) {
        _shouldIncludeChild = shouldIncludedChildInPlaceholder;
        assert(mounted);
        final RenderBox box = context.findRenderObject() as RenderBox;
        assert(box != null && box.hasSize);
        setState(() {
          _placeholderSize = box.size;
        });
      }
    
      void ensurePlaceholderIsHidden() {
        if (mounted) {
          setState(() {
            _placeholderSize = null;
          });
        }
      }
    
      void endFlight({bool keepPlaceholder = false}) {
        if (!keepPlaceholder) {
          ensurePlaceholderIsHidden();
        }
      }
    
      @override
      Widget build(BuildContext context) {
        assert(context.findAncestorWidgetOfExactType<ColorHero>() == null,
            'A Hero widget cannot be the descendant of another Hero widget.');
    
        final bool showPlaceholder = _placeholderSize != null;
    
        if (showPlaceholder && widget.placeholderBuilder != null) {
          return widget.placeholderBuilder(context, _placeholderSize, widget.child);
        }
    
        if (showPlaceholder && !_shouldIncludeChild) {
          return SizedBox(
            width: _placeholderSize.width,
            height: _placeholderSize.height,
          );
        }
    
        return SizedBox(
          width: _placeholderSize?.width,
          height: _placeholderSize?.height,
          child: Offstage(
            offstage: showPlaceholder,
            child: TickerMode(
              enabled: !showPlaceholder,
              child: KeyedSubtree(
                key: _key,
                child: ColoredBox(
                  color: widget.color,
                  child: widget.child,
                ),
              ),
            ),
          ),
        );
      }
    }
    
    class _HeroFlightManifest {
      _HeroFlightManifest({
        @required this.type,
        @required this.overlay,
        @required this.navigatorRect,
        @required this.fromRoute,
        @required this.toRoute,
        @required this.fromHero,
        @required this.toHero,
        @required this.createRectTween,
        @required this.shuttleBuilder,
        @required this.isUserGestureTransition,
        @required this.isDiverted,
      }) : assert(fromHero.widget.tag == toHero.widget.tag);
    
      final HeroFlightDirection type;
      final OverlayState overlay;
      final Rect navigatorRect;
      final PageRoute<dynamic> fromRoute;
      final PageRoute<dynamic> toRoute;
      final _ColorHeroState fromHero;
      final _ColorHeroState toHero;
      final CreateRectTween createRectTween;
      final HeroFlightShuttleBuilder shuttleBuilder;
      final bool isUserGestureTransition;
      final bool isDiverted;
    
      Object get tag => fromHero.widget.tag;
    
      Animation<double> get animation {
        return CurvedAnimation(
          parent: (type == HeroFlightDirection.push)
              ? toRoute.animation
              : fromRoute.animation,
          curve: Curves.fastOutSlowIn,
          reverseCurve: isDiverted ? null : Curves.fastOutSlowIn.flipped,
        );
      }
    
      @override
      String toString() {
        return '_HeroFlightManifest($type tag: $tag from route: ${fromRoute.settings} '
            'to route: ${toRoute.settings} with hero: $fromHero to $toHero)';
      }
    }
    
    class _HeroFlight {
      _HeroFlight(this.onFlightEnded) {
        _proxyAnimation = ProxyAnimation()
          ..addStatusListener(_handleAnimationUpdate);
      }
    
      final _OnFlightEnded onFlightEnded;
    
      Tween<Rect> heroRectTween;
      Tween<Color> colorTween;
    
      Widget shuttle;
    
      Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
      ProxyAnimation _proxyAnimation;
    
      _HeroFlightManifest manifest;
      OverlayEntry overlayEntry;
      bool _aborted = false;
    
      Tween<Rect> _doCreateRectTween(Rect begin, Rect end) {
        final CreateRectTween createRectTween =
            manifest.toHero.widget.createRectTween ?? manifest.createRectTween;
        if (createRectTween != null) return createRectTween(begin, end);
        return RectTween(begin: begin, end: end);
      }
    
      static final Animatable<double> _reverseTween =
          Tween<double>(begin: 1.0, end: 0.0);
    
      Widget _buildOverlay(BuildContext context) {
        assert(manifest != null);
        shuttle ??= manifest.shuttleBuilder(
          context,
          manifest.animation,
          manifest.type,
          manifest.fromHero.context,
          manifest.toHero.context,
        );
        assert(shuttle != null);
    
        return AnimatedBuilder(
          animation: _proxyAnimation,
          child: shuttle,
          builder: (BuildContext context, Widget child) {
            final RenderBox toHeroBox =
                manifest.toHero.context?.findRenderObject() as RenderBox;
            if (_aborted || toHeroBox == null || !toHeroBox.attached) {
              if (_heroOpacity.isCompleted) {
                _heroOpacity = _proxyAnimation.drive(
                  _reverseTween.chain(
                      CurveTween(curve: Interval(_proxyAnimation.value, 1.0))),
                );
              }
            } else if (toHeroBox.hasSize) {
              final RenderBox finalRouteBox =
                  manifest.toRoute.subtreeContext?.findRenderObject() as RenderBox;
              final Offset toHeroOrigin =
                  toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
              if (toHeroOrigin != heroRectTween.end.topLeft) {
                final Rect heroRectEnd = toHeroOrigin & heroRectTween.end.size;
                heroRectTween =
                    _doCreateRectTween(heroRectTween.begin, heroRectEnd);
              }
            }
    
            final Rect rect = heroRectTween.evaluate(_proxyAnimation);
            final Size size = manifest.navigatorRect.size;
            final RelativeRect offsets = RelativeRect.fromSize(rect, size);
    
            final color = ColorTween(
              begin: manifest.fromHero.widget.color,
              end: manifest.toHero.widget.color,
            ).evaluate(_proxyAnimation);
    
            return Positioned(
              top: offsets.top,
              right: offsets.right,
              bottom: offsets.bottom,
              left: offsets.left,
              child: IgnorePointer(
                child: RepaintBoundary(
                  child: Opacity(
                    opacity: _heroOpacity.value,
                    child: ColoredBox(
                      color: color,
                      child: child,
                    ),
                  ),
                ),
              ),
            );
          },
        );
      }
    
      void _handleAnimationUpdate(AnimationStatus status) {
        if (manifest.fromRoute?.navigator?.userGestureInProgress == true) return;
        if (status == AnimationStatus.completed ||
            status == AnimationStatus.dismissed) {
          _proxyAnimation.parent = null;
    
          assert(overlayEntry != null);
          overlayEntry.remove();
          overlayEntry = null;
    
          manifest.fromHero
              .endFlight(keepPlaceholder: status == AnimationStatus.completed);
          manifest.toHero
              .endFlight(keepPlaceholder: status == AnimationStatus.dismissed);
          onFlightEnded(this);
        }
      }
    
      void start(_HeroFlightManifest initialManifest) {
        assert(!_aborted);
        assert(() {
          final Animation<double> initial = initialManifest.animation;
          assert(initial != null);
          final HeroFlightDirection type = initialManifest.type;
          assert(type != null);
          switch (type) {
            case HeroFlightDirection.pop:
              return initial.value == 1.0 && initialManifest.isUserGestureTransition
                  ? initial.status == AnimationStatus.completed
                  : initial.status == AnimationStatus.reverse;
            case HeroFlightDirection.push:
              return initial.value == 0.0 &&
                  initial.status == AnimationStatus.forward;
          }
          return null;
        }());
    
        manifest = initialManifest;
    
        if (manifest.type == HeroFlightDirection.pop)
          _proxyAnimation.parent = ReverseAnimation(manifest.animation);
        else
          _proxyAnimation.parent = manifest.animation;
    
        manifest.fromHero.startFlight(
            shouldIncludedChildInPlaceholder:
                manifest.type == HeroFlightDirection.push);
        manifest.toHero.startFlight();
    
        heroRectTween = _doCreateRectTween(
          _boundingBoxFor(
              manifest.fromHero.context, manifest.fromRoute.subtreeContext),
          _boundingBoxFor(manifest.toHero.context, manifest.toRoute.subtreeContext),
        );
    
        overlayEntry = OverlayEntry(builder: _buildOverlay);
        manifest.overlay.insert(overlayEntry);
      }
    
      void divert(_HeroFlightManifest newManifest) {
        assert(manifest.tag == newManifest.tag);
        if (manifest.type == HeroFlightDirection.push &&
            newManifest.type == HeroFlightDirection.pop) {
          assert(newManifest.animation.status == AnimationStatus.reverse);
          assert(manifest.fromHero == newManifest.toHero);
          assert(manifest.toHero == newManifest.fromHero);
          assert(manifest.fromRoute == newManifest.toRoute);
          assert(manifest.toRoute == newManifest.fromRoute);
    
          _proxyAnimation.parent = ReverseAnimation(newManifest.animation);
          heroRectTween = ReverseTween<Rect>(heroRectTween);
        } else if (manifest.type == HeroFlightDirection.pop &&
            newManifest.type == HeroFlightDirection.push) {
          assert(newManifest.animation.status == AnimationStatus.forward);
          assert(manifest.toHero == newManifest.fromHero);
          assert(manifest.toRoute == newManifest.fromRoute);
    
          _proxyAnimation.parent = newManifest.animation.drive(
            Tween<double>(
              begin: manifest.animation.value,
              end: 1.0,
            ),
          );
          if (manifest.fromHero != newManifest.toHero) {
            manifest.fromHero.endFlight(keepPlaceholder: true);
            newManifest.toHero.startFlight();
            heroRectTween = _doCreateRectTween(
              heroRectTween.end,
              _boundingBoxFor(
                  newManifest.toHero.context, newManifest.toRoute.subtreeContext),
            );
          } else {
            heroRectTween =
                _doCreateRectTween(heroRectTween.end, heroRectTween.begin);
          }
        } else {
          assert(manifest.fromHero != newManifest.fromHero);
          assert(manifest.toHero != newManifest.toHero);
    
          heroRectTween = _doCreateRectTween(
            heroRectTween.evaluate(_proxyAnimation),
            _boundingBoxFor(
                newManifest.toHero.context, newManifest.toRoute.subtreeContext),
          );
          shuttle = null;
    
          if (newManifest.type == HeroFlightDirection.pop)
            _proxyAnimation.parent = ReverseAnimation(newManifest.animation);
          else
            _proxyAnimation.parent = newManifest.animation;
    
          manifest.fromHero.endFlight(keepPlaceholder: true);
          manifest.toHero.endFlight(keepPlaceholder: true);
    
          newManifest.fromHero.startFlight(
              shouldIncludedChildInPlaceholder:
                  newManifest.type == HeroFlightDirection.push);
          newManifest.toHero.startFlight();
    
          overlayEntry.markNeedsBuild();
        }
    
        _aborted = false;
        manifest = newManifest;
      }
    
      void abort() {
        _aborted = true;
      }
    
      @override
      String toString() {
        final RouteSettings from = manifest.fromRoute.settings;
        final RouteSettings to = manifest.toRoute.settings;
        final Object tag = manifest.tag;
        return 'HeroFlight(for: $tag, from: $from, to: $to ${_proxyAnimation.parent})';
      }
    }
    
    class ColorHeroController extends NavigatorObserver {
      ColorHeroController({this.createRectTween});
    
      final CreateRectTween createRectTween;
    
      final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
    
      @override
      void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
        assert(navigator != null);
        assert(route != null);
        _maybeStartHeroTransition(
            previousRoute, route, HeroFlightDirection.push, false);
      }
    
      @override
      void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
        assert(navigator != null);
        assert(route != null);
    
        if (!navigator.userGestureInProgress)
          _maybeStartHeroTransition(
              route, previousRoute, HeroFlightDirection.pop, false);
      }
    
      @override
      void didReplace({Route<dynamic> newRoute, Route<dynamic> oldRoute}) {
        assert(navigator != null);
        if (newRoute?.isCurrent == true) {
          _maybeStartHeroTransition(
              oldRoute, newRoute, HeroFlightDirection.push, false);
        }
      }
    
      @override
      void didStartUserGesture(Route<dynamic> route, Route<dynamic> previousRoute) {
        assert(navigator != null);
        assert(route != null);
        _maybeStartHeroTransition(
            route, previousRoute, HeroFlightDirection.pop, true);
      }
    
      @override
      void didStopUserGesture() {
        if (navigator.userGestureInProgress) return;
    
        bool isInvalidFlight(_HeroFlight flight) {
          return flight.manifest.isUserGestureTransition &&
              flight.manifest.type == HeroFlightDirection.pop &&
              flight._proxyAnimation.isDismissed;
        }
    
        final List<_HeroFlight> invalidFlights =
            _flights.values.where(isInvalidFlight).toList(growable: false);
    
        for (final _HeroFlight flight in invalidFlights) {
          flight._handleAnimationUpdate(AnimationStatus.dismissed);
        }
      }
    
      void _maybeStartHeroTransition(
        Route<dynamic> fromRoute,
        Route<dynamic> toRoute,
        HeroFlightDirection flightType,
        bool isUserGestureTransition,
      ) {
        if (toRoute != fromRoute &&
            toRoute is PageRoute<dynamic> &&
            fromRoute is PageRoute<dynamic>) {
          final PageRoute<dynamic> from = fromRoute;
          final PageRoute<dynamic> to = toRoute;
          final Animation<double> animation =
              (flightType == HeroFlightDirection.push)
                  ? to.animation
                  : from.animation;
    
          switch (flightType) {
            case HeroFlightDirection.pop:
              if (animation.value == 0.0) {
                return;
              }
              break;
            case HeroFlightDirection.push:
              if (animation.value == 1.0) {
                return;
              }
              break;
          }
    
          if (isUserGestureTransition &&
              flightType == HeroFlightDirection.pop &&
              to.maintainState) {
            _startHeroTransition(
                from, to, animation, flightType, isUserGestureTransition);
          } else {
            to.offstage = to.animation.value == 0.0;
    
            WidgetsBinding.instance.addPostFrameCallback((Duration value) {
              _startHeroTransition(
                  from, to, animation, flightType, isUserGestureTransition);
            });
          }
        }
      }
    
      void _startHeroTransition(
        PageRoute<dynamic> from,
        PageRoute<dynamic> to,
        Animation<double> animation,
        HeroFlightDirection flightType,
        bool isUserGestureTransition,
      ) {
        if (navigator == null ||
            from.subtreeContext == null ||
            to.subtreeContext == null) {
          to.offstage = false;
          return;
        }
    
        final Rect navigatorRect = _boundingBoxFor(navigator.context);
    
        final Map<Object, _ColorHeroState> fromHeroes = ColorHero._allHeroesFor(
            from.subtreeContext, isUserGestureTransition, navigator);
        final Map<Object, _ColorHeroState> toHeroes = ColorHero._allHeroesFor(
            to.subtreeContext, isUserGestureTransition, navigator);
    
        to.offstage = false;
    
        for (final Object tag in fromHeroes.keys) {
          if (toHeroes[tag] != null) {
            final HeroFlightShuttleBuilder fromShuttleBuilder =
                fromHeroes[tag].widget.flightShuttleBuilder;
            final HeroFlightShuttleBuilder toShuttleBuilder =
                toHeroes[tag].widget.flightShuttleBuilder;
            final bool isDiverted = _flights[tag] != null;
    
            final _HeroFlightManifest manifest = _HeroFlightManifest(
              type: flightType,
              overlay: navigator.overlay,
              navigatorRect: navigatorRect,
              fromRoute: from,
              toRoute: to,
              fromHero: fromHeroes[tag],
              toHero: toHeroes[tag],
              createRectTween: createRectTween,
              shuttleBuilder: toShuttleBuilder ??
                  fromShuttleBuilder ??
                  _defaultHeroFlightShuttleBuilder,
              isUserGestureTransition: isUserGestureTransition,
              isDiverted: isDiverted,
            );
    
            if (isDiverted)
              _flights[tag].divert(manifest);
            else
              _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
          } else if (_flights[tag] != null) {
            _flights[tag].abort();
          }
        }
    
        for (final Object tag in toHeroes.keys) {
          if (fromHeroes[tag] == null) toHeroes[tag].ensurePlaceholderIsHidden();
        }
      }
    
      void _handleFlightEnded(_HeroFlight flight) {
        _flights.remove(flight.manifest.tag);
      }
    
      static final HeroFlightShuttleBuilder _defaultHeroFlightShuttleBuilder = (
        BuildContext flightContext,
        Animation<double> animation,
        HeroFlightDirection flightDirection,
        BuildContext fromHeroContext,
        BuildContext toHeroContext,
      ) {
        final ColorHero toHero = toHeroContext.widget as ColorHero;
        return toHero.child;
      };
    }