本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。
前言:今天是1024,先祝各位兄弟们节日快乐,永不脱发,永无Bug😜。说正事:在前几天,我发现了一个动画特别炫酷的一个Flutter项目,一款习惯养成类的App,看了后就真的是爱不释手,功能很丰富,所以我立刻找到了开源作者,向他申请了写作权限。然后开始了对项目的分析(求个赞!!!相信我,看完这篇你会有收获的👍)
我对他项目的代码进行了部分修改,修改的源代码在文章最后~
开源项目地址:https://github.com/designDo/flutter-checkio
先上效果图:
还有很多的功能大家自己下载源码(觉得好的话给开源作者点个star哦,人家不容易!)
本文分析重点:
- 登录界面的动画、输入框处理以及顶部弹出框
- 底部导航栏的动画处理
- 首页动画以及环形进度条处理
- 适配深色模式(分析一下作者的全局状态管理)
1.登录界面的动画、输入框处理以及顶部弹出框
-
动画处理
这里一共有3处动画,输入框的缩放动画,验证码按钮的平移动画,登录界面的缩放动画。
当我们使用动画时,我们需要定义一个Controller来控制管理动画
AnimationController _animationController;
当然使用动画时我们的State是需要混入SingleTickerProviderStateMixin这个类的
在效果图中我们也不难看出动画直接是有时间间距的,所以我们整个界面仅用一个Controller来控制,使其从上到下逐步显示。
关于缩放动画呢,在flutter我们需要使用ScaleTransition,其中最重要的一点便是:
Animation<double> scale //控制widget缩放
来看看详细使用:
ScaleTransition( //控制缩放从0到1 scale: Tween<double>(begin: 0, end: 1).animate(CurvedAnimation( //控制动画的Controller parent: _animationController, //0,0.3是动画运行的时间 //curve用来描述曲线运动的动画 curve: Interval(0, 0.3, curve: Curves.fastOutSlowIn), )), child:... )
这里关于其他动画也差不多,区别就在于动画和动画的运行时间
关键区别:
验证码的输入框:
curve: Interval(0.3, 0.6, curve: Curves.fastOutSlowIn),
获取验证码按钮:
这里主要区别是position用于处理初始时的绝对位置
SlideTransition( //大家可以将begin: Offset(2, 0)的数据更改,这样就会清晰的体验到它的功能 position: Tween<Offset>(begin: Offset(2, 0), end: Offset.zero) .animate(CurvedAnimation( parent: _animationController, curve: Interval(0.6, 0.8, curve: Curves.fastOutSlowIn))),child:...)
登录按钮:
ScaleTransition( scale: Tween<double>(begin: 0, end: 1).animate(CurvedAnimation( parent: _animationController, curve: Interval(0.8, 1, curve: Curves.fastOutSlowIn), )),child:...)
关于动画的实现就是这样,是不是非常的简单~
-
手机号输入框的限制处理
我觉得这个样式很炫酷,主要是在平时不是很常见,就分析一下
这里我们封装了一个CustomEditField输入框,可以更好的做动画的处理
动画定义
///文本内容
String _value = '';
TextEditingController editingController;
AnimationController numAnimationController;
Animation<double> numAnimation;
且该组件需要混入(Mixin)TickerProviderStateMixin与AutomaticKeepAliveClientMixin,因为AnimationController需要调用TickerProvider里的createTicker方法(感兴趣可以查看flutter源码)
with TickerProviderStateMixin, AutomaticKeepAliveClientMixin
初始化时:
@override
void initState() {
_value = widget.initValue;
//初始化controller
editingController = TextEditingController(text: widget.initValue);
//初始化限制框的控制器与动画
numAnimationController =
AnimationController(duration: Duration(milliseconds: 500), vsync: this);
numAnimation = CurvedAnimation(
parent: numAnimationController, curve: Curves.easeOutBack);
if (widget.initValue.length > 0) {
numAnimationController.forward(from: 0.3);
}
super.initState();
}
销毁时:
@override
void dispose() {
editingController.dispose();
numAnimationController.dispose();
super.dispose();
}
UI: 使用Stack用于包裹一个输入框和限制框
Stack(
children:[
TextField(),
//限制框的动画,所以在外面套一层ScaleTransition
ScaleTransition(
child:Padding()
)
]
)
使用这个封装的组件时,我们主要处理numDecoration
此处的颜色为全局管理的处理,直接复制该代码需要修改
numDecoration: BoxDecoration(
shape: BoxShape.rectangle,
color: AppTheme.appTheme.cardBackgroundColor(),
borderRadius: BorderRadius.all(Radius.circular(15)),
boxShadow: AppTheme.appTheme.containerBoxShadow()),
numTextStyle: AppTheme.appTheme
.themeText(fontWeight: FontWeight.bold, fontSize: 15),
- 顶部弹出框的处理
使用了flash这个插件,一个高度可定制、功能强大且易于使用的警告框
为了代码的复用,在这里进行了封装处理
class FlashHelper {
static Future<T> toast<T>(BuildContext context, String message) async {
return showFlash<T>(
context: context,
//显示两秒
duration: Duration(milliseconds: 2000),
builder: (context, controller) {
//弹出框
return Flash.bar(
margin: EdgeInsets.only(left: 24, right: 24),
position: FlashPosition.top,
brightness: AppTheme.appTheme.isDark()
? Brightness.light
: Brightness.dark,
backgroundColor: Colors.transparent,
controller: controller,
child: Container(
alignment: Alignment.center,
padding: EdgeInsets.all(16),
height: 80,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: BorderRadius.all(Radius.circular(16)),
gradient: AppTheme.appTheme.containerGradient(),
boxShadow: AppTheme.appTheme.coloredBoxShadow()),
child: Text(
//显示的文字
message,
style: AppTheme.appTheme.headline1(
textColor: Colors.white,
fontWeight: FontWeight.normal,
fontSize: 16),
),
));
});
}
}
2.底部导航栏的动画处理
这里真的是惊艳到我了,Icon都是画出来的,作者真的是脑洞大开,点赞!
-
Icon的绘制
房子:
static final home = FluidFillIconData([
//房子
ui.Path()..addRRect(RRect.fromLTRBXY(-10, -2, 10, 10, 2, 2)),
ui.Path()
..moveTo(-14, -2)
..lineTo(14, -2)
..lineTo(0, -16)
..close(),
]);
四个正方形:
static final window = FluidFillIconData([
//正方形
ui.Path()..addRRect(RRect.fromLTRBXY(-12, -12, -2, -2, 2, 2)),
ui.Path()..addRRect(RRect.fromLTRBXY(2, -12, 12, -2, 2, 2)),
ui.Path()..addRRect(RRect.fromLTRBXY(-12, 2, -2, 12, 2, 2)),
ui.Path()..addRRect(RRect.fromLTRBXY(2, 2, 12, 12, 2, 2)),
]);
趋势图:
static final progress = FluidFillIconData([
//趋势图
ui.Path()
..moveTo(-10, -10)
..lineTo(-10, 8)
..arcTo(Rect.fromCircle(center: Offset(-8, 8), radius: 2), -1 * math.pi,
-0.5 * math.pi, true)
..moveTo(-8, 10)
..lineTo(10, 10),
ui.Path()
..moveTo(-6.5, 2.5)
..lineTo(0, -5)
..lineTo(4, 0)
..lineTo(10, -9),
]);
我的:
static final user = FluidFillIconData([
//我的
ui.Path()..arcTo(Rect.fromLTRB(-5, -16, 5, -6), 0, 1.9 * math.pi, true),
ui.Path()..arcTo(Rect.fromLTRB(-10, 0, 10, 20), 0, -1.0 * math.pi, true),
]);
大佬的思路就是强👍
-
切换时的波浪动画
这里主要是两个部分,一个是点击切换时的波浪动画,一个是动画结束后的凹凸效果
这样的效果我们需要通过CustomPainter来进行绘制
我们需要定义一些参数(指展示最重要的)
final double _normalizedY;final double _x;
然后进行绘制
@override
void paint(canvas, size) {
// 使用基于“_normalizedY”值的各种线性插值绘制两条三次bezier曲线
final norm = LinearPointCurve(0.5, 2.0).transform(_normalizedY) / 2;
final radius = Tween<double>(
begin: _radiusTop,
end: _radiusBottom
).transform(norm);
// 当动画结束后的凹凸效果
final anchorControlOffset = Tween<double>(
begin: radius * _horizontalControlTop,
end: radius * _horizontalControlBottom
).transform(LinearPointCurve(0.5, 0.75).transform(norm));
final dipControlOffset = Tween<double>(
begin: radius * _pointControlTop,
end: radius * _pointControlBottom
).transform(LinearPointCurve(0.5, 0.8).transform(norm));
final y = Tween<double>(
begin: _topY,
end: _bottomY
).transform(LinearPointCurve(0.2, 0.7).transform(norm));
final dist = Tween<double>(
begin: _topDistance,
end: _bottomDistance
).transform(LinearPointCurve(0.5, 0.0).transform(norm));
final x0 = _x - dist / 2;
final x1 = _x + dist / 2;
//绘制工程
final path = Path()
..moveTo(0, 0)
..lineTo(x0 - radius, 0)
..cubicTo(x0 - radius + anchorControlOffset, 0, x0 - dipControlOffset, y, x0, y)
..lineTo(x1, y) //背景的宽高
..cubicTo(x1 + dipControlOffset, y, x1 + radius - anchorControlOffset, 0, x1 + radius, 0)
//背景的宽高
..lineTo(size.width, 0)
..lineTo(size.width, size.height)
..lineTo(0, size.height);
final paint = Paint()
..color = _color;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(_BackgroundCurvePainter oldPainter) {
return _x != oldPainter._x
|| _normalizedY != oldPainter._normalizedY
|| _color != oldPainter._color;
}
这样带波浪动画的背景就完成啦~
-
按钮的弹跳动画
其实实现方式与波浪动画相同,也是通过CustomPainter来进行绘制
(只展示核心代码)
//绘制其他无状态的按钮
final paintBackground = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = AppTheme.iconColor;
//绘制点击该按钮时的颜色
final paintForeground = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = AppTheme.appTheme.selectColor();
Icon的背景以及跳跃我们需要定义AnimationController与Animation,进行跳跃动画的绘制
在初始化时处理动画
@override
void initState() {
_animationController = AnimationController(
duration: const Duration(milliseconds: 1666),
reverseDuration: const Duration(milliseconds: 833),
vsync: this);
_animation = Tween<double>(begin: 0.0, end: 1.0).animate(_animationController)
..addListener(() {
setState(() {
});
});
_startAnimation();
super.initState();
}
final offsetCurve = _selected ? ElasticOutCurve(0.38) : Curves.easeInQuint;
final scaleCurve = _selected ? CenteredElasticOutCurve(0.6) : CenteredElasticInCurve(0.6);
final progress = LinearPointCurve(0.28, 0.0).transform(_animation.value);
final offset = Tween<double>(
begin: _defaultOffset,
end: _activeOffset
).transform(offsetCurve.transform(progress));
final scaleCurveScale = 0.50;
final scaleY = 0.5 + scaleCurve.transform(progress) * scaleCurveScale + (0.5 - scaleCurveScale / 2);
用于控制动画的运行与销毁:
@override
void didUpdateWidget(oldWidget) {
setState(() {
_selected = widget._selected;
});
_startAnimation();
super.didUpdateWidget(oldWidget);
}
void _startAnimation() {
if (_selected) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
ui布局:
return GestureDetector(
onTap: _onPressed,
behavior: HitTestBehavior.opaque,
child: Container(
constraints: BoxConstraints.tight(ne),
alignment: Alignment.center,
child: Container(
margin: EdgeInsets.all(ne.width / 2 - _radius),
constraints: BoxConstraints.tight(Size.square(_radius * 2)),
decoration: ShapeDecoration(
color: AppTheme.appTheme.cardBackgroundColor(),
shape: CircleBorder(),
),
transform: Matrix4.translationValues(0, -offset, 0),
//Icon的绘制
child: FluidFillIcon(
_iconData,
LinearPointCurve(0.25, 1.0).transform(_animation.value),
scaleY,
),
),
),
);
这样底部导航栏就完成啦!
3.首页动画以及环形进度条处理
-
首页整体列表动画处理
这一部分数据是最为复杂的
与其他动画相同,我们需要一个controller来控制,在此页面,我们还需要一个List来存放数据
final AnimationController mainScreenAnimationController; final Animation<dynamic> mainScreenAnimation; final List<Habit> habits;
数据存储在此文章暂时不分析,大家可以自己运行源码~
初始化动画:
@override
void initState() {
animationController = AnimationController(
duration: const Duration(milliseconds: 2000), vsync: this);
super.initState();
}
因为使用到动画的组件很多,所以我们根节点使用AnimatedBuilder,主要使用的动画FadeTransition与Transform,做法于上面相同,在此就不多赘述了。
-
环形进度条
我们封装了一个CircleProgressBar用户绘制圆形进度条
这部分的ui很简单,主要是动画的绘制较为复杂
ui:
return AspectRatio(
aspectRatio: 1,
child: AnimatedBuilder(
animation: this.curve,
child: Container(),
builder: (context, child) {
final backgroundColor =
this.backgroundColorTween?.evaluate(this.curve) ??
this.widget.backgroundColor;
final foregroundColor =
this.foregroundColorTween?.evaluate(this.curve) ??
this.widget.foregroundColor;
return CustomPaint(
child: child,
//重点是这个封装组件,这里是圆形里面的进度条
foregroundPainter: CircleProgressBarPainter(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
percentage: this.valueTween.evaluate(this.curve),
strokeWidth: widget.strokeWidth
),
);
},
),
);
详细的绘制:
@override
void paint(Canvas canvas, Size size) {
final Offset center = size.center(Offset.zero);
final Size constrainedSize =
size - Offset(this.strokeWidth, this.strokeWidth);
final shortestSide =
Math.min(constrainedSize.width, constrainedSize.height);
final foregroundPaint = Paint()
..color = this.foregroundColor
..strokeWidth = this.strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final radius = (shortestSide / 2);
// Start at the top. 0 radians represents the right edge
final double startAngle = -(2 * Math.pi * 0.25);
final double sweepAngle = (2 * Math.pi * (this.percentage ?? 0));
// Don't draw the background if we don't have a background color
if (this.backgroundColor != null) {
final backgroundPaint = Paint()
..color = this.backgroundColor
..strokeWidth = this.strokeWidth
..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius, backgroundPaint);
}
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
foregroundPaint,
);
}
这里还有一个很实用的功能:
时间定义和欢迎词
这个demo包含了大部分对时间的处理
例如:
///根据当前时间获取,[monthIndex]个月的开始结束日期
static Pair<DateTime> getMonthStartAndEnd(DateTime now, int monthIndex) {
DateTime start = DateTime(now.year, now.month - monthIndex, 1);
DateTime end = DateTime(now.year, now.month - monthIndex + 1, 0);
return Pair<DateTime>(start, end);
}
强烈推荐大家学习,开发中比较常用!
关于此app的大部分动画ui都分析完成了,其他都是在复用,大家觉得还不错的话可以自己下载体验一下,养成好习惯~
4.适配深色模式(分析一下作者的全局状态管理)
作者在这里使用了Bloc用于状态管理
/// theme mode
enum AppThemeMode {
Light,
Dark,
}
///字体模式
enum AppFontMode {
///默认字体
Roboto,
///三方字体
MaShanZheng,
}
///颜色模式,特定view背景颜色
enum AppThemeColorMode {
Indigo, Orange, Pink, Teal, Blue, Cyan, Purple }
在此基础上,定义了颜色,样式,例如:
String fontFamily(AppFontMode fontMode) {
switch (fontMode) {
case AppFontMode.MaShanZheng:
return 'MaShanZheng';
}
return 'Roboto';
}
然后在使用样式时多用三元判断,这样就很简单的实现了状态管理
这样对这个项目的ui已经动画就分析完成了,大家也可以通过这个项目来学习本地存储,看到这里了,不妨点个赞吧😘