前言: 在AppStore里面,首页的app推荐点击后有一个丝滑的到详情页面的过渡动画,并且关闭的时候也会存在一个可打断的向下划动关闭的交互,今天我来尝试实现一下
在实现之前,首先看一下我们的目标效果
在页面1导航到页面2的过程中,有个一点对点的形变动画过渡,而在关闭页面2的时候,不仅包含过渡,而且有一个通过向下划动而且中途可打断的缩放动画,是根据捕捉滑动路径来决定的,要实现如上的交互效果,我们可以把步骤简化为3步
- 实现页面之间的Navigate过渡
- 实现过渡之间的弹性动画
- 实现下滑关闭页面时页面的缩放与控制边缘角度的渐变
Section1 HeroAnimation
虽然,Flutter没有提供SwiftUI里可以一步到位的组件以实现上面的效果,但是我们仍然可以自己去尝试实现,Flutter提供了Hero Animation(主动画)以让开发者实现在页面之间的穿梭,简单说一下原理
基本上 一次HeroAnimation的变换要经历四个步骤:
过渡前 > 页面被推送到Navigator 触发动画 > 过渡完成 目标hero来到最终的位置
因此,要实现一个主动画很简单,确定目标hero,确定hero最终的位置,给hero打上标签,一个简单的主动画就完成了,来简单实现一下,创建一个新项目并创建两个页面:
- home兼main页面
import 'package:flutter/material.dart';
import 'CustomRectTween.dart';
import 'FlutterHeroAnimationSecondPage.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Hero(
tag: "HeroAnimationTag",
child: GestureDetector(
onTap: () {
_startHeroAnimation(context);
},
child: Container(
margin: EdgeInsets.symmetric(horizontal: 10),
width: double.infinity,
height: 250,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Color(0xffBB8045),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 5)
]),
child: Stack(
children: [
const Image(
image: AssetImage('assets/room.jpeg'),
fit: BoxFit.cover),
Container(
margin: EdgeInsets.only(top: 80),
child: Container(
width: double.infinity,
height: 100,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Color(0xffBB8045)
])))),
const Positioned(
top: 120,
left: 10,
child: Text(
'今日作品',
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.bold),
)),
],
),
),
)),
],
),
),
);
}
// heroAnimation的实现
void _startHeroAnimation(BuildContext context) {
Navigator.push(context, PageRouteBuilder(pageBuilder: (BuildContext context,
Animation<double> animation, Animation<double> secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FlutterHeroAnimationSecondPage(),
);
}));
}
}
- 详情页面 FlutterHeroAniamtionSecondPage
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'CustomRectTween.dart';
class FlutterHeroAnimationSecondPage extends StatefulWidget {
const FlutterHeroAnimationSecondPage({Key? key}) : super(key: key);
@override
State<FlutterHeroAnimationSecondPage> createState() =>
_FlutterHeroAnimationSecondPageState();
}
class _FlutterHeroAnimationSecondPageState
extends State<FlutterHeroAnimationSecondPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Hero(
tag: "HeroAnimationTag",
child: Container(
height: 290,
child: Stack(
children: [
Container(
height: 250,
child: Image(
image: AssetImage('assets/room.jpeg'),
fit: BoxFit.cover,
)),
Positioned(
top: 50,
right: 10,
child: GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
color: Color(0x5feeeeee)),
child: Icon(Icons.close),
),
)),
Positioned(
top: 250,
left: 10,
child: Text('今日作品',
style: TextStyle(
fontSize: 30,
color: Colors.black,
fontWeight: FontWeight.bold)))
],
),
)),
Expanded(
child: Container(
child: Text('.....'),
))
],
),
),
),
// ),
);
}
}
如此,实现了一个简单的heroAnimation效果
可以看到,基础的过渡跳转已经实现,但是动画是线性的,没有结束时的弹跳感,这时候我们需要为项目的hero() widge 添加一个属性: createRectTween,创建重写一个属于自己的动画效果。
Section2 createRectTween
要创建自定义补间动画,首先看一下hero里面的创建动画都需要什么参数
Hero(
tag: "HeroAnimationTag",
createRectTween: (begin, end) {
return MaterialRectCenterArcTween(begin: begin, end: end);
}
)
createRectTween 接受一个函数,以Rect类型的开始和结束作为参数,返回值便是其需要执行的动画。如上的代码是flutter自定义好的一个补间动画方式MaterialRectCenterArcTween 用以让动画流畅的在矩形和圆形间变动,如果不使用这个方法,则会出现问题,变化期间动画会很奇怪,产生椭圆形
接下来,让我们实现自己的自定义动画
为页面创建一个新的dart文件,命名为CustomRectTween.dart ,并在其中创建一个类,接收参数
import 'dart:ui';
import 'package:flutter/animation.dart';
class CustomRectTween extends RectTween {
CustomRectTween({ Rect? begin, Rect? end})
: super(begin: begin, end: end);
@override
Rect lerp(double t) {
double transformT = Curves.easeInOutBack.transform(t);
// print(transformT);
var rect = Rect.fromLTRB(
_rectMove(begin!.left, end!.left, transformT),
_rectMove(begin!.top, end!.top, transformT),
_rectMove(end!.right, end!.right, transformT),
_rectMove(begin!.bottom, end!.bottom, transformT));
return rect;
}
double _rectMove(double begin, double end, double t) {
print('${begin * (1 - t) + end * t}');
return begin * (1 - t) + end * t;
}
}
实际上,我按照源码的重写方法来实现了这个类,通过对lerp的override,并通过easeInOut的方法来创建补间动画值,使得原先的动画从
-1 → 0 → 1 → 0
的线性变化到现在的
-1.2 → -1 → 0 → 1.2 → -1 -0
添加了为弹性而存在的量,从上面的代码可以看出动画是对于不断更新形状的LTRB值,也就是一个矩形的四条边,来实现动画的过渡。
如此,我们将这个重写类添加到页面中,注意,如果想要两个页面都有过渡效果,则两个页面都要添加,这也就是我为什么要把这个重写方法抽离出来单写成一个类的原因。
Hero(
tag: "HeroAnimationTag",
createRectTween: (begin, end) {
return CustomRectTween(begin: begin!, end: end!);
},
child: .......
)
接下来,就来探讨一下最后一步,如何通过捕捉触摸动作来实现关闭页面
Section3 GestureDetector
gestureDetector 这个组件大伙应该非常熟悉,为widget绑定触摸监听之类的会频繁运用到。不过今天我们不探讨onTap,我们来试试它的onDrag事件。
在js中,为页面的滚动事件添加监听的时候,我们经常使用onScroll、clientHeight、offsetHeight、scrollHeight等方法,并且通过简单的计算来得出偏移量,实现对页面做出滚动动画的实现,不过触屏设备我们只需要考虑触摸事件就好,这次我打算通过下滑来实现页面的关闭,介绍这四个包含在gestureDetector的方法。
onVerticalDragStart : 纵向滑动开始
onVerticalDragEnd: 纵向滑动结束
onVerticalDragUpdate: 纵向滑动更新
我需要一个在0~1之间不断变化的值来控制我的页面过渡动画,通过计算偏移量就可以得出。如图
可计算:
偏移量
=
(
触摸结束点的
y
值
−
触摸开始点的
y
值
)
/
(
屏幕高度
−
触摸开始点的
y
值
)
偏移量 = ( 触摸结束点的y值 - 触摸开始点的y值 ) / (屏幕高度 - 触摸开始点的y值)
偏移量=(触摸结束点的y值−触摸开始点的y值)/(屏幕高度−触摸开始点的y值)
现在开始实现,在详情页面中添加相应的值
....
double vertivalDragStart = 0.0;
double verticalDragUpdate = 0.0;
double screenHeight = 0.0;
bool loopControl = true;
....
在build 中获取屏幕高度,并为避免死循环做处理
if(loopContrl){
setState((){
screenHeight = MediaQuery.of(context).size.height;
loopContorl = false;
};)
}
计算页面的各种值,并实时更新偏移量,并且在偏移量超过一定值的时候关闭页面
return GestureDetector(
onVerticalDragStart: (detail) {
setState(() {
verticalDragStart = detail.localPosition.dy;
});
},
onVerticalDragEnd: (detail) {
setState(() {
verticalDragUpdate = 0.0;
});
},
onVerticalDragUpdate: (DragUpdateDetails detail) {
var offsetNum = (detail.localPosition.dy - verticalDragStart) / ( screenHeight - verticalDragStart);
print('offsetNum: $offsetNum');
if(offsetNum > 0.4){
Navigator.pop(context);
}else{
setState(() {
verticalDragUpdate = offsetNum;
});
}
child: .......
)
对页面的尺寸进行实时计算以进行缩放
child: SizedBox(
width: MediaQuery.of(context).size.width - verticalDragUpdate*100,
height: MediaQuery.of(context).size.height - verticalDragUpdate*100,
)
对页面的角也进行实时的变化
Container(
height: 290,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(100 * ( verticalDragUpdate))
),
child:......
)
看一下滑动关闭的最终效果
可以看到 我们实现了:
- 按照滑动的偏移量对页面进行缩放 对页面的角进行角度的变化
- 动画是可以打断的,并且打断时会恢复到原来的大小
- 滑动到一定的位置时,触发页面的关闭
以下,贴上两个页面的全部代码, 动画类的代码在上面可以找到哦
1.main.dart
import 'package:flutter/material.dart';
import 'CustomRectTween.dart';
import 'FlutterHeroAnimationSecondPage.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Hero(
tag: "HeroAnimationTag",
createRectTween: (begin, end) {
return CustomRectTween(begin: begin!, end: end!);
},
child: GestureDetector(
onTap: () {
_startHeroAnimation(context);
},
child: Container(
margin: EdgeInsets.symmetric(horizontal: 10),
width: double.infinity,
height: 250,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Color(0xffBB8045),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 5)
]),
child: Stack(
children: [
const Image(
image: AssetImage('assets/room.jpeg'),
fit: BoxFit.cover),
Container(
margin: EdgeInsets.only(top: 80),
child: Container(
width: double.infinity,
height: 100,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Color(0xffBB8045)
])))),
const Positioned(
top: 120,
left: 10,
child: Text(
'今日作品',
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.bold),
)),
],
),
),
)),
],
),
),
);
}
void _startHeroAnimation(BuildContext context) {
Navigator.push(context, PageRouteBuilder(pageBuilder: (BuildContext context,
Animation<double> animation, Animation<double> secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: FlutterHeroAnimationSecondPage(),
);
}));
}
}
- FlutterHeroAnimationSecondPage.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'CustomRectTween.dart';
class FlutterHeroAnimationSecondPage extends StatefulWidget {
const FlutterHeroAnimationSecondPage({Key? key}) : super(key: key);
@override
State<FlutterHeroAnimationSecondPage> createState() =>
_FlutterHeroAnimationSecondPageState();
}
class _FlutterHeroAnimationSecondPageState
extends State<FlutterHeroAnimationSecondPage> {
double verticalDragStart = 0.0;
double verticalDragUpdate = 0.0;
double screenHeight = 0.0;
bool loopControl = true;
@override
Widget build(BuildContext context) {
if(loopControl){
setState(() {
screenHeight = MediaQuery.of(context).size.height;
loopControl = false;
});
}
return GestureDetector(
onVerticalDragStart: (detail) {
setState(() {
verticalDragStart = detail.localPosition.dy;
});
},
onVerticalDragEnd: (detail) {
setState(() {
verticalDragUpdate = 0.0;
});
},
onVerticalDragUpdate: (DragUpdateDetails detail) {
var offsetNum = (detail.localPosition.dy - verticalDragStart) / ( screenHeight - verticalDragStart);
print('offsetNum: $offsetNum');
if(offsetNum > 0.4){
Navigator.pop(context);
}else{
setState(() {
verticalDragUpdate = offsetNum;
});
}
// print('update: ${detail.localPosition}');
},
child: Scaffold(
body: Center(
child: SizedBox(
width: MediaQuery.of(context).size.width - verticalDragUpdate*100,
height: MediaQuery.of(context).size.height - verticalDragUpdate*100,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Hero(
tag: "HeroAnimationTag",
createRectTween: (begin, end) {
return CustomRectTween(begin: begin!, end: end!);
},
child: Container(
height: 290,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(100 * ( verticalDragUpdate))
),
// width: ,
child: Stack(
children: [
Container(
height: 250,
child: Image(
image: AssetImage('assets/room.jpeg'),
fit: BoxFit.cover,
)),
Positioned(
top: 50,
right: 10,
child: GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
color: Color(0x5feeeeee)),
child: Icon(Icons.close),
),
)),
Positioned(
top: 250,
left: 10,
child: Text('今日作品',
style: TextStyle(
fontSize: 30,
color: Colors.black,
fontWeight: FontWeight.bold)))
],
),
)),
Expanded(
child: Container(
child: Text('.....'),
))
],
),
),
),
),
);
}
}
总结
通过对Flutter的HeroAnimation实现页面的跳转与过渡
通过自己创建类实现非线性弹性动画
通过gestureDetector实现对页面的缩放效果
欢迎讨论、指错、提出更好的方法、文章仅发在CSDN 账号名称:Vicissitidues,不允许任何形式的转载,谢谢🙏