【Flutter】 如何快速地实现一个计时动画+跳转

发布时间 2023-09-19 16:59:07作者: ZzTzZ

如何快速高效地实现一个计时动画+跳转

没什么用的【背景】

今天群里一个意向移动组的小朋友突然提了个问题,需求是实现一个计时器动画+跳转

image

然后上午的时候我就初步地在群里回答了下,就没再管...

image

不过总之也是不太妥当,刚好下午也是水课,于是就简单做一个面向移动组新生的Flutter动画教程吧!

当然,如果你想省流,可以直接到最后看代码!还有如下的流程图:

image

下面是实现的效果:

image


本文使用环境:Flutter 3.10

工具:Android Studio

正式启动的小教程

由于在课上没带数据线,所以用edge来运行了,希望不会拉跨吧

  1. 新建一个默认项目

image

  1. 观察到MyHomePage这个部分就是Stateful的,所以我们就直接利用MyHomePage来实现计时器的功能,先把没用的模板数据清理干净。

image

  1. 我们分析动画的需求:

    1. 首先需要有一个计时器
    2. 计时器到一定时间之后要触发跳转方法
    3. 要有一个动画来表示计时器的状态
  2. 接下来声明一个Timer,我们先看看能否正常实现计时功能。

  Timer? _timer; // 声明一个计时器,需要引入import 'dart:async';
  int _countValue = 5; // 用于记录计时的变量

  @override
  void initState() { // 在StatefulWidget的生命周期中,initState只会在最开始被执行一次
    super.initState();
    startTimer();
  }
  void startTimer() {
    // Timer.periodic方法可以反复执行,这里设置执行周期为1s
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) { 
      setState(() { // 使用setState来刷新整个StatefulWidget的状态
        if (_countValue > 0) {
          _countValue--;
          print(_countValue);
        } else {
          print('jump'); // 这里使用print来代替跳转的行为,后面再进行不断完善
          _timer?.cancel(); // 记得取消掉计时器,防止出现内存泄漏等问题
        }
      });
    });
  }

  @override
  void dispose() { // dispose方法也只会在StatefulWidget的生命周期中被执行一次,销毁内存
    _timer?.cancel(); // 这里防止计时器没有计时到0时,此页面就被销毁而跳转了,保证严谨
    super.dispose();
  }

看一下实际效果:

image

看起来还是比较合理的,在第6秒,数字被减为-1,触发了<0的判断,于是print了'jump'

  1. 接下来,我们调整一下代码结构,这样方便后续的维护和管理。

image

注意这里的逻辑,我们最终想要5秒跳转,而不是6秒跳转,而我们的代码逻辑中,只有_countValue到了-1的时候才会触发跳转功能,所以这里有一个小细节

  1. 加入路由跳转的功能
  • 首先随便写一个新页面

    image

  • 使用Navigator的路径跳转,这里就不多讲解了,不是重点

    image

  1. 加入动画,放到下一章吧,原谅我混乱的排版

动画的小部分

如今我们已经完成了一个计时器+页面跳转的功能,那么接下来就要加入进度条动画了。

实现动画有很多简单的方式,这里选择最简单易写的方式!!!

使用AnimatedController来实现进度条

  • 首先是简单地实现一个小小的进度条框,注意这里把框的宽度设置为屏幕宽度的0.8倍,高度设置为定值。

    image

  • 创建一个宽度随着时间增加的进度条框,注意Stack的渲染覆盖顺序由于AnimatedController的特性,会自动形成补间动画

  • 这里有很多细节,先把截图放在这里,后面慢慢把这里的细节讲清楚。

    image

小细节大改变!

  1. 关于duration的处理:可以看到我将AnimatedController的duration设置为了一个常量。

image

image

image

这是为什么呢?因为只有当AnimatedController的补间动画的周期,和计时器更新的周期同步时,才能实现秒和秒之间的动画可以稳定衔接!

(当然这里也需要另一个条件,就是动画的曲线默认是线性动画,才能实现完美衔接)

  1. 关于很多处-1的问题:(当然这是因为苯人是个OIER,所以很喜欢搞这些细节上的计算)

image

  • 1:因为_countValue的值是从 jumpTime-1(即4)一直数到-1就跳转,而我们的进度条需要相应的,能够正确从占比0%一直到占比100%。这里需要有一个相减的计算
  • 2:分母也需要减一,因为我们的AnimatedController的width修改了之后,需要用duration的时间进行补间动画,而我们数到最后一秒的时候就直接跳转走了,所以我们最后需要留出一秒的时间来进行跳转,可以参观图例:

image

  1. 关于decoration的问题:为什么这里还需要写一个边框呢?因为我们本质上是下面的紫色条覆盖了上面的框,如果紫色条本身不带框的话,就会出现如下的情况:

image

加了黑色的边框后:

image

总结与代码

至此!我们已经将大多数细节全部讲解完啦!

当然!动画还有很多种实现方式,这里还有一些没有涉及的细节没讲,比如关于Stack的元素对齐方式的问题,比如生命周期的问题。

后面再说吧~

再次放一下具体实现的录屏:

image


最后放一下代码:

main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:test_1/second_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const jumpTime = 5;
  static const duration = Duration(seconds: 1);

  Timer? _timer;
  int _countValue = jumpTime - 1;

  @override
  void initState() {
    super.initState();
    startTimer();
  }

  void startTimer() {
    _timer = Timer.periodic(duration, (timer) {
      setState(() {
        if (_countValue > 0) {
          _countValue--;
          print(_countValue);
        } else {
          //print('jump');
          route2SecondPage();
          _timer?.cancel();
        }
      });
    });
  }

  void route2SecondPage() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => const SecondPage(),
      ),
    );
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    final width = size.width;
    final boxWidth = width * 0.8;
    const boxHeight = 30.0;
    return Scaffold(
      appBar: AppBar(
        title: const Text('ZzTzZ'),
      ),
      body: Center(
        child: Stack(
          children: [
            Container(
              width: boxWidth,
              height: boxHeight,
              decoration: BoxDecoration(
                border: Border.all(
                  color: Colors.black87,
                  width: 3,
                ),
              ),
            ),
            AnimatedContainer(
              duration: duration,
              width: boxWidth * (((jumpTime - 1) - _countValue) / (jumpTime-1)),
              height: boxHeight,
              decoration: BoxDecoration(
                color: Colors.deepPurple,
                border: Border.all(
                  color: Colors.black87,
                  width: 3,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

second_page.dart

import 'package:flutter/material.dart';
class SecondPage extends StatefulWidget {
  const SecondPage({super.key});

  @override
  State<SecondPage> createState() => _SecondPageState();
}

class _SecondPageState extends State<SecondPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('zZtZz')),
      body: const Center(
        child: Text('second page'),
      )
    );
  }
}