Flutter - 这么炫酷的App你见过吗😍?

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言:今天是1024,先祝各位兄弟们节日快乐,永不脱发,永无Bug😜。说正事:在前几天,我发现了一个动画特别炫酷的一个Flutter项目,一款习惯养成类的App,看了后就真的是爱不释手,功能很丰富,所以我立刻找到了开源作者,向他申请了写作权限。然后开始了对项目的分析(求个赞!!!相信我,看完这篇你会有收获的👍)

我对他项目的代码进行了部分修改,修改的源代码在文章最后~

开源项目地址:github.com/designDo/fl…

先上效果图:

tt0.top-432794.gif tt0.top-795301.gif

还有很多的功能大家自己下载源码(觉得好的话给开源作者点个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:...)
    

    关于动画的实现就是这样,是不是非常的简单~

  • 手机号输入框的限制处理

登录输入框处理.png

我觉得这个样式很炫酷,主要是在平时不是很常见,就分析一下

这里我们封装了一个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),
  • 顶部弹出框的处理

1634777618(1).png

使用了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.底部导航栏的动画处理

tt0.top-150276.gif

这里真的是惊艳到我了,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很简单,主要是动画的绘制较为复杂

屏幕截图 2021-10-23 140905.jpg

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,
);
}

这里还有一个很实用的功能:

时间定义和欢迎词

屏幕截图 2021-10-23 142038.jpg

这个demo包含了大部分对时间的处理

屏幕截图 2021-10-23 142440.jpg 例如:

///根据当前时间获取,[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已经动画就分析完成了,大家也可以通过这个项目来学习本地存储,看到这里了,不妨点个赞吧😘

通知