前言
当前案例 Flutter SDK版本:3.13.2
显式动画
Tween({this.begin,this.end}) 两个构造参数,分别是 开始值 和 结束值,根据这两个值,提供了控制动画的方法,以下是常用的;
- controller.forward() : 向前,执行 begin 到 end 的动画,执行结束后,处于end状态;
- controller.reverse() : 反向,当动画已经完成,进行还原动画;
- controller.reset() : 重置,当动画已经完成,进行还原,注意这个是直接还原,没有动画;
使用方式一
使用 addListener() 和 setState();
import 'package:flutter/material.dart'; class TweenAnimation extends StatefulWidget { const TweenAnimation({super.key}); @override State
createState() => _TweenAnimationState(); } /// 使用 addListener() 和 setState() class _TweenAnimationState extends State with SingleTickerProviderStateMixin { late Animation animation; late AnimationController controller; @override void initState() { super.initState(); controller = AnimationController( duration: const Duration(milliseconds: 2000), vsync: this); animation = Tween (begin: 50, end: 100).animate(controller) ..addListener(() { setState(() {}); // 更新UI })..addStatusListener((status) { debugPrint('status:$status'); // 监听动画执行状态 }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( '显式动画', style: TextStyle(fontSize: 20), )), body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: animation.value, height: animation.value, child: const FlutterLogo(), ), ElevatedButton( onPressed: () { if (controller.isCompleted) { controller.reverse(); } else { controller.forward(); } // controller.forward(); // 向前,执行 begin 到 end 的动画,执行结束后,处于end状态 // controller.reverse(); // 反向,当动画已经完成,进行还原动画 // controller.reset(); // 重置,当动画已经完成,进行还原,注意这个是直接还原,没有动画 }, child: const Text('缩放'), ) ], ) ], ), ), ); } } 使用方式二
AnimatedWidget,解决痛点:不需要再使用 addListener() 和 setState();
import 'package:flutter/material.dart'; class TweenAnimation extends StatefulWidget { const TweenAnimation({super.key}); @override State
createState() => _TweenAnimationState(); } /// 测试 AnimatedWidget class _TweenAnimationState extends State with SingleTickerProviderStateMixin { late Animation animation; late AnimationController controller; @override void initState() { super.initState(); controller = AnimationController(duration: const Duration(milliseconds: 2000), vsync: this); animation = Tween (begin: 50, end: 100).animate(controller) ..addStatusListener((status) { debugPrint('status:$status'); // 监听动画执行状态 }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( '显式动画', style: TextStyle(fontSize: 20), )), body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedLogo(animation: animation), ElevatedButton( onPressed: () { if (controller.isCompleted) { controller.reverse(); } else { controller.forward(); } // controller.forward(); // 向前,执行 begin 到 end 的动画,执行结束后,处于end状态 // controller.reverse(); // 反向,当动画已经完成,进行还原动画 // controller.reset(); // 重置,当动画已经完成,进行还原,注意这个是直接还原,没有动画 }, child: const Text('缩放'), ) ], ) ], ), ), ); } } /// 使用 AnimatedWidget,创建显式动画 /// 解决痛点:不需要再使用 addListener() 和 setState() class AnimatedLogo extends AnimatedWidget { const AnimatedLogo({super.key, required Animation animation}) : super(listenable: animation); @override Widget build(BuildContext context) { final animation = listenable as Animation ; return Center( child: Container( margin: const EdgeInsets.symmetric(vertical: 10), width: animation.value, height: animation.value, child: const FlutterLogo(), ), ); } } 使用方式三
使用 内置的显式动画 widget;
后缀是 Transition 的组件,几乎都是 显式动画 widget;
import 'package:flutter/material.dart'; class TweenAnimation extends StatefulWidget { const TweenAnimation({super.key}); @override State
createState() => _TweenAnimationState(); } /// 使用 内置的显式动画Widget class _TweenAnimationState extends State with SingleTickerProviderStateMixin { late Animation animation; late AnimationController controller; @override void initState() { super.initState(); controller = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this); animation = Tween (begin: 0.1, end: 1.0).animate(controller) ..addListener(() { setState(() {}); // 更新UI }) ..addStatusListener((status) { debugPrint('status:$status'); // 监听动画执行状态 }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( '显式动画', style: TextStyle(fontSize: 20), )), body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ /// 单个显示动画 FadeTransition( opacity: animation, child: const SizedBox( width: 100, height: 100, child: FlutterLogo(), ), ), /// 多个显示动画 配合使用 // FadeTransition( // 淡入淡出 // opacity: animation, // child: RotationTransition( // 旋转 // turns: animation, // child: ScaleTransition( // 更替 // scale: animation, // child: const SizedBox( // width: 100, // height: 100, // child: FlutterLogo(), // ), // ), // ), // ), ElevatedButton( onPressed: () { if (controller.isCompleted) { controller.reverse(); } else { controller.forward(); } // controller.forward(); // 向前,执行 begin 到 end 的动画,执行结束后,处于end状态 // controller.reverse(); // 反向,当动画已经完成,进行还原动画 // controller.reset(); // 重置,当动画已经完成,进行还原,注意这个是直接还原,没有动画 }, child: const Text('淡入淡出'), ) ], ) ], ), ), ); } } 使用方式四
AnimatedBuilder,这种方式感觉是 通过逻辑 动态选择 Widget,比如 flag ? widgetA : widgetB;
官方解释:
- AnimatedBuilder 知道如何渲染过渡效果
- 但 AnimatedBuilder 不会渲染 widget,也不会控制动画对象。
- 使用 AnimatedBuilder 描述一个动画是其他 widget 构建方法的一部分。
- 如果只是单纯需要用可重复使用的动画定义一个 widget,可参考文档:简单使用 AnimatedWidget。
import 'package:flutter/material.dart'; class TweenAnimation extends StatefulWidget { const TweenAnimation({super.key}); @override State
createState() => _TweenAnimationState(); } /// 测试 AnimatedBuilder class _TweenAnimationState extends State with SingleTickerProviderStateMixin { late Animation animation; late AnimationController controller; @override void initState() { super.initState(); controller = AnimationController( duration: const Duration(milliseconds: 2000), vsync: this); animation = Tween (begin: 50, end: 100).animate(controller) ..addStatusListener((status) { debugPrint('status:$status'); // 监听动画执行状态 }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( '显式动画', style: TextStyle(fontSize: 20), )), body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ GrowTransition( animation: animation, child: const FlutterLogo()), ElevatedButton( onPressed: () { if (controller.isCompleted) { controller.reverse(); } else { controller.forward(); } // controller.forward(); // 向前,执行 begin 到 end 的动画,执行结束后,处于end状态 // controller.reverse(); // 反向,当动画已经完成,进行还原动画 // controller.reset(); // 重置,当动画已经完成,进行还原,注意这个是直接还原,没有动画 }, child: const Text('缩放'), ) ], ) ], ), ), ); } } class GrowTransition extends StatelessWidget { final Widget child; final Animation animation; const GrowTransition( {required this.child, required this.animation, super.key}); @override Widget build(BuildContext context) { return Center( child: AnimatedBuilder( animation: animation, builder: (context, child) { return SizedBox( width: animation.value, height: animation.value, child: child, ); }, child: child, ), ); } } 使用方式五
CurvedAnimation 曲线动画,一个Widget,同时使用多个动画;
import 'package:flutter/material.dart'; class TweenAnimation extends StatefulWidget { const TweenAnimation({super.key}); @override State
createState() => _TweenAnimationState(); } /// 测试 动画同步使用 class _TweenAnimationState extends State with SingleTickerProviderStateMixin { late Animation animation; late AnimationController controller; @override void initState() { super.initState(); controller = AnimationController(duration: const Duration(milliseconds: 2000), vsync: this); animation = CurvedAnimation(parent: controller, curve: Curves.easeIn) ..addStatusListener((status) { debugPrint('status:$status'); // 监听动画执行状态 }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( '显式动画', style: TextStyle(fontSize: 20), )), body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedLogoSync(animation: animation), ElevatedButton( onPressed: () { if (controller.isCompleted) { controller.reverse(); } else { controller.forward(); } // controller.forward(); // 向前,执行 begin 到 end 的动画,执行结束后,处于end状态 // controller.reverse(); // 反向,当动画已经完成,进行还原动画 // controller.reset(); // 重置,当动画已经完成,进行还原,注意这个是直接还原,没有动画 }, child: const Text('缩放 + 淡入淡出'), ) ], ) ], ), ), ); } } /// 动画同步使用 class AnimatedLogoSync extends AnimatedWidget { AnimatedLogoSync({super.key, required Animation animation}) : super(listenable: animation); final Tween _opacityTween = Tween (begin: 0.1, end: 1); final Tween _sizeTween = Tween (begin: 50, end: 100); @override Widget build(BuildContext context) { final animation = listenable as Animation ; return Center( child: Opacity( opacity: _opacityTween.evaluate(animation), child: SizedBox( width: _sizeTween.evaluate(animation), height: _sizeTween.evaluate(animation), child: const FlutterLogo(), ), ), ); } } 隐式动画
- 根据属性值变化,为 UI 中的 widget 添加动作并创造视觉效果,有些库包含各种各样可以帮你管理动画的widget,这些widgets被统称为 隐式动画 或 隐式动画 widget。
- 前缀是 Animated 的组件,几乎都是 隐式动画 widget;
import 'dart:math'; import 'package:flutter/material.dart'; class ImplicitAnimation extends StatefulWidget { const ImplicitAnimation({super.key}); @override State
createState() => _ImplicitAnimationState(); } class _ImplicitAnimationState extends State { double opacity = 0; late Color color; late double borderRadius; late double margin; double randomBorderRadius() { return Random().nextDouble() * 64; } double randomMargin() { return Random().nextDouble() * 32; } Color randomColor() { return Color(0xFFFFFFFF & Random().nextInt(0xFFFFFFFF)); } @override void initState() { super.initState(); color = randomColor(); borderRadius = randomBorderRadius(); margin = randomMargin(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( '隐式动画', style: TextStyle(fontSize: 20), )), body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedOpacity( opacity: opacity, curve: Curves.easeInOutBack, duration: const Duration(milliseconds: 1000), child: Container( width: 50, height: 50, margin: const EdgeInsets.only(right: 12), color: Colors.primaries[2], ), ), ElevatedButton( onPressed: () { if(opacity == 0) { opacity = 1; } else { opacity = 0; } setState(() {}); }, child: const Text('淡入或淡出'), ) ], ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedContainer( width: 50, height: 50, margin: EdgeInsets.all(margin), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(borderRadius) ), curve: Curves.easeInBack, duration: const Duration(milliseconds: 1000), ), ElevatedButton( onPressed: () { color = randomColor(); borderRadius = randomBorderRadius(); margin = randomMargin(); setState(() {}); }, child: const Text('形状变化'), ) ], ) ], ), ), ); } } 显示和隐式的区别
看图,隐式动画 就是 显示动画 封装后的产物,是不是很蒙,这有什么意义?
应用场景不同:如果想 控制动画,使用 显示动画,controller.forward()、controller.reverse()、controller.reset(),反之只是在Widget属性值发生改变,进行UI过渡这种简单操作,使用 隐式动画;
误区
Flutter显式动画的关键对象 Tween,翻译过来 补间,联想到 Android原生的补间动画,就会有一个问题,Android原生的补间动画,只是视觉上的UI变化,对象属性并非真正改变,那么Flutter是否也是如此?
答案:非也,是真的改变了,和Android原生补间动画不同,看图:
以下偏移动画,在Flutter中的,点击偏移后的矩形位置,可以触发提示,反之Android原生不可以,只能在矩形原来的位置,才能触发;
Flutte 提示库 以及 封装相关 的代码
fluttertoast: ^8.2.4
toast_util.dart
import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; class ToastUtil { static FToast fToast = FToast(); static void init(BuildContext context) { fToast.init(context); } static void showToast(String msg) { Widget toast = Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(25.0), color: Colors.greenAccent, ), alignment: Alignment.center, child: Text(msg), ) ], ); fToast.showToast( child: toast, gravity: ToastGravity.BOTTOM, toastDuration: const Duration(seconds: 2), ); } }
在Flutter主入口 初始化Toast配置
import 'package:flutter/material.dart'; import 'package:flutter_animation/util/toast_util.dart'; import 'package:fluttertoast/fluttertoast.dart'; void main() { runApp(const MyApp()); } GlobalKey
navigatorKey = GlobalKey (); // 配置一 class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( builder: FToastBuilder(), // 配置二 navigatorKey: navigatorKey, // 配置三 ... ... home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { @override void initState() { super.initState(); ToastUtil.init(context); // 配置四 } ... ... } Flutter显示动画 代码
import 'package:flutter/material.dart'; import 'package:flutter_animation/util/toast_util.dart'; class TweenAnimation extends StatefulWidget { const TweenAnimation({super.key}); @override State
createState() => _TweenAnimationState(); } /// 测试显式动画,属性是否真的改变了 class _TweenAnimationState extends State with SingleTickerProviderStateMixin { late Animation animation; late AnimationController controller; @override void initState() { super.initState(); controller = AnimationController( duration: const Duration(milliseconds: 500), vsync: this); animation = Tween (begin: const Offset(0, 0), end: const Offset(1.5, 0)) .animate(controller) ..addStatusListener((status) { debugPrint('status:$status'); // 监听动画执行状态 }); } @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( 'Flutter 显式动画', style: TextStyle(fontSize: 20), )), body: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, color: Colors.primaries[5], child: Stack( children: [ Align( alignment: Alignment.center, child: Container( width: 80, height: 80, decoration: BoxDecoration( border: Border.all(color: Colors.white, width: 1.0), ), ), ), Align( alignment: Alignment.center, child: SlideTransition( position: animation, child: InkWell( onTap: () { ToastUtil.showToast('点击了'); }, child: Container( width: 80, height: 80, color: Colors.primaries[2], ), ), ), ), Positioned( left: (MediaQuery.of(context).size.width / 2) - 35, top: 200, child: ElevatedButton( onPressed: () { if (controller.isCompleted) { controller.reverse(); } else { controller.forward(); } // controller.forward(); // 向前,执行 begin 到 end 的动画,执行结束后,处于end状态 // controller.reverse(); // 反向,当动画已经完成,进行还原动画 // controller.reset(); // 重置,当动画已经完成,进行还原,注意这个是直接还原,没有动画 }, child: const Text('偏移'), ), ) ], ), ), ); } } Flutter隐式动画 代码
import 'package:flutter/material.dart'; import 'package:flutter_animation/util/toast_util.dart'; class ImplicitAnimation extends StatefulWidget { const ImplicitAnimation({super.key}); @override State
createState() => _ImplicitAnimationState(); } /// 测试隐式动画,属性是否真的改变了 class _ImplicitAnimationState extends State { late double offsetX = 0; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( 'Flutter 隐式动画', style: TextStyle(fontSize: 20), )), body: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, color: Colors.primaries[5], child: Stack( children: [ Align( alignment: Alignment.center, child: Container( width: 80, height: 80, decoration: BoxDecoration( border: Border.all(color: Colors.white, width: 1.0), ), ), ), Align( alignment: Alignment.center, child: AnimatedSlide( offset: Offset(offsetX, 0), duration: const Duration(milliseconds: 500), child: InkWell( onTap: () { ToastUtil.showToast('点击了'); }, child: Container( width: 80, height: 80, color: Colors.primaries[2], ), ), ), ), Positioned( left: (MediaQuery.of(context).size.width / 2) - 35, top: 200, child: ElevatedButton( onPressed: () { if (offsetX == 0) { offsetX = 1.5; } else { offsetX = 0; } setState(() {}); }, child: const Text('偏移'), ), ) ], ), ), ); } } Android原生补间动画 代码
import android.app.Activity import android.os.Bundle import android.view.View import android.view.animation.TranslateAnimation import android.widget.Toast import com.example.flutter_animation.databinding.ActivityMainBinding class MainActivity : Activity(), View.OnClickListener { private lateinit var bind: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) bind = ActivityMainBinding.inflate(layoutInflater) setContentView(bind.root) bind.offsetX.setOnClickListener(this) bind.offsetBox.setOnClickListener(this) } private fun offsetAnimation() { val translateAnimation = TranslateAnimation(0f, 200f, 0f, 0f) translateAnimation.duration = 800 translateAnimation.fillAfter = true bind.offsetBox.startAnimation(translateAnimation) } override fun onClick(v: View?) { if (bind.offsetX == v) { offsetAnimation() } else if (bind.offsetBox == v) { Toast.makeText(this,"点击了",Toast.LENGTH_SHORT).show() } } }
Hero动画
应用于 元素共享 的动画。
下面这三个图片详情案例的使用方式,将 Widget 从 A页面 共享到 B页面 后,改变Widget大小,被称为 标准 hero 动画;
图片详情案例一:本地图片
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart' show timeDilation; class HeroAnimation extends StatefulWidget { const HeroAnimation({super.key}); @override State
createState() => _HeroAnimationState(); } /// 将 Widget 从 A页面 共享到 B页面 后,改变Widget大小 class _HeroAnimationState extends State { /// 测试本地图片 final List images = [ 'assets/images/01.jpg', 'assets/images/02.jpg', 'assets/images/03.jpg', 'assets/images/04.jpg', ]; @override Widget build(BuildContext context) { // 减慢动画速度,可以通过此值帮助开发, // 注意这个值是针对所有动画,所以路由动画也会受影响 // timeDilation = 10.0; return Scaffold( appBar: AppBar( title: const Text('Photo List Page'), ), body: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, alignment: Alignment.topLeft, child: GridView.count( padding: const EdgeInsets.all(10), crossAxisCount: 2, mainAxisSpacing: 10, crossAxisSpacing: 10, children: List.generate( images.length, (index) => PhotoHero( photo: images[index], size: 100, onTap: () { Navigator.of(context).push(CupertinoPageRoute ( builder: (context) => PhotoDetail( size: MediaQuery.of(context).size.width, photo: images[index]), )); }, )), ), ), ); } } class PhotoHero extends StatelessWidget { const PhotoHero({ super.key, required this.photo, this.onTap, required this.size, }); final String photo; final VoidCallback? onTap; final double size; @override Widget build(BuildContext context) { return SizedBox( width: size, height: size, child: Hero( tag: photo, child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, /// 测试本地图片 child: Image.asset( photo, fit: BoxFit.cover, ), ), ), ), ); } } class PhotoDetail extends StatelessWidget { const PhotoDetail({ super.key, required this.photo, required this.size, }); final String photo; final double size; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Photo Detail Page'), ), body: Column( children: [ Container( color: Colors.lightBlueAccent, padding: const EdgeInsets.all(16), alignment: Alignment.topCenter, child: PhotoHero( photo: photo, size: size, onTap: () { Navigator.of(context).pop(); }, ), ), const Text( '详情xxx', style: TextStyle(fontSize: 20), ) ], ), ); } } 图片详情案例二:网络图片
可以看出,在有延迟的情况下,效果没有本地图片好;
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart' show timeDilation; class HeroAnimation extends StatefulWidget { const HeroAnimation({super.key}); @override State
createState() => _HeroAnimationState(); } /// 将 Widget 从 A页面 共享到 B页面 后,改变Widget大小 class _HeroAnimationState extends State { /// 测试网络图片 final List images = [ 'https://img1.baidu.com/it/u=1161835547,3275770506&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=500', 'https://p9.toutiaoimg.com/origin/pgc-image/6d817289d3b44d53bb6e55aa81e41bd2?from=pc', 'https://img0.baidu.com/it/u=102503057,4196586556&fm=253&fmt=auto&app=138&f=BMP?w=500&h=724', 'https://lmg.jj20.com/up/allimg/1114/041421115008/210414115008-3-1200.jpg', ]; @override Widget build(BuildContext context) { // 减慢动画速度,可以通过此值帮助开发, // 注意这个值是针对所有动画,所以路由动画也会受影响 // timeDilation = 10.0; return Scaffold( appBar: AppBar( title: const Text('Photo List Page'), ), body: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, alignment: Alignment.topLeft, child: GridView.count( padding: const EdgeInsets.all(10), crossAxisCount: 2, mainAxisSpacing: 10, crossAxisSpacing: 10, children: List.generate( images.length, (index) => PhotoHero( photo: images[index], size: 100, onTap: () { Navigator.of(context).push(CupertinoPageRoute ( builder: (context) => PhotoDetail( size: MediaQuery.of(context).size.width, photo: images[index]), )); }, )), ), ), ); } } class PhotoHero extends StatelessWidget { const PhotoHero({ super.key, required this.photo, this.onTap, required this.size, }); final String photo; final VoidCallback? onTap; final double size; @override Widget build(BuildContext context) { return SizedBox( width: size, height: size, child: Hero( tag: photo, child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, /// 测试网络图片 child: Image.network( photo, fit: BoxFit.cover, ), ), ), ), ); } } class PhotoDetail extends StatelessWidget { const PhotoDetail({ super.key, required this.photo, required this.size, }); final String photo; final double size; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Photo Detail Page'), ), body: Column( children: [ Container( color: Colors.lightBlueAccent, padding: const EdgeInsets.all(16), alignment: Alignment.topCenter, child: PhotoHero( photo: photo, size: size, onTap: () { Navigator.of(context).pop(); }, ), ), const Text( '详情xxx', style: TextStyle(fontSize: 20), ) ], ), ); } } 图片详情案例三:背景透明
import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart' show timeDilation; class HeroAnimation extends StatefulWidget { const HeroAnimation({super.key}); @override State
createState() => _HeroAnimationState(); } /// 测试 新页面背景透明色 的图片详情 class _HeroAnimationState extends State { /// 测试本地图片 final List images = [ 'assets/images/01.jpg', 'assets/images/02.jpg', 'assets/images/03.jpg', 'assets/images/04.jpg', ]; @override Widget build(BuildContext context) { // 减慢动画速度,可以通过此值帮助开发, // 注意这个值是针对所有动画,所以路由动画也会受影响 // timeDilation = 10.0; return Scaffold( appBar: AppBar( title: const Text('Photo List Page'), ), body: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, alignment: Alignment.topLeft, child: GridView.count( padding: const EdgeInsets.all(10), crossAxisCount: 2, mainAxisSpacing: 10, crossAxisSpacing: 10, children: List.generate( images.length, (index) => PhotoHero( photo: images[index], size: 100, onTap: () { Navigator.of(context).push( PageRouteBuilder ( opaque: false, // 新页面,背景色不透明度 pageBuilder: (context, animation, secondaryAnimation) { return PhotoDetail( size: MediaQuery.of(context).size.width, photo: images[index]); }, ), ); }, )), ), ), ); } } class PhotoHero extends StatelessWidget { const PhotoHero({ super.key, required this.photo, this.onTap, required this.size, }); final String photo; final VoidCallback? onTap; final double size; @override Widget build(BuildContext context) { return SizedBox( width: size, height: size, child: Hero( tag: photo, child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, /// 测试本地图片 child: Image.asset( photo, fit: BoxFit.cover, ), ), ), ), ); } } class PhotoDetail extends StatelessWidget { const PhotoDetail({ super.key, required this.photo, required this.size, }); final String photo; final double size; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.transparent, // backgroundColor: const Color(0x66000000), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.all(16), alignment: Alignment.center, child: PhotoHero( photo: photo, size: size, onTap: () { Navigator.of(context).pop(); }, ), ), const Text( '详情xxx', style: TextStyle(fontSize: 20,color: Colors.white), ) ], ), ); } } 图片形状转换案例:圆形 转 矩形
这个案例的使用方式,被称为 径向hero动画;
- 径向hero动画的 径 是半径距离,圆形状 向 矩形状转换,矩形状的对角半径距离 = 圆形状半径距离 * 2;
- 这个是官方模版代码,我也没改什么;
- 官方代码地址:https://github.com/cfug/flutter.cn/blob/main/examples/_animation/radial_hero_animation/lib/main.dart
- 问题:这种官方代码是 初始化为 圆形 点击向 矩形改变的方式,我尝试反向操作:初始化为 矩形 点击向 圆形改变,但没有成功,如果有哪位同学找到实现方式,麻烦评论区留言;
我是这样修改的:
class RadialExpansion extends StatelessWidget { ... ... @override Widget build(BuildContext context) { /// 原来的代码 // 控制形状变化的核心代码 // return ClipOval( // 圆形 // child: Center( // child: SizedBox( // width: clipRectSize, // height: clipRectSize, // child: ClipRect( // 矩形 // child: child, // ), // ), // ), // ); /// 尝试修改 形状顺序 return ClipRect( // 矩形 child: Center( child: SizedBox( width: clipRectSize, height: clipRectSize, child: ClipOval( // 圆形 child: child, ), ), ), ); } }
官方代码演示
import 'package:flutter/material.dart'; import 'dart:math' as math; import 'package:flutter/scheduler.dart' show timeDilation; class HeroAnimation extends StatefulWidget { const HeroAnimation({super.key}); @override State
createState() => _HeroAnimationState(); } /// 将 Widget 从 A页面 共享到 B页面 后,改变Widget形状 class _HeroAnimationState extends State { static double kMinRadius = 32.0; static double kMaxRadius = 128.0; static Interval opacityCurve = const Interval(0.0, 0.75, curve: Curves.fastOutSlowIn); static RectTween _createRectTween(Rect? begin, Rect? end) { return MaterialRectCenterArcTween(begin: begin, end: end); } static Widget _buildPage( BuildContext context, String imageName, String description) { return Container( color: Theme.of(context).canvasColor, child: Center( child: Card( elevation: 8, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: kMaxRadius * 2.0, height: kMaxRadius * 2.0, child: Hero( createRectTween: _createRectTween, tag: imageName, child: RadialExpansion( maxRadius: kMaxRadius, child: Photo( photo: imageName, onTap: () { Navigator.of(context).pop(); }, ), ), ), ), Text( description, style: const TextStyle(fontWeight: FontWeight.bold), textScaleFactor: 3, ), const SizedBox(height: 16), ], ), ), ), ); } Widget _buildHero( BuildContext context, String imageName, String description, ) { return SizedBox( width: kMinRadius * 2.0, height: kMinRadius * 2.0, child: Hero( createRectTween: _createRectTween, tag: imageName, child: RadialExpansion( maxRadius: kMaxRadius, child: Photo( photo: imageName, onTap: () { Navigator.of(context).push( PageRouteBuilder ( pageBuilder: (context, animation, secondaryAnimation) { return AnimatedBuilder( animation: animation, builder: (context, child) { return Opacity( opacity: opacityCurve.transform(animation.value), child: _buildPage(context, imageName, description), ); }, ); }, ), ); }, ), ), ), ); } @override Widget build(BuildContext context) { timeDilation = 5.0; // 1.0 is normal animation speed. return Scaffold( appBar: AppBar( title: const Text('Radial Transition Demo'), ), body: Container( padding: const EdgeInsets.all(32), alignment: FractionalOffset.bottomLeft, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildHero(context, 'assets/images/01.jpg', 'Chair'), _buildHero(context, 'assets/images/02.jpg', 'Binoculars'), _buildHero(context, 'assets/images/03.jpg', 'Beach ball'), _buildHero(context, 'assets/images/04.jpg', 'Beach ball'), ], ), ), ); } } class Photo extends StatelessWidget { const Photo({super.key, required this.photo, this.onTap}); final String photo; final VoidCallback? onTap; @override Widget build(BuildContext context) { return Material( // Slightly opaque color appears where the image has transparency. color: Theme.of(context).primaryColor.withOpacity(0.25), child: InkWell( onTap: onTap, child: LayoutBuilder( builder: (context, size) { return Image.asset( photo, fit: BoxFit.contain, ); }, ), ), ); } } class RadialExpansion extends StatelessWidget { const RadialExpansion({ super.key, required this.maxRadius, this.child, }) : clipRectSize = 2.0 * (maxRadius / math.sqrt2); final double maxRadius; final double clipRectSize; final Widget? child; @override Widget build(BuildContext context) { // 控制形状变化的核心代码 return ClipOval( // 圆形 child: Center( child: SizedBox( width: clipRectSize, height: clipRectSize, child: ClipRect( // 矩形 child: child, ), ), ), ); } } 页面转场动画
在自定义路由时,添加动画,自定义路由需要用到PageRouteBuilder
; import 'package:flutter/material.dart'; /// 为页面切换加入动画效果 class PageAnimation extends StatefulWidget { const PageAnimation({super.key}); @override State
createState() => _PageAnimationState(); } class _PageAnimationState extends State { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( '为页面切换加入动画效果', style: TextStyle(fontSize: 20), )), body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ ElevatedButton( onPressed: () { Navigator.of(context).push(_createRouteX()); }, child: const Text( 'X轴偏移', style: TextStyle(fontSize: 20), )), ElevatedButton( onPressed: () { Navigator.of(context).push(_createRouteY()); }, child: const Text( 'Y轴偏移', style: TextStyle(fontSize: 20), )), ElevatedButton( onPressed: () { Navigator.of(context).push(_createRouteMix()); }, child: const Text( '混合动画', style: TextStyle(fontSize: 20), )), ], ), ), ); } /// X轴 平移动画,切换页面 Route _createRouteX() { return PageRouteBuilder( // opaque: false, // 新页面,背景色不透明度 pageBuilder: (context, animation, secondaryAnimation) => const TestPage01(), transitionsBuilder: (context, animation, secondaryAnimation, child) { const begin = Offset(1.0, 0.0); // 将 dx 参数设为 1,这代表在水平方向左切换整个页面的宽度 const end = Offset.zero; const curve = Curves.ease; var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); return SlideTransition( position: animation.drive(tween), child: child, ); }); } /// Y轴 平移动画,切换页面 Route _createRouteY() { return PageRouteBuilder( // opaque: false, // 新页面,背景色不透明度 pageBuilder: (context, animation, secondaryAnimation) => const TestPage01(), transitionsBuilder: (context, animation, secondaryAnimation, child) { const begin = Offset(0.0, 1.0); // 将 dy 参数设为 1,这代表在竖直方向上切换整个页面的高度 const end = Offset.zero; const curve = Curves.ease; var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); return SlideTransition( position: animation.drive(tween), child: child, ); }); } /// 多个动画配合,切换页面 Route _createRouteMix() { return PageRouteBuilder( // opaque: false, // 新页面,背景色不透明度 pageBuilder: (context, animation, secondaryAnimation) => const TestPage01(), transitionsBuilder: (context, animation, secondaryAnimation, child) { var tween = Tween (begin: 0.1, end: 1.0) .chain(CurveTween(curve: Curves.ease)); return FadeTransition( // 淡入淡出 opacity: animation.drive(tween), child: RotationTransition( // 旋转 turns: animation.drive(tween), child: ScaleTransition( // 更替 scale: animation.drive(tween), child: child, ), ), ); }); } } class TestPage01 extends StatelessWidget { const TestPage01({super.key}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.lightBlue, appBar: AppBar( title: const Text('TestPage01'), ), ); } } 交错动画
多个动画配合使用
这个案例是官方的,原汁原味;
import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart' show timeDilation; class IntertwinedAnimation extends StatefulWidget { const IntertwinedAnimation({super.key}); @override State
createState() => _IntertwinedAnimationState(); } class _IntertwinedAnimationState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 2000), vsync: this); } @override void dispose() { _controller.dispose(); super.dispose(); } Future _playAnimation() async { try { await _controller.forward().orCancel; await _controller.reverse().orCancel; } on TickerCanceled {} } @override Widget build(BuildContext context) { // timeDilation = 10.0; return Scaffold( appBar: AppBar( title: const Text( '交错动画', style: TextStyle(fontSize: 20), )), body: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { _playAnimation(); }, child: Center( child: Container( width: 300, height: 300, decoration: BoxDecoration( color: Colors.black.withOpacity(0.1), border: Border.all( color: Colors.black.withOpacity(0.5), )), child: StaggerAnimation(controller: _controller), ), ), ), ); } } class StaggerAnimation extends StatelessWidget { final Animation controller; final Animation opacity; final Animation width; final Animation height; final Animation padding; final Animation borderRadius; final Animation color; StaggerAnimation({super.key, required this.controller}) : opacity = Tween ( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: controller, curve: const Interval( 0.0, 0.100, curve: Curves.ease, ))), width = Tween ( begin: 50.0, end: 150.0, ).animate(CurvedAnimation( parent: controller, curve: const Interval( 0.125, 0.250, curve: Curves.ease, ))), height = Tween (begin: 50.0, end: 150.0).animate(CurvedAnimation( parent: controller, curve: const Interval( 0.250, 0.375, curve: Curves.ease, ))), padding = EdgeInsetsTween( begin: const EdgeInsets.only(bottom: 16), end: const EdgeInsets.only(bottom: 75), ).animate(CurvedAnimation( parent: controller, curve: const Interval( 0.250, 0.375, curve: Curves.ease, ))), borderRadius = BorderRadiusTween( begin: BorderRadius.circular(4), end: BorderRadius.circular(75), ).animate(CurvedAnimation( parent: controller, curve: const Interval( 0.375, 0.500, curve: Curves.ease, ))), color = ColorTween(begin: Colors.indigo[100], end: Colors.orange[400]) .animate(CurvedAnimation( parent: controller, curve: const Interval( 0.500, 0.750, curve: Curves.ease, ))); Widget _buildAnimation(BuildContext context, Widget? child) { return Container( padding: padding.value, alignment: Alignment.bottomCenter, child: Opacity( opacity: opacity.value, child: Container( width: width.value, height: height.value, decoration: BoxDecoration( color: color.value, border: Border.all( color: Colors.indigo[300]!, width: 3, ), borderRadius: borderRadius.value), ), ), ); } @override Widget build(BuildContext context) { return AnimatedBuilder( builder: _buildAnimation, animation: controller, ); } } 依次执行动画
这个案例是根据官方demo改的,它那个太复杂了,不利于新手阅读(个人觉得);
官方文档:创建一个交错效果的侧边栏菜单 - Flutter 中文文档 - Flutter 中文开发者网站 - Flutter
import 'package:flutter/material.dart'; class Intertwined02Animation extends StatefulWidget { const Intertwined02Animation({super.key}); @override State
createState() => _Intertwined02AnimationState(); } class _Intertwined02AnimationState extends State { @override Widget build(BuildContext context) { return Scaffold( body: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: const TableList(), // child: const Column( // crossAxisAlignment: CrossAxisAlignment.center, // children: [ // TableList() // ], // ), ), ); } } class TableList extends StatefulWidget { const TableList({super.key}); @override State createState() => _TableListState(); } class _TableListState extends State with SingleTickerProviderStateMixin { /// 遍历循环写法 late AnimationController _controller; final Duration _durationTime = const Duration(milliseconds: 3000); @override initState() { super.initState(); _controller = AnimationController(vsync: this, duration: _durationTime); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } /// 遍历Interval List _createInterval() { List intervals = []; // Interval(0.0,0.5); // Interval(0.5,0.75); // Interval(0.75,1.0); double begin = 0.0; double end = 0.5; for (int i = 0; i < 3; i++) { if (i == 0) { intervals.add(Interval(begin, end)); } else { begin = end; end = begin + 0.25; intervals.add(Interval(begin, end)); } // debugPrint('begin:$begin --- end:$end'); } return intervals; } /// 遍历循环组件 List _createWidget() { var intervals = _createInterval(); List listItems = []; for (int i = 0; i < 3; i++) { listItems.add(AnimatedBuilder( animation: _controller, builder: (context, child) { var animationPercent = Curves.easeOut.transform(intervals[i].transform(_controller.value)); final opacity = animationPercent; final slideDistance = (1.0 - animationPercent) * 150; return Opacity( opacity: i == 2 ? opacity : 1, child: Transform.translate( offset: Offset(slideDistance, 100 + (i * 50)), child: child, )); }, child: Container( width: 100, height: 50, color: Colors.lightBlue, ), )); } return listItems; } @override Widget build(BuildContext context) { return SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Column( children: _createWidget(), ), ); } /// 非遍历循环写法 // late AnimationController _controller; // // final Interval _intervalA = const Interval(0.0, 0.5); // final Interval _intervalB = const Interval(0.5, 0.8); // final Interval _intervalC = const Interval(0.8, 1.0); // // final Duration _durationTime = const Duration(milliseconds: 3000); // // @override // void initState() { // super.initState(); // _controller = AnimationController(vsync: this, duration: _durationTime); // _controller.forward(); // } // // @override // void dispose() { // _controller.dispose(); // super.dispose(); // } // // @override // Widget build(BuildContext context) { // return SizedBox( // width: MediaQuery.of(context).size.width, // height: MediaQuery.of(context).size.height, // child: Column( // children: [ // AnimatedBuilder( // animation: _controller, // builder: (context,child) { // var animationPercent = Curves.easeOut.transform(_intervalA.transform(_controller.value)); // final slideDistance = (1.0 - animationPercent) * 150; // return Transform.translate( // offset: Offset(slideDistance,100), // child: child // ); // }, // child: Container( // width: 100, // height: 50, // color: Colors.lightBlue, // ), // ), // AnimatedBuilder( // animation: _controller, // builder: (context,child) { // var animationPercent = Curves.easeOut.transform(_intervalB.transform(_controller.value)); // final slideDistance = (1.0 - animationPercent) * 150; // return Transform.translate( // offset: Offset(slideDistance,150), // child: child // ); // }, // child: Container( // width: 100, // height: 50, // color: Colors.lightBlue, // ), // ), // AnimatedBuilder( // animation: _controller, // builder: (context,child) { // var animationPercent = Curves.easeOut.transform(_intervalC.transform(_controller.value)); // final opacity = animationPercent; // final slideDistance = (1.0 - animationPercent) * 150; // return Opacity( // opacity: opacity, // child: Transform.translate( // offset: Offset(slideDistance,200), // child: child // ), // ); // }, // child: Container( // width: 100, // height: 50, // color: Colors.lightBlue, // ), // ), // ], // ), // ); // } /// 基础版本写法 // late AnimationController _controller; // final Duration _durationTime = const Duration(milliseconds: 2000); // // 0.0 - 1.0 / 0% - 100% // final Interval _interval = const Interval(0.5, 1.0); // 延迟 50% 再开始 启动动画,执行到 100% // // final Interval _interval = const Interval(0.5, 0.7); // 延迟 50% 再开始 启动动画,后期的执行速度,增加 30% // // final Interval _interval = const Interval(0.0, 0.1); // 不延迟 动画执行速度,增加 90% // // @override // void initState() { // super.initState(); // _controller = AnimationController(vsync: this, duration: _durationTime); // _controller.forward(); // } // // @override // void dispose() { // _controller.dispose(); // super.dispose(); // } // @override // Widget build(BuildContext context) { // return AnimatedBuilder( // animation: _controller, // builder: (context,child) { // // var animationPercent = Curves.easeOut.transform(_controller.value); // 加动画曲线 // // var animationPercent = _interval.transform(_controller.value); // 加动画间隔 // var animationPercent = Curves.easeOut.transform(_interval.transform(_controller.value)); // 动画曲线 + 动画间隔 // // final slideDistance = (1.0 - animationPercent) * 150; // 就是对150 做递减 // // debugPrint('animationPercent:$animationPercent --- slideDistance:$slideDistance'); // debugPrint('slideDistance:$slideDistance'); // // return Transform.translate( // offset: Offset(0,slideDistance), // child: child // ); // }, // child: Container( // width: 100, // height: 50, // color: Colors.lightBlue, // ), // ); // } } 官方文档
动画效果介绍 - Flutter 中文文档 - Flutter 中文开发者网站 - Flutter