Flutter 实现一个灵动的按钮
Flutter 实现一个灵动的按钮
看文章的大致内容和实现效果,请看"本文概要"
看具体代码,请看"具体实现"
看实现过程思路和其中的问题等,请看"心路历程"
本文概要
最近在仿写一个项目,其中需要实现一个点击之后具有回弹动画的按钮。原生好像也没有这样效果的按钮,于是自己动手撸了一个。本文主要讲解了我实现这个按钮的具体过程。具体效果如下。
具体效果.gif具体实现
主要是通过自定义一个StatefulWidget,当用户点击时,通过AnimationController来播放动画,从而实现点击回弹动画。
以下是具体代码。
import 'package:flutter/material.dart';
// 果冻按钮 点击回弹
class JellyButton extends StatefulWidget {
// 动画的时间
final Duration duration;
// 动画图标的大小
final Size size;
// 点击后的回调
final VoidCallback onTap;
// 未选中时的图片
final String unCheckedImgAsset;
// 选中时的图片
final String checkedImgAsset;
// 是否选中
final bool checked;
// 一定要添加这个背景颜色 不知道为什么 container不添加背景颜色 不能撑开
final Color backgroundColor;
final EdgeInsetsGeometry padding;
const JellyButton({
this.duration = const Duration(milliseconds: 500),
this.size = const Size(40.0, 40.0),
this.onTap,
@required this.unCheckedImgAsset,
@required this.checkedImgAsset,
this.checked = false,
this.backgroundColor = Colors.transparent,
this.padding = const EdgeInsets.all(8.0)
});
@override
_JellyButtonState createState() => _JellyButtonState();
}
class _JellyButtonState extends State<JellyButton> with TickerProviderStateMixin {
// 动画控制器 点击触发播放动画
AnimationController _controller;
// 非线性动画 用来实现点击效果
CurvedAnimation _animation;
@override
void initState() {
super.initState();
// 初始化 Controller
_controller = AnimationController(vsync: this, duration: widget.duration);
// 线性动画 可以让按钮从小到大变化
Animation<double> linearAnimation = Tween(begin: 0.0, end: 1.0).animate(_controller);
// 将线性动画转化成非线性动画 让按钮点击效果更加灵动
_animation = CurvedAnimation(parent: linearAnimation, curve: Curves.elasticOut);
// 一开始不播放动画 直接显示原始大小
_controller.forward(from: 1.0);
}
@override
void dispose() {
// 记得要释放Controller的资源
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// 点击的同时 播放动画
_playAnimation();
if (widget.onTap != null) {
widget.onTap();
}
},
child: Container(
// 添加这个约束 为了让按钮可以撑满屏幕 (主要是为了实现我仿写的项目的效果)
constraints: BoxConstraints(minWidth: widget.size.width, minHeight: widget.size.height),
color: widget.backgroundColor,
padding: widget.padding,
child: Center(
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
// size / 1.55 是为了防止溢出
return Image.asset(
widget.checked ? widget.checkedImgAsset : widget.unCheckedImgAsset,
width: _animation.value * (widget.size.width - widget.padding.horizontal) / 1.55,
height: _animation.value * (widget.size.height - widget.padding.vertical) / 1.55,
);
}
),
),
),
);
}
void _playAnimation() {
_controller.forward(from: 0.0);
}
}
心路历程
思路
要实现这么一个按钮,简单了说,就是一张图片,点击之后让这张图片的尺寸由小到大变化即可。如果要达到"灵动"的效果,就只要将图片的放大过程是一个非线性变化即可,有一个回弹的效果。
1.实现线性的变化
我们先来实现一个Icon的点击放大动画(别问我为什么不用FlutterLogo,因为用FlutterLogo翻车了。有兴趣的小伙伴可以研究下为什么把Icon替换成FlutterLogo之后,点击放大效果就会小时,取而代之的是效果是,FlutterLogo会先变小一下下,然后恢复成正常的尺寸。可能是与FlutterLogo本来就带动画的原因。这也算是一个坑吧。)。
需要用到Animation的知识,本人参考的是简书 Animation。
思路很简单,就是通过AnimationBuilder,触发动画时,将一个Icon的尺寸从0到40变化。
具体代码如下。
具体实现效果贴在代码下。
lass JellyButton extends StatefulWidget {
@override
_JellyButtonState createState() => _JellyButtonState();
}
class _JellyButtonState extends State<JellyButton> with SingleTickerProviderStateMixin {
final double SIZE = 40.0;
// 动画控制器 用来控制动画的播放
AnimationController _controller;
// 自己定义的动画
Animation<double> _animation;
@override
void initState() {
super.initState();
// 创建一个controller
_controller = AnimationController(vsync: this, duration: Duration(seconds: 1));
// 创建一个动画
_animation = Tween(begin: 0.0, end: 1.0).animate(_controller);
// 一开始不播放动画 直接显示原始大小
_controller.forward(from: 1.0);
}
@override
void dispose() {
// 不要忘记释放资源
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 使用GestureDetector来检测点击事件
return GestureDetector(
// 使用AnimationBuilder来实现动画
onTap: () {
// 点击之后播放动画
_playAnimation();
},
child: AnimatedBuilder(
animation: _animation,
builder: (BuildContext context, Widget child) {
print('${_animation.value}');
return Icon(
Icons.ac_unit,
size: SIZE * _animation.value,
);
},
),
);
}
void _playAnimation() {
_controller.forward(from: 0.0);
}
}
线性变化.gif
2.线性动画转变为非线性动画
第一步做完之后,动画完全没有灵动的效果,接下来我们就来把代码附上灵魂。
具体的非线性动画细节可以查看Curves
做法很简单,需要修改的代码如下。
class _JellyButtonState extends State<JellyButton> with SingleTickerProviderStateMixin {
// 将我们之前申明的_animation改为CurvedAnimation
// Animation<double> _animation;
CurvedAnimation _animation;
// ... 此处省略其他代码
// initState中改写为如下代码
@override
void initState() {
super.initState();
// 创建一个controller
_controller = AnimationController(vsync: this, duration: Duration(seconds: 1));
// 线性动画 可以让按钮从小到大变化
Animation<double> linearAnimation = Tween(begin: 0.0, end: 1.0).animate(_controller);
// 将线性动画转化成非线性动画 让按钮点击效果更加灵动
_animation = CurvedAnimation(parent: linearAnimation, curve: Curves.elasticOut);
// 一开始不播放动画 直接显示原始大小
_controller.forward(from: 1.0);
}
}
非线性变化.gif
3.完善细节
核心的内容已经写完了,但是这还没完。我们看到第一张gif图中,按钮还有选中和未选中的情况,这就需要添加按钮的状态来实现。然后还要支持不同按钮样式,这样一来,Icon就不够用了,于是我将Icon换成了Image。
修改完的代码如下所示。
class JellyButton extends StatefulWidget {
// 主要是添加了这些属性,用来方便按钮的定制化
// 动画的时间
final Duration duration;
// 动画图标的大小
final Size size;
// 点击后的回调
final VoidCallback onTap;
// 未选中时的图片
final String unCheckedImgAsset;
// 选中时的图片
final String checkedImgAsset;
// 是否选中
final bool checked;
final EdgeInsetsGeometry padding;
const JellyButton({
this.duration = const Duration(milliseconds: 500),
this.size = const Size(40.0, 40.0),
this.onTap,
@required this.unCheckedImgAsset,
@required this.checkedImgAsset,
this.checked = false,
this.padding = const EdgeInsets.all(8.0)
});
@override
_JellyButtonState createState() => _JellyButtonState();
}
class _JellyButtonState extends State<JellyButton> with SingleTickerProviderStateMixin {
// 变量代码没有修改 省略
// initState 代码没有修改 省略
// dispose 没有修改 省略
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// 点击之后播放动画
_playAnimation();
// 执行外部传入的onTap回调
if (widget.onTap != null) {
widget.onTap();
}
},
child: Container(
padding: widget.padding,
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Image.asset(
widget.checked ? widget.checkedImgAsset : widget.unCheckedImgAsset,
width: _animation.value * (widget.size.width - widget.padding.horizontal),
height: _animation.value * (widget.size.height - widget.padding.vertical),
);
}
),
),
);
}
// _playAnimation 方法代码没有修改 省略
}
改好了,放到页面中,走你~
页面代码如下。
class TestPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: TestButton(),
),
],
)
],
),
);
}
}
class TestButton extends JellyButton {
const TestButton({
VoidCallback onTap,
bool checked = false,
}) : super(
unCheckedImgAsset: 'images/page_one_normal.png',
checkedImgAsset: 'images/page_one_selected.png',
size: const Size(48.0, 48.0),
onTap: onTap,
checked: checked,
);
}
看下效果。
缺陷一.gif为什么不是从中间变大?
改改改,光加个Center肯定是不行的。
我们需要先规定这个按钮的高度,然后让图片Center居中,这样就可以实现从中间放大的效果。
// 修改 GestureDetector 的child为如下代码
child: Container(
width: widget.size.width,
height: widget.size.height,
padding: widget.padding,
child: Center(
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Image.asset(
widget.checked ? widget.checkedImgAsset : widget.unCheckedImgAsset,
width: _animation.value * (widget.size.width - widget.padding.horizontal),
height: _animation.value * (widget.size.height - widget.padding.vertical),
);
}
),
),
),
上图。
缺陷二.gif看起来好像没有之前那么灵动呢?有点卡住了的感觉。
因为图标会有溢出然后再缩小的效果,但是我们给控件Container指定了大小之后,就会导致图标无法溢出。
我们在Image的width和height上除以一个系数,让图标缩小,这样就不会溢出了。作者是除以1.55的。(这个方法比较low,但是很管用。其他我能想到的,就是使用Stack的overflow属性,设置为Overflow.visible,让超出Stack的部分也能显示,但是经过好几次尝试,还是没有成功。所以就放弃了Orz)。
跑起来~
缺陷三.gif诶?为什么必须点击图标才能出发效果呢,点击空白的地方就不行?我明明使用了Expand。添加背景色之后发现,只有按钮那一块的颜色有背景色。那我们就手动指定约束吧。
// 为Container添加背景和约束
Container(
constraints: BoxConstraints(minWidth: widget.size.width, minHeight: widget.size.height),
width: widget.size.width,
height: widget.size.height,
padding: widget.padding,
color: Colors.red,
// ...
),
满分!
缺陷三效果.gif好了,虽然是本命年,但是为了美观,还是把背景色去掉。
去掉背景色之后,又变回原来的了效果了???
查看源码之后发现,我们设置了width,又设置了constraints属性,这时,Container会将contraints属性设置为尽可能小。所以我们的constraints无效。
附上源码
constraints =
(width != null || height != null)
? constraints?.tighten(width: width, height: height)
?? BoxConstraints.tightFor(width: width, height: height)
: constraints,
可是为什么设置了背景颜色,就可以了呢?于是笔者曲线救国,偷偷设置了一个透明的背景颜色。
最后,完成的代码请看"具体实现"。