建立交錯式選單動畫
單個應用程式畫面可能包含多個動畫。同時播放所有動畫可能會讓人眼花撩亂。依序播放動畫可能需要太長時間。更好的選擇是錯開動畫。每個動畫在不同的時間開始,但動畫會重疊,以縮短持續時間。在本食譜中,您將建立一個具有動畫內容的抽屜選單,該內容是錯開的,並且在底部有一個彈出的按鈕。
以下動畫顯示了應用程式的行為
建立不含動畫的選單
#抽屜選單會顯示標題列表,接著在選單底部顯示「開始使用」按鈕。
定義一個名為 Menu
的有狀態 Widget,在靜態位置顯示列表和按鈕。
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,
),
),
),
),
),
);
}
}
除非另有說明,否則本網站上的文件反映了 Flutter 的最新穩定版本。頁面上次更新時間為 2024-06-26。 檢視原始碼 或 回報問題。