交错动画由连续或重叠的动画组成。动画可能是纯连续的,一个变化发生在下一个变化之后,也可能部分或完全重叠。当动画是连续的时,元素会按顺序进行动画,每个元素的开始时间之间会略有延迟。这会产生级联或涟漪效果,动画看起来是分阶段而不是一次性移动元素。
交错动画被视为一种微交互,因为它们通过提供微妙的交互式反馈来引导用户浏览界面,从而增强用户体验。在 Flutter 中,您可以使用隐式或显式动画制作微妙的动画来构建微交互。
就上下文而言,隐式动画设计简单易用,因为动画细节被抽象出来,而显式动画更适合复杂动画,因为它们提供对动画过程的完全控制。这尤其适用于您需要对动画进行更细粒度的控制时。
在本文中,我们将深入研究微交互的概念,然后对于微交互用例,我们将使用显式动画来创建交错动画,为列小部件中包含的子项设置动画。
优秀的产品是功能和细节都表现出色的产品。功能吸引人们购买您的产品,但细节留住他们。这些细节让您的应用脱颖而出。借助微交互,您可以通过向用户提供令人愉悦的反馈来创建这些细节。
微交互的概念基于这样的理念:小细节会对整体用户体验产生重大影响。微交互可用于实现基本功能,例如传达反馈或操作结果。微交互的示例包括:
下面,我们可以看到微交互的实际应用,这些微妙的动画是在 Flutter 中创建的,以提升用户体验。👇🏽
这些应用程序的设计参考来自 Dribbble
在此示例中,我们将创建一个交错动画,当滑动该页面时,该动画将为列小部件中的子项设置动画。这将使用显式动画方法构建,因为在这种情况下,我们需要完全控制动画的运行方式。
完成后的动画效果如下 👇🏽
为了充分利用本教程,您应该具备以下条件:
一旦您检查完了项目的所有先决条件,我们就可以开始研究了。
首先,我们将构建包含这两个页面的主屏幕。这两个页面将包装在一个页面视图小部件中,该小部件控制滑动时显示哪个页面。此外,在主屏幕的底部,我们有一个指示器,显示我们当前所在的页面。
class MainScreen extends StatefulWidget { const MainScreen({super.key}); @override State<MainScreen> createState() => _MainScreenState(); } class _MainScreenState extends State<MainScreen>{ final controller = PageController(keepPage: true); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: PageView( controller: controller, children: [ Page1(pageController: controller,), Page2(pageController: controller,), ], ), ), bottomNavigationBar: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 10), height: 30, child: SmoothPageIndicator( controller: controller, count: 2, effect: const JumpingDotEffect( dotHeight: 10, dotWidth: 10, activeDotColor: Colors.grey, dotColor: Colors.black12, ), ), ) ); } }
上述代码片段中使用的SmoothPageIndicator
可以在pub.dev上找到。在这里, SmoothPageIndicator
用于显示当前页面。您可以像这样将包添加到您的pubspec.yaml
中 👇🏽
dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0
在这两个页面中,我们将有一个带有多个空卡片小部件的列小部件。这些空卡片小部件将用于填充列,以便我们可以充分看到和欣赏交错动画。我们将创建空卡片小部件作为可重复使用的组件,这样我们就不必在每次使用它时从头开始构建它。
class EmptyCard extends StatelessWidget { const EmptyCard({super.key, required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( height: height, width: width, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), color: Colors.blue.shade200, boxShadow: const [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0,4), ) ] ), ); } }
完成所有这些样板代码后,我们现在开始构建动画。首先,我们将创建一个新的有状态小部件,我们将其称为AnimateWidget
。它将有几个参数来控制动画的运行方式。
class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); }
使用上面代码片段中看到的AnimateWidget
的参数,我们可以成功控制:
pageController
,当页面滑动时触发动画。
接下来,在AnimateWiget
中,我们将定义以下内容:
AnimationController
:用于控制动画序列。
当前页面:将用于保存当前页面数据。
计时器:将用于在一定延迟后触发动画;这将带来交错动画级联效果。
class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); }
分解上面的代码片段,你会注意到:
AnimationController
来控制动画序列,为此,我们引入了SingleTickerProviderStateMixin
。
init state
方法中,我们初始化了AnimationController
,然后在页面进入和页面滑动时使用animationController.forward
触发动画。
dispose
方法中,我们清理了资源。
AutomaticKeepAliveClientMixin
来保存小部件的状态。这是为了防止在页面被滑动且不再可见时丢弃AnimationController
。
在getDelay
函数中,我们计算了每个元素触发动画之前的延迟时间。为了实现这一点,我们将持续时间(以毫秒为单位)除以 6,然后计算近似值,再将其乘以位置。这就是元素的位置(在本例中为索引)。
在 Build 方法中,我们返回一个AnimatedBuilder
。在这个动画构建器中,我们将返回一个名为_slideAnimation
的小部件函数,该函数返回一个Transform.translate
小部件。在_slideAnimation
函数中,我们有offsetAnimation
函数。
offsetAnimation
函数返回Transform.translate
小部件中使用的 Animation 属性。Transform.translate Transform.translate
部件使用动画中的值来为子小部件设置动画。
@override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); }
这是AnimateWidget
类的完整代码👇🏽
class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } }
接下来,要在列小部件中使用此AnimateWidget
类,我们将创建一个具有静态方法的类,该方法名为toStaggeredList
,该方法返回一个列表,在此方法中,我们传递所有需要的参数,包括列表children
。 children
参数是我们传递要设置动画的元素列表的地方。
接下来,我们将映射子项,并用AnimateWidget
包装每个子项。
class AnimateList{ static List<Widget>toStaggeredList({ required Duration duration, double? horizontalOffset, required PageController controller, required List<Widget>children, })=> children .asMap() .map((index, widget){ return MapEntry( index, AnimateWidget( duration: duration, position: index, horizontalOffset: horizontalOffset, controller: controller, child: widget, ) ); }) .values .toList(); }
在AnimateWidget
中,我们传入所需的参数,以便成功为列表中的每个子项设置动画。使用AnimateList.toStaggeredList
方法,我们现在可以在正在处理的两个页面上实现它。
class Page1 extends StatelessWidget { const Page1({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), ], ), ), ); } } class Page2 extends StatelessWidget { const Page2({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 200, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), ], ), ), ); } }
在列控件的子项中,我们将传递AnimateList.toStaggeredList
并传递所需的参数,包括要在列中显示的小部件。这样,我们就成功创建了在滑动时触发的交错动画。您可以在此处查看完整代码。
这是我们的最终结果:
我们已经到了本教程的结尾。至此,我们介绍了微交互的概念及其对用户体验的影响。我们还介绍了在页面滑动时触发的列小部件中项目交错动画的构建过程。
您可以将多种微交互添加到您的应用中,以改善项目的用户体验;您可以通过在 Flutter 中构建更多交互式动画进行实验。
如果你觉得这篇文章对你有帮助,可以点赞或者留言支持一下。你也可以关注我获取更多相关文章。