Skip to content

Instantly share code, notes, and snippets.

@HansMuller
Created November 17, 2022 15:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save HansMuller/b8974d9718f0d506e72b0e4d63163d05 to your computer and use it in GitHub Desktop.
Save HansMuller/b8974d9718f0d506e72b0e4d63163d05 to your computer and use it in GitHub Desktop.
/*
The overall goal here is to trigger an animated transition when the width of the application's window crosses a "breakpoint" threshold. When the window's widget is less than the breakpoint's value we'll show a bottom NavigationBar in the app's Scaffold. Otherwise we'll show a NavigationRail to the left or right of the app's body, depending on the prevailing text direction (see Directionality). The animated transition from bottom navigation bar to rail happens in two discrete steps. For example if window's width gets small and we're transitioning from showing the navigation rail to showing the bottom navgation bar:
1 - Slide the rail out of view and return its horizontal space to the app's body.
2 - Slide the bar into up into view, compressing the app's height.
These two steps, which happen one after the other, will happen over 2500ms and be driven by a single AnimationController. All of the component transitions will happen in the half of that time, so the controller and its duration are defined like this:
const double transitionLength = 1250; // Type double will be convenient later on
controller = AnimationController(
duration: Duration(milliseconds: transitionLength.toInt() * 2),
value: 0,
vsync: this,
);
The app uses an AnimatedBuilder, to rebuild itself each time the controller "ticks", i.e. whenever the controller is running.
When the app window's breakpoint width is crossed, the controller will be run forward or in reverse. This happens in the app State's didChangeDependencies method. This is significant for several reasons:
- The didChangeDependencies method runs after initState() and each time an inherited widget that app depends on changes. In this case "that the app depends on" means a reference to an inherited widget, like MediaQuery.of(), that's made in the app's didiChangeDependencies, didUpdateWidget, or build method.
- Unlike initState(), didChangeDependencies can depend on inherited widgets that are looked up via the State's context.
@override
void didChangeDependencies() {
super.didChangeDependencies();
final double width = MediaQuery.of(context).size.width;
final AnimationStatus status = controller.status;
if (width > 600) {
controller.forward(); // Show the NavigationRail
} else {
controller.reverse(); // Show the bottom NavigationBar
}
}
[TBD explain the advantages and disadvantages of using a LayoutBuilder instead]
To create two animations that run one after the other, we use the Interval curve. Interval is a way to take a parent animation that runs from 0 to 1 and have it run within a subset of the parent animation's interval.
railAnimation = CurvedAnimation(
parent: controller,
curve: const Interval(0.5, 1.0),
);
barAnimation = ReverseAnimation(
CurvedAnimation(
parent: controller,
curve: const Interval(0.0, 0.5),
),
);
The duration of both of these animations will be transitionLength (1250ms). Both animations run from 0 to 1 and the corresponding widgets must be hidden when the animation's value is 0 and shown when the animation's value is 1. That's why barAnimation runs in reverse - only one navigation widget is visible at a time.
The transition animations for both navigation widgets will be essentially the same. Showing the widget happens in two overlapping steps:
- Make room for the widget (starts at 250ms, ends at 1000ms)
- Slide the widget into view (starts at 500ms, ends at 1250ms)
Hiding the widget is a little different:
- Remove the space occupied by the widget (starts at 0, ends at 250ms)
- Slide the widget out of view (starts at 0, ends at 250ms)
The animation start and end times ensure that the disappearing widget will be hidden between 0 and 250ms and the appearing widget will show up between 250ms and 1250ms (transitionLength).
All of the animations are "curved". Rather than just linearly ramping from 0 to 1, their values change according to Curves.easeInOutCubicEmphasized. The curve makes the resultant motion much easier to understand.
class SizeAnimation extends CurvedAnimation {
SizeAnimation(Animation<double> parent) : super(
parent: parent,
curve: const Interval(
250 / transitionLength, 1000 / transitionLength,
curve: Curves.easeInOutCubicEmphasized,
),
reverseCurve: Interval(
0, 250 / transitionLength,
curve: Curves.easeInOutCubicEmphasized.flipped,
),
);
}
class OffsetAnimation extends CurvedAnimation {
OffsetAnimation(Animation<double> parent) : super(
parent: parent,
curve: const Interval(
500 / transitionLength, 1.0,
curve: Curves.easeInOutCubicEmphasized,
),
reverseCurve: Interval(
0, 250 / transitionLength,
curve: Curves.easeInOutCubicEmphasized.flipped,
),
);
}
The animated transitions are implemented by two similar widgets. BarTransition is slightly simpler. It uses the animation provided by the app to scale a backgroundColor container whose size matches the NavigationBar child, and to horizontally translate the child itself. The Align and FractionalTranslation widgets make doing this straightforward. When the animation runs the child is larger than its Align parent (whose height is growing or shrinking), so a ClipRect is used to hide the overflow. It's significant that child is only laid out once, and always occupies its intrinsic size.
class BarTransition extends StatefulWidget {
const BarTransition({ super.key, required this.animation, required this.backgroundColor, required this.child });
final Animation<double> animation;
final Color backgroundColor;
final Widget child;
@override
State<BarTransition> createState() => _BarTransition();
}
class _BarTransition extends State<BarTransition> {
late final Animation<Offset> offsetAnimation;
late final Animation<double> heightAnimation;
@override
void initState() {
super.initState();
offsetAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(OffsetAnimation(widget.animation));
heightAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(SizeAnimation(widget.animation));
}
@override
Widget build(BuildContext context) {
return ClipRect(
child: DecoratedBox(
decoration: BoxDecoration(color: widget.backgroundColor),
child: Align(
alignment: Alignment.topLeft,
heightFactor: heightAnimation.value,
child: FractionalTranslation(
translation: offsetAnimation.value,
child: widget.child,
),
),
),
);
}
}
The app's floating action button appears at the top of the rail or above the end of the bottom navigation bar. When the layout changes the floating action button disappears from its old location and then reappears at the new one. The floating action button's transition is handled in the same as as the navigation rail and bottom navigation bar, except that it starts to appear a little later and its appearance completes a little earlier:
Interval(
750 / transitionLength, 1000 / transitionLength,
curve: Curves.easeInOutCubicEmphasized
)
SIDEBAR: The perils of animating the Material widget and components based on the Material widget. For reasons lost to history, the Material widget implicitly animates changes to its shape, elevation, and many of its color properties. If an application tries to animate these properties itself, or tries to animate properties of a component which passes them through to its Material widget, the results will be both confusing and wrong. This is because, each time a Material property like shape is reset, the Material widget will start a new 200ms animation that changes the old value to the new one (see for example https://api.flutter.dev/flutter/material/Material/animationDuration.html). Thankfully, Material doesn't implicitly animate its color property.
The app's body displays one column when the app window's width is less than the width breakpoint (600) and two columns when the app is wider. The allocation of the column widths gradually changes as the app gets wider. When the app's width is less than 800 the two columns split the available width 50/50. As the window gets wider, the first column gets a smaller percentage of the width, until the app's width limt is is reached and the first column gets 25% of the available width.
The transition from one column to two columns is similar to the others. Before the second column appears the first column becomes narrower as space is made for the second. Once that change is underway, the second column slides into place.
class OneTwoTransition extends StatefulWidget {
const OneTwoTransition({
super.key,
required this.animation,
required this.backgroundColor,
required this.one,
required this.two,
});
final Animation<double> animation;
final Color backgroundColor;
final Widget one;
final Widget two;
@override
State<OneTwoTransition> createState() => _OneTwoTransitionState();
}
class _OneTwoTransitionState extends State<OneTwoTransition> {
late final Animation<Offset> offsetAnimation;
late Animation<double> widthAnimation;
@override
void initState() {
super.initState();
offsetAnimation = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(OffsetAnimation(widget.animation));
}
@override
@override
void didChangeDependencies() {
super.didChangeDependencies();
// When the app's width is < 800, widgets one and two get 1/2 of
// the available width, As the app gets wider, the allocation
// gradually changes to 1/3 and 2/3 for widgets one and two. When
// the window is wider than 1600, the allocation changes to 1/4 3/4.
final double width = MediaQuery.of(context).size.width;
double end = 1000;
if (width >= 800 && width < 1200) {
end = lerpDouble(1000, 2000, (width - 800) / 400)!;
} else if (width >= 1200 && width < 1600) {
end = lerpDouble(2000, 3000, (width - 1200) / 400)!;
} else if (width > 1600) {
end = 3000;
}
widthAnimation = Tween<double>(
begin: 0,
end: end,
).animate(SizeAnimation(widget.animation));
}
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Flexible(
flex: 1000,
child: widget.one,
),
Flexible(
flex: widthAnimation.value.toInt(),
child: FractionalTranslation(
translation: offsetAnimation.value,
child: widget.two,
),
),
],
);
}
}
*/
import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
const double transitionLength = 1250;
class SizeAnimation extends CurvedAnimation {
SizeAnimation(Animation<double> parent) : super(
parent: parent,
curve: const Interval(
250 / transitionLength, 1000 / transitionLength,
curve: Curves.easeInOutCubicEmphasized,
),
reverseCurve: Interval(
0, 250 / transitionLength,
curve: Curves.easeInOutCubicEmphasized.flipped,
),
);
}
class OffsetAnimation extends CurvedAnimation {
OffsetAnimation(Animation<double> parent) : super(
parent: parent,
curve: const Interval(
500 / transitionLength, 1.0,
curve: Curves.easeInOutCubicEmphasized,
),
reverseCurve: Interval(
0, 250 / transitionLength,
curve: Curves.easeInOutCubicEmphasized.flipped,
),
);
}
class RailTransition extends StatefulWidget {
const RailTransition({ super.key, required this.animation, required this.backgroundColor, required this.child });
final Animation<double> animation;
final Widget child;
final Color backgroundColor;
@override
State<RailTransition> createState() => _RailTransition();
}
class _RailTransition extends State<RailTransition> {
late Animation<Offset> offsetAnimation;
late Animation<double> widthAnimation;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// The animations are only rebuilt by this method when the text
// direction changes because this widget only depends on Directionality.
final bool ltr = Directionality.of(context) == TextDirection.ltr;
widthAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(SizeAnimation(widget.animation));
offsetAnimation = Tween<Offset>(
begin: ltr ? const Offset(-1, 0) : const Offset(1, 0),
end: Offset.zero,
).animate(OffsetAnimation(widget.animation));
}
@override
Widget build(BuildContext context) {
return ClipRect(
child: DecoratedBox(
decoration: BoxDecoration(color: widget.backgroundColor),
child: Align(
alignment: Alignment.topLeft,
widthFactor: widthAnimation.value,
child: FractionalTranslation(
translation: offsetAnimation.value,
child: widget.child,
),
),
),
);
}
}
class BarTransition extends StatefulWidget {
const BarTransition({ super.key, required this.animation, required this.backgroundColor, required this.child });
final Animation<double> animation;
final Color backgroundColor;
final Widget child;
@override
State<BarTransition> createState() => _BarTransition();
}
class _BarTransition extends State<BarTransition> {
late final Animation<Offset> offsetAnimation;
late final Animation<double> heightAnimation;
@override
void initState() {
super.initState();
offsetAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(OffsetAnimation(widget.animation));
heightAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(SizeAnimation(widget.animation));
}
@override
Widget build(BuildContext context) {
return ClipRect(
child: DecoratedBox(
decoration: BoxDecoration(color: widget.backgroundColor),
child: Align(
alignment: Alignment.topLeft,
heightFactor: heightAnimation.value,
child: FractionalTranslation(
translation: offsetAnimation.value,
child: widget.child,
),
),
),
);
}
}
class AnimatedFloatingActionButton extends StatefulWidget {
const AnimatedFloatingActionButton({ super.key, required this.animation, this.onPressed, this.child });
final Animation<double> animation;
final VoidCallback? onPressed;
final Widget? child;
@override
State<AnimatedFloatingActionButton> createState() => _AnimatedFloatingActionButton();
}
class _AnimatedFloatingActionButton extends State<AnimatedFloatingActionButton> {
late Animation<double> scaleAnimation;
late Animation<ShapeBorder?> shapeAnimation;
@override
void initState() {
super.initState();
scaleAnimation = CurvedAnimation(
parent: widget.animation,
curve: Interval(
750 / transitionLength, 1000 / transitionLength,
curve: Curves.easeInOutCubicEmphasized
),
reverseCurve: Interval(
0, 250 / transitionLength,
curve: Curves.easeInOutCubicEmphasized.flipped,
),
);
}
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
return ScaleTransition(
scale: scaleAnimation,
child: FloatingActionButton(
backgroundColor: colorScheme.tertiaryContainer,
foregroundColor: colorScheme.onTertiaryContainer,
onPressed: widget.onPressed,
child: widget.child,
),
);
}
}
class OneTwoTransition extends StatefulWidget {
const OneTwoTransition({
super.key,
required this.animation,
required this.backgroundColor,
required this.one,
required this.two,
});
final Animation<double> animation;
final Color backgroundColor;
final Widget one;
final Widget two;
@override
State<OneTwoTransition> createState() => _OneTwoTransitionState();
}
class _OneTwoTransitionState extends State<OneTwoTransition> {
late final Animation<Offset> offsetAnimation;
late Animation<double> widthAnimation;
@override
void initState() {
super.initState();
offsetAnimation = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(OffsetAnimation(widget.animation));
}
@override
@override
void didChangeDependencies() {
super.didChangeDependencies();
// When the app's width is < 800, widgets one and two get 1/2 of
// the available width, As the app gets wider, the allocation
// gradually changes to 1/3 and 2/3 for widgets one and two. When
// the window is wider than 1600, the allocation changes to 1/4 3/4.
final double width = MediaQuery.of(context).size.width;
double end = 1000;
if (width >= 800 && width < 1200) {
end = lerpDouble(1000, 2000, (width - 800) / 400)!;
} else if (width >= 1200 && width < 1600) {
end = lerpDouble(2000, 3000, (width - 1200) / 400)!;
} else if (width > 1600) {
end = 3000;
}
widthAnimation = Tween<double>(
begin: 0,
end: end,
).animate(SizeAnimation(widget.animation));
}
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Flexible(
flex: 1000,
child: widget.one,
),
Flexible(
flex: widthAnimation.value.toInt(),
child: FractionalTranslation(
translation: offsetAnimation.value,
child: widget.two,
),
),
],
);
}
}
class _Destination {
const _Destination(this.icon, this.label);
final IconData icon;
final String label;
}
class Home extends StatefulWidget {
const Home({ super.key });
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> with SingleTickerProviderStateMixin {
late final AnimationController controller;
late final railAnimation;
late final barAnimation;
int selectedIndex = 0;
bool controllerInitialized = false;
@override initState() {
super.initState();
controller = AnimationController(
duration: Duration(milliseconds: transitionLength.toInt() * 2),
value: 0,
vsync: this,
);
barAnimation = ReverseAnimation(
CurvedAnimation(
parent: controller,
curve: const Interval(0.0, 0.5),
),
);
railAnimation = CurvedAnimation(
parent: controller,
curve: const Interval(0.5, 1.0),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
const double breakpointWidth = 600;
final double width = MediaQuery.of(context).size.width;
final AnimationStatus status = controller.status;
if (width > breakpointWidth) {
if (status != AnimationStatus.forward && status != AnimationStatus.completed) {
controller.forward();
}
} else {
if (status != AnimationStatus.reverse && status != AnimationStatus.dismissed) {
controller.reverse();
}
}
if (!controllerInitialized) {
controllerInitialized = true;
controller.value = width > breakpointWidth ? 1 : 0;
}
}
final List<_Destination> destinations = const <_Destination>[
_Destination(Icons.inbox_rounded, 'Inbox'),
_Destination(Icons.article_outlined, 'Articles'),
_Destination(Icons.messenger_outline_rounded, 'Messages'),
_Destination(Icons.group_outlined, 'Groups'),
];
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
final Color backgroundColor = const Color(0xffecebf4);
return AnimatedBuilder(
animation: controller,
builder: (BuildContext context, Widget? child) {
return Scaffold(
body: Row(
children: <Widget>[
RailTransition(
animation: railAnimation,
backgroundColor: backgroundColor,
child: NavigationRail(
selectedIndex: selectedIndex,
backgroundColor: backgroundColor,
onDestinationSelected: (int index) {
setState(() {
selectedIndex = index;
});
},
leading: Column(
children: <Widget>[
IconButton(
onPressed: () { },
icon: Icon(Icons.menu),
),
SizedBox(height: 8),
AnimatedFloatingActionButton(
animation: railAnimation,
onPressed: () { },
child: const Icon(Icons.add),
),
],
),
groupAlignment: -0.85,
destinations: destinations.map<NavigationRailDestination>((_Destination d) {
return NavigationRailDestination(
icon: Icon(d.icon),
label: Text(d.label),
);
}).toList(),
),
),
Expanded(
child: OneTwoTransition(
animation: railAnimation,
backgroundColor: backgroundColor,
one: Padding(
padding: EdgeInsets.only(top: 16, bottom: 16, left: 16, right: 8),
child: Container(color: colorScheme.primaryContainer),
),
two: Padding(
padding: EdgeInsets.only(top: 16, bottom: 16, right: 16, left: 8),
child: Container(color: colorScheme.primaryContainer),
)
),
),
],
),
floatingActionButton: AnimatedFloatingActionButton(
animation: barAnimation,
onPressed: () { },
child: const Icon(Icons.add),
),
bottomNavigationBar: BarTransition(
animation: barAnimation,
backgroundColor: backgroundColor,
child: NavigationBar(
elevation: 0,
destinations: destinations.map<NavigationDestination>((_Destination d) {
return NavigationDestination(
icon: Icon(d.icon),
label: d.label,
);
}).toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (int index) {
setState(() {
selectedIndex = index;
});
},
),
),
);
}
);
}
}
void main() {
runApp(
MaterialApp(
theme: ThemeData(useMaterial3: true),
home: const Home(),
),
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment