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?
Modify Hero
with ColoredBox
, plus add new NavigatorObserver
to navigatorObservers
of MaterialApp
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;
};
}