Skip to content

Instantly share code, notes, and snippets.

@HansMuller
Created November 17, 2022 00:15
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/1546b6fca9e2cb1b9fb2331ccf022a4e to your computer and use it in GitHub Desktop.
Save HansMuller/1546b6fca9e2cb1b9fb2331ccf022a4e 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,
),
),
),
);
}
}
*/
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;
late Color backgroundColor;
late Color foregroundColor;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final ColorScheme colorScheme = Theme.of(context).colorScheme;
backgroundColor = colorScheme.tertiaryContainer;
foregroundColor = colorScheme.onTertiaryContainer;
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) {
return ScaleTransition(
scale: scaleAnimation,
child: FloatingActionButton(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
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 final Animation<double> widthAnimation;
@override
void initState() {
super.initState();
offsetAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(OffsetAnimation(widget.animation));
widthAnimation = Tween<double>(
begin: 0,
end: 2000,
).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();
final double width = MediaQuery.of(context).size.width;
final AnimationStatus status = controller.status;
if (width > 600) {
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 > 600 ? 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: Container(color: colorScheme.primaryContainer),
two: Container(color: colorScheme.surface),
),
),
],
),
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() {
timeDilation = 1;
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