创建交错菜单动画
一个应用程序屏幕可能包含多个动画。同时播放所有动画可能会让人感到不知所措。一个接一个地播放动画可能需要太长时间。更好的选择是交错动画。每个动画在不同的时间开始,但动画重叠以创建更短的持续时间。在本食谱中,您将构建一个带有动画内容的抽屉菜单,该菜单是交错的,并且有一个在底部弹出的按钮。
以下动画展示了应用程序的行为。
创建无动画的菜单
#抽屉菜单显示标题列表,并在菜单底部显示一个“开始使用”按钮。
定义一个名为Menu
的状态小部件,它以静态位置显示列表和按钮。
class Menu extends StatefulWidget {
const Menu({super.key});
@override
State<Menu> createState() => _MenuState();
}
class _MenuState extends State<Menu> {
static const _menuTitles = [
'Declarative Style',
'Premade Widgets',
'Stateful Hot Reload',
'Native Performance',
'Great Community',
];
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: Stack(
fit: StackFit.expand,
children: [
_buildFlutterLogo(),
_buildContent(),
],
),
);
}
Widget _buildFlutterLogo() {
// TODO: We'll implement this later.
return Container();
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
..._buildListItems(),
const Spacer(),
_buildGetStartedButton(),
],
);
}
List<Widget> _buildListItems() {
final listItems = <Widget>[];
for (var i = 0; i < _menuTitles.length; ++i) {
listItems.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
child: Text(
_menuTitles[i],
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
),
);
}
return listItems;
}
Widget _buildGetStartedButton() {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(24),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
backgroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
),
onPressed: () {},
child: const Text(
'Get Started',
style: TextStyle(
color: Colors.white,
fontSize: 22,
),
),
),
),
);
}
}
准备动画
#动画时序的控制需要一个AnimationController
。
将SingleTickerProviderStateMixin
添加到MenuState
类中。然后,声明并实例化一个AnimationController
。
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
late AnimationController _staggeredController;
@override
void initState() {
super.initState();
_staggeredController = AnimationController(
vsync: this,
);
}
}
@override
void dispose() {
_staggeredController.dispose();
super.dispose();
}
}
每次动画之前的延迟时间由您决定。定义动画延迟、单个动画持续时间和总动画持续时间。
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
static const _initialDelayTime = Duration(milliseconds: 50);
static const _itemSlideTime = Duration(milliseconds: 250);
static const _staggerTime = Duration(milliseconds: 50);
static const _buttonDelayTime = Duration(milliseconds: 150);
static const _buttonTime = Duration(milliseconds: 500);
final _animationDuration = _initialDelayTime +
(_staggerTime * _menuTitles.length) +
_buttonDelayTime +
_buttonTime;
}
在本例中,所有动画都延迟了 50 毫秒。之后,列表项开始出现。每个列表项的出现都比前一个列表项开始滑动进入延迟 50 毫秒。每个列表项从右到左滑动需要 250 毫秒。在最后一个列表项开始滑动进入后,底部的按钮再等待 150 毫秒弹出。按钮动画需要 500 毫秒。
定义每个延迟和动画持续时间后,计算总持续时间,以便可以用来计算单个动画时间。
以下图表显示了所需的动画时间。
为了在较大动画的子部分期间对值进行动画处理,Flutter 提供了Interval
类。Interval
接受开始时间百分比和结束时间百分比。然后,可以使用该Interval
在这些开始和结束时间之间对值进行动画处理,而不是使用整个动画的开始和结束时间。例如,对于一个持续 1 秒的动画,从 0.2 到 0.5 的间隔将从 200 毫秒(20%)开始,在 500 毫秒(50%)结束。
声明并计算每个列表项的Interval
和底部按钮Interval
。
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
final List<Interval> _itemSlideIntervals = [];
late Interval _buttonInterval;
@override
void initState() {
super.initState();
_createAnimationIntervals();
_staggeredController = AnimationController(
vsync: this,
duration: _animationDuration,
);
}
void _createAnimationIntervals() {
for (var i = 0; i < _menuTitles.length; ++i) {
final startTime = _initialDelayTime + (_staggerTime * i);
final endTime = startTime + _itemSlideTime;
_itemSlideIntervals.add(
Interval(
startTime.inMilliseconds / _animationDuration.inMilliseconds,
endTime.inMilliseconds / _animationDuration.inMilliseconds,
),
);
}
final buttonStartTime =
Duration(milliseconds: (_menuTitles.length * 50)) + _buttonDelayTime;
final buttonEndTime = buttonStartTime + _buttonTime;
_buttonInterval = Interval(
buttonStartTime.inMilliseconds / _animationDuration.inMilliseconds,
buttonEndTime.inMilliseconds / _animationDuration.inMilliseconds,
);
}
}
为列表项和按钮添加动画
#交错动画在菜单变得可见时立即播放。
在initState()
中启动动画。
@override
void initState() {
super.initState();
_createAnimationIntervals();
_staggeredController = AnimationController(
vsync: this,
duration: _animationDuration,
)..forward();
}
每个列表项从右到左滑动并同时淡入。
使用列表项的Interval
和easeOut
曲线为每个列表项的透明度和平移值制作动画。
List<Widget> _buildListItems() {
final listItems = <Widget>[];
for (var i = 0; i < _menuTitles.length; ++i) {
listItems.add(
AnimatedBuilder(
animation: _staggeredController,
builder: (context, child) {
final animationPercent = Curves.easeOut.transform(
_itemSlideIntervals[i].transform(_staggeredController.value),
);
final opacity = animationPercent;
final slideDistance = (1.0 - animationPercent) * 150;
return Opacity(
opacity: opacity,
child: Transform.translate(
offset: Offset(slideDistance, 0),
child: child,
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
child: Text(
_menuTitles[i],
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
return listItems;
}
使用相同的方法来动画化底部按钮的不透明度和缩放。这次,使用elasticOut
曲线来给按钮一个弹簧效果。
Widget _buildGetStartedButton() {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(24),
child: AnimatedBuilder(
animation: _staggeredController,
builder: (context, child) {
final animationPercent = Curves.elasticOut.transform(
_buttonInterval.transform(_staggeredController.value));
final opacity = animationPercent.clamp(0.0, 1.0);
final scale = (animationPercent * 0.5) + 0.5;
return Opacity(
opacity: opacity,
child: Transform.scale(
scale: scale,
child: child,
),
);
},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
backgroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
),
onPressed: () {},
child: const Text(
'Get Started',
style: TextStyle(
color: Colors.white,
fontSize: 22,
),
),
),
),
),
);
}
恭喜!您现在拥有一个动画菜单,其中每个列表项的出现是交错的,然后是一个弹出的底部按钮。
交互式示例
#import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleStaggeredAnimations(),
debugShowCheckedModeBanner: false,
),
);
}
class ExampleStaggeredAnimations extends StatefulWidget {
const ExampleStaggeredAnimations({
super.key,
});
@override
State<ExampleStaggeredAnimations> createState() =>
_ExampleStaggeredAnimationsState();
}
class _ExampleStaggeredAnimationsState extends State<ExampleStaggeredAnimations>
with SingleTickerProviderStateMixin {
late AnimationController _drawerSlideController;
@override
void initState() {
super.initState();
_drawerSlideController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
);
}
@override
void dispose() {
_drawerSlideController.dispose();
super.dispose();
}
bool _isDrawerOpen() {
return _drawerSlideController.value == 1.0;
}
bool _isDrawerOpening() {
return _drawerSlideController.status == AnimationStatus.forward;
}
bool _isDrawerClosed() {
return _drawerSlideController.value == 0.0;
}
void _toggleDrawer() {
if (_isDrawerOpen() || _isDrawerOpening()) {
_drawerSlideController.reverse();
} else {
_drawerSlideController.forward();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(),
body: Stack(
children: [
_buildContent(),
_buildDrawer(),
],
),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
title: const Text(
'Flutter Menu',
style: TextStyle(
color: Colors.black,
),
),
backgroundColor: Colors.transparent,
elevation: 0.0,
automaticallyImplyLeading: false,
actions: [
AnimatedBuilder(
animation: _drawerSlideController,
builder: (context, child) {
return IconButton(
onPressed: _toggleDrawer,
icon: _isDrawerOpen() || _isDrawerOpening()
? const Icon(
Icons.clear,
color: Colors.black,
)
: const Icon(
Icons.menu,
color: Colors.black,
),
);
},
),
],
);
}
Widget _buildContent() {
// Put page content here.
return const SizedBox();
}
Widget _buildDrawer() {
return AnimatedBuilder(
animation: _drawerSlideController,
builder: (context, child) {
return FractionalTranslation(
translation: Offset(1.0 - _drawerSlideController.value, 0.0),
child: _isDrawerClosed() ? const SizedBox() : const Menu(),
);
},
);
}
}
class Menu extends StatefulWidget {
const Menu({super.key});
@override
State<Menu> createState() => _MenuState();
}
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
static const _menuTitles = [
'Declarative style',
'Premade widgets',
'Stateful hot reload',
'Native performance',
'Great community',
];
static const _initialDelayTime = Duration(milliseconds: 50);
static const _itemSlideTime = Duration(milliseconds: 250);
static const _staggerTime = Duration(milliseconds: 50);
static const _buttonDelayTime = Duration(milliseconds: 150);
static const _buttonTime = Duration(milliseconds: 500);
final _animationDuration = _initialDelayTime +
(_staggerTime * _menuTitles.length) +
_buttonDelayTime +
_buttonTime;
late AnimationController _staggeredController;
final List<Interval> _itemSlideIntervals = [];
late Interval _buttonInterval;
@override
void initState() {
super.initState();
_createAnimationIntervals();
_staggeredController = AnimationController(
vsync: this,
duration: _animationDuration,
)..forward();
}
void _createAnimationIntervals() {
for (var i = 0; i < _menuTitles.length; ++i) {
final startTime = _initialDelayTime + (_staggerTime * i);
final endTime = startTime + _itemSlideTime;
_itemSlideIntervals.add(
Interval(
startTime.inMilliseconds / _animationDuration.inMilliseconds,
endTime.inMilliseconds / _animationDuration.inMilliseconds,
),
);
}
final buttonStartTime =
Duration(milliseconds: (_menuTitles.length * 50)) + _buttonDelayTime;
final buttonEndTime = buttonStartTime + _buttonTime;
_buttonInterval = Interval(
buttonStartTime.inMilliseconds / _animationDuration.inMilliseconds,
buttonEndTime.inMilliseconds / _animationDuration.inMilliseconds,
);
}
@override
void dispose() {
_staggeredController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: Stack(
fit: StackFit.expand,
children: [
_buildFlutterLogo(),
_buildContent(),
],
),
);
}
Widget _buildFlutterLogo() {
return const Positioned(
right: -100,
bottom: -30,
child: Opacity(
opacity: 0.2,
child: FlutterLogo(
size: 400,
),
),
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
..._buildListItems(),
const Spacer(),
_buildGetStartedButton(),
],
);
}
List<Widget> _buildListItems() {
final listItems = <Widget>[];
for (var i = 0; i < _menuTitles.length; ++i) {
listItems.add(
AnimatedBuilder(
animation: _staggeredController,
builder: (context, child) {
final animationPercent = Curves.easeOut.transform(
_itemSlideIntervals[i].transform(_staggeredController.value),
);
final opacity = animationPercent;
final slideDistance = (1.0 - animationPercent) * 150;
return Opacity(
opacity: opacity,
child: Transform.translate(
offset: Offset(slideDistance, 0),
child: child,
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
child: Text(
_menuTitles[i],
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
return listItems;
}
Widget _buildGetStartedButton() {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(24),
child: AnimatedBuilder(
animation: _staggeredController,
builder: (context, child) {
final animationPercent = Curves.elasticOut.transform(
_buttonInterval.transform(_staggeredController.value));
final opacity = animationPercent.clamp(0.0, 1.0);
final scale = (animationPercent * 0.5) + 0.5;
return Opacity(
opacity: opacity,
child: Transform.scale(
scale: scale,
child: child,
),
);
},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
backgroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
),
onPressed: () {},
child: const Text(
'Get started',
style: TextStyle(
color: Colors.white,
fontSize: 22,
),
),
),
),
),
);
}
}