Flutter 实战:lucky_number 幸运数字生成器的滚动动画、历史记录与鸿蒙适配解析

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

幸运数字生成器是一个很适合拆解 Flutter 动画状态的小项目。它看起来只是点击按钮生成一个数字,但源码里包含了 随机数生成动画监听防重复点击滚动数字效果结果高亮历史记录按钮禁用态跨端视觉验证 等多个知识点。

lucky_number 的核心流程非常清楚:用户点击按钮后,页面进入 spinning 状态,AnimationController 在 2 秒内驱动滚动数字持续变化;动画完成后生成 0 到 9 的幸运数字,写入历史列表,并更新数字出现频次。界面会高亮中间数字,同时展示最近生成的结果。

抽奖类小工具的关键不是随机数本身,而是“开始、滚动、完成、展示、历史”这一整条交互链路是否稳定。

在这里插入图片描述

图示说明:上图展示 Flutter 页面在移动端的布局组织方式。lucky_number 的实际界面由标题、五位滚动数字、幸运数字结果卡片、按钮和历史 Chip 组成。

一、项目定位与功能边界

1.1 应用定位

lucky_number 是一个轻量幸运数字生成工具,适合用于抽签、随机演示、Flutter 动画教学和状态流转分析。它没有网络请求,也没有复杂业务依赖,核心逻辑完全由 Dart 随机数和 Flutter 动画系统实现。

项目当前支持:

  • 生成 0 到 9 的幸运数字。
  • 使用 5 个数字格模拟滚动效果。
  • 中间数字作为最终幸运数字展示。
  • 动画期间按钮禁用,防止重复触发。
  • 动画完成后展示结果卡片。
  • 保存最近 10 次生成结果。
  • 页面底部展示最近 5 个历史数字。
  • 维护数字出现频次 Map。

1.2 功能模块

功能模块 页面表现 源码实现
随机数字 0 到 9 的结果 math.Random().nextInt(10)
滚动动画 五个数字持续变化 _controller.addListener(_updateNumbers)
防重复点击 转动中按钮禁用 _isSpinning
结果展示 中间数字高亮 index == 2
历史记录 最近数字 Chip _history.take(5)
频次统计 内部 Map 更新 _numberFrequency[result]
生命周期 释放动画控制器 _controller.dispose()

1.3 技术栈

技术点 使用位置 价值
Flutter 页面、按钮、卡片、Chip 构建可交互 UI
Dart 随机数、列表、Map 实现抽取与统计
Material 3 应用主题和组件风格 useMaterial3: true
StatefulWidget 管理转动状态和历史 响应用户点击和动画完成
AnimationController 驱动滚动效果 形成 2 秒抽取过程

二、工程结构与运行环境

2.1 工程结构

lucky_number 是标准 Flutter 工程,核心代码集中在 lib/main.dart

文件或目录 作用
lib/main.dart 应用入口、动画控制、随机数、历史记录和 UI
pubspec.yaml Flutter SDK 与测试依赖声明
test/widget_test.dart Widget 测试入口
ohos/ 鸿蒙平台工程目录
analysis_options.yaml Dart 静态分析规则

2.2 运行命令

flutter doctor
flutter pub get
flutter run

项目依赖较轻,随机数来自 Dart 标准库 dart:math,动画来自 Flutter SDK。

2.3 依赖声明

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

这种结构对鸿蒙适配比较友好,核心逻辑不依赖平台通道,主要验证动画、布局、字体和按钮状态即可。

三、应用入口与主题配置

3.1 main 函数

Flutter 应用从 main() 进入:

import 'package:flutter/material.dart';
import 'dart:math' as math;

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

dart:math 用于生成滚动数字和最终幸运数字。

3.2 根组件

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Lucky Number',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber),
        useMaterial3: true,
      ),
      home: const LuckyNumberHomePage(title: 'Lucky Number'),
    );
  }
}

根组件负责配置标题、主题和首页,不保存抽取状态。抽取状态全部由首页 State 管理。

3.3 主题色

colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber)

琥珀色主题和幸运数字的视觉语义比较契合,也用于结果高亮、按钮背景和阴影效果。

四、StatefulWidget 与动画混入

4.1 首页组件

class LuckyNumberHomePage extends StatefulWidget {
  const LuckyNumberHomePage({super.key, required this.title});
  final String title;

  
  State<LuckyNumberHomePage> createState() => _LuckyNumberHomePageState();
}

首页需要处理按钮点击、动画进度、随机数字、历史记录和结果展示,因此使用 StatefulWidget

4.2 SingleTickerProviderStateMixin

class _LuckyNumberHomePageState extends State<LuckyNumberHomePage>
    with SingleTickerProviderStateMixin {
  // ...
}

SingleTickerProviderStateMixin 为单个 AnimationController 提供 vsync,避免不必要的动画资源消耗。

4.3 核心状态字段

int _luckyNumber = 7;
List<int> _spinningNumbers = List.generate(5, (_) => 0);
bool _isSpinning = false;
late AnimationController _controller;
final List<int> _history = [];
final Map<int, int> _numberFrequency = {};
字段 类型 作用
_luckyNumber int 最终幸运数字,初始为 7
_spinningNumbers List<int> 转动过程中显示的 5 个数字
_isSpinning bool 是否正在转动
_controller AnimationController 控制 2 秒动画
_history List<int> 最近生成的幸运数字
_numberFrequency Map<int, int> 数字出现次数统计

五、动画初始化与生命周期

5.1 初始化控制器


void initState() {
  super.initState();
  _controller = AnimationController(
    duration: const Duration(milliseconds: 2000),
    vsync: this,
  );
  _controller.addListener(_updateNumbers);
  _controller.addStatusListener((status) {
    if (status == AnimationStatus.completed) {
      _finishSpinning();
    }
  });
}

动画时长为 2 秒。动画播放期间,每一帧都会触发 _updateNumbers();动画完成时调用 _finishSpinning() 收口。

5.2 监听器职责

监听器 触发时机 职责
addListener 动画每次 tick 更新滚动数字
addStatusListener 动画状态变化 在 completed 时生成最终结果

5.3 释放控制器


void dispose() {
  _controller.dispose();
  super.dispose();
}

动画控制器持有 ticker 资源,页面销毁时必须释放。这是 Flutter 动画页面的基础规范。

六、滚动数字更新逻辑

6.1 _updateNumbers 方法

void _updateNumbers() {
  final random = math.Random();
  setState(() {
    _spinningNumbers = List.generate(5, (_) => random.nextInt(10));
  });
}

动画播放期间,这个方法不断生成 5 个 0 到 9 的随机数字,模拟老虎机式滚动效果。

6.2 数字范围

random.nextInt(10)

nextInt(10) 会生成 0 到 9 的整数,不包含 10。

6.3 为什么是 5 个数字

页面使用 5 个数字格,中间位置最终高亮。这样既有滚动氛围,又能明确告诉用户哪个数字是结果。

左侧数字
左中数字
中间结果数字
右中数字
右侧数字

七、开始转动与防重复点击

7.1 _spin 方法

void _spin() {
  if (_isSpinning) return;

  setState(() {
    _isSpinning = true;
  });
  _controller.forward(from: 0);
}

如果当前已经在转动,方法直接返回,避免重复启动动画。

7.2 动画从头播放

_controller.forward(from: 0);

每次抽取都从动画起点重新开始,这样每次点击都有完整 2 秒滚动过程。

7.3 按钮禁用态

onPressed: _isSpinning ? null : _spin

_isSpinning 为 true 时,按钮禁用。这比单纯在 _spin() 里 return 更直观,因为 UI 也会告诉用户当前不能重复点击。

八、完成转动与结果写入

8.1 _finishSpinning 方法

void _finishSpinning() {
  final random = math.Random();
  final result = random.nextInt(10);

  setState(() {
    _isSpinning = false;
    _spinningNumbers = List.generate(5, (_) => random.nextInt(10));
    _luckyNumber = result;
    _history.insert(0, result);
    if (_history.length > 10) {
      _history.removeLast();
    }
    _numberFrequency[result] = (_numberFrequency[result] ?? 0) + 1;
  });
}

动画结束后会生成最终幸运数字,并更新页面状态。

8.2 状态更新内容

更新项 作用
_isSpinning = false 结束转动,恢复按钮
_spinningNumbers 刷新 5 个显示数字
_luckyNumber = result 写入最终结果
_history.insert(0, result) 保存最新历史
_history.removeLast() 控制历史长度
_numberFrequency[result] 统计结果出现次数

8.3 频次统计的真实表现

源码维护了 _numberFrequency,但当前 UI 没有把频次统计展示出来。也就是说它已经具备统计数据基础,但还没有形成可见的统计面板。

九、数字滚动 UI

9.1 五个数字格

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: List.generate(5, (index) {
    final isHighlighted = !_isSpinning && index == 2;
    return Container(
      width: 50,
      height: 70,
      margin: const EdgeInsets.symmetric(horizontal: 4),
      child: Center(child: Text(...)),
    );
  }),
)

五个数字格横向排列,营造抽取滚动效果。

9.2 中间高亮

final isHighlighted = !_isSpinning && index == 2;

当动画停止后,中间数字格高亮,表示它是最终结果。

9.3 展示逻辑

_isSpinning
    ? _spinningNumbers[index].toString()
    : (index == 2
        ? _luckyNumber.toString()
        : _spinningNumbers[index].toString())

转动中展示滚动数字;停止后,中间位置展示 _luckyNumber,其他位置仍展示最后一次滚动数字。

十、结果卡片与历史记录

10.1 结果卡片条件

if (!_isSpinning && _history.isNotEmpty)
  Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          const Text('Your Lucky Number'),
          Text(_luckyNumber.toString()),
        ],
      ),
    ),
  )

只有不在转动中且已有历史结果时,才展示幸运数字卡片。

10.2 历史 Chip

if (_history.length > 1)
  Wrap(
    spacing: 4,
    children: _history.take(5).map((n) {
      return Chip(
        label: Text(n.toString()),
        backgroundColor:
            n == _luckyNumber ? Colors.amber : Colors.grey.shade200,
      );
    }).toList(),
  )

页面最多展示最近 5 个历史数字,当前结果会使用琥珀色高亮。

10.3 历史保存策略

历史用途 当前实现
数据保存 最多 10 个
页面展示 最近 5 个
最新位置 列表第 0 位
当前结果高亮 Chip 背景色

十一、按钮与交互状态

11.1 按钮实现

ElevatedButton.icon(
  onPressed: _isSpinning ? null : _spin,
  icon: Icon(_isSpinning ? Icons.hourglass_empty : Icons.casino),
  label: Text(_isSpinning ? 'Spinning...' : 'Spin for Lucky Number'),
  style: ElevatedButton.styleFrom(
    padding: const EdgeInsets.all(20),
    backgroundColor: Colors.amber,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(16),
    ),
  ),
)

按钮图标和文案会根据转动状态变化。

11.2 状态表

状态 图标 文案 是否可点击
未转动 Icons.casino Spin for Lucky Number 可以
转动中 Icons.hourglass_empty Spinning... 不可以

11.3 交互反馈

按钮禁用态、滚动数字和结果卡片共同组成完整反馈。用户点击后能明显感知“已经开始、正在进行、已经完成”。

十二、页面布局结构

12.1 Scaffold 骨架

return Scaffold(
  appBar: AppBar(
    title: Text(widget.title),
    backgroundColor: Theme.of(context).colorScheme.inversePrimary,
  ),
  body: Column(
    children: [
      const SizedBox(height: 32),
      const Text('Lucky Number Generator'),
      Row(...),
      if (!_isSpinning && _history.isNotEmpty) Card(...),
      const Spacer(),
      ElevatedButton.icon(...),
      if (_history.length > 1) Padding(...),
    ],
  ),
);

页面采用纵向结构,上方展示标题和数字,中部展示结果,下方放按钮和历史。

12.2 Spacer 的作用

const Spacer()

Spacer 把按钮推向页面底部,让结果区域和操作区域形成清晰层级。

12.3 视觉层级

区域 作用
标题 明确应用主题
数字格 展示滚动过程
结果卡片 展示最终幸运数字
按钮 触发新一轮抽取
历史 Chip 回看最近结果

十三、边界场景与真实限制

13.1 防重复点击

_spin() 内部和按钮禁用态都处理了重复点击问题。即使用户快速点击按钮,转动过程中也不会重复启动动画。

13.2 历史数量限制

历史列表最多保存 10 条:

if (_history.length > 10) {
  _history.removeLast();
}

页面只展示最近 5 条,因此历史数据和 UI 展示范围并不完全相同。

13.3 频次统计未展示

_numberFrequency 已经在结果完成时更新,但当前页面没有展示频次图表或统计列表。后续可以把它做成数字出现次数面板。

13.4 随机数重复

随机数范围只有 0 到 9,重复出现是正常现象。它更像娱乐抽取工具,不适合用于严肃随机安全场景。

十四、Widget 测试设计

14.1 基础渲染测试

import 'package:flutter_test/flutter_test.dart';
import '../lib/main.dart';

void main() {
  testWidgets('lucky number renders home page', (tester) async {
    await tester.pumpWidget(const LuckyNumberApp());

    expect(find.text('Lucky Number'), findsWidgets);
    expect(find.text('Spin for Lucky Number'), findsOneWidget);
  });
}

这个测试验证根组件和默认按钮文案。

14.2 点击按钮测试

testWidgets('spin button enters spinning state', (tester) async {
  await tester.pumpWidget(const LuckyNumberApp());

  await tester.tap(find.text('Spin for Lucky Number'));
  await tester.pump();

  expect(find.text('Spinning...'), findsOneWidget);
});

这个测试覆盖点击后 _isSpinning 状态变化。

14.3 动画完成测试

testWidgets('spin completes and shows result card', (tester) async {
  await tester.pumpWidget(const LuckyNumberApp());

  await tester.tap(find.text('Spin for Lucky Number'));
  await tester.pump(const Duration(milliseconds: 2100));

  expect(find.text('Your Lucky Number'), findsOneWidget);
});

测试中推进时间超过 2 秒,可以验证动画完成后的结果展示。

14.4 测试命令

flutter test

保持测试里的根组件名称与实际源码一致,能避免默认模板测试残留造成编译失败。

十五、鸿蒙适配观察

15.1 适配优势

lucky_number 的核心逻辑由 Dart 随机数和 Flutter 动画系统完成,没有复杂原生插件依赖,因此鸿蒙侧主要关注动画、布局和字体显示。

维度 当前项目情况 鸿蒙侧关注点
随机数 math.Random() 多端逻辑一致
动画 AnimationController 2 秒动画流畅度
按钮 ElevatedButton.icon 禁用态和触控反馈
数字格 Row + Container 小屏宽度和数字显示
历史 Wrap + Chip 换行和高亮表现

15.2 构建命令参考

flutter clean
flutter pub get
flutter build hap

具体构建命令取决于所使用的鸿蒙 Flutter 适配环境。这个项目重点验证动画、按钮状态、数字布局和 Chip 展示。

15.3 运行验证要点

  1. 应用能正常启动到首页。
  2. 点击按钮后进入 Spinning... 状态。
  3. 转动期间数字持续变化。
  4. 动画结束后中间数字高亮。
  5. 结果卡片能正常显示。
  6. 最近历史 Chip 能正确展示和高亮。

鸿蒙适配时,这类项目的关键是动画帧、按钮禁用态、数字布局和历史 Chip 换行,而不是随机算法本身。

十六、性能与可维护性

16.1 性能特征

项目计算量很小,动画期间主要是 5 个数字的状态刷新。

维度 当前表现
动画时长 2 秒
每次刷新数字 5 个
历史保存 10 条
页面展示历史 5 条
结果范围 0 到 9

16.2 当前结构优点

  • 抽取状态由 _isSpinning 统一控制。
  • 动画监听与完成监听职责分离。
  • 历史记录有长度限制。
  • 按钮 UI 和状态同步变化。
  • 动画控制器生命周期处理完整。

16.3 可演进方向

可以把结果范围做成可配置项:

int generateLuckyNumber({int maxExclusive = 10}) {
  return math.Random().nextInt(maxExclusive);
}

也可以把 _numberFrequency 展示成统计列表:

List<MapEntry<int, int>> sortedFrequency(Map<int, int> source) {
  final entries = source.entries.toList();
  entries.sort((a, b) => b.value.compareTo(a.value));
  return entries;
}

这样可以从娱乐工具扩展成带统计信息的小型随机分析页面。

十七、扩展功能思路

17.1 自定义范围

用户可以输入最小值和最大值,生成指定范围内的幸运数字。

int randomInRange(int min, int max) {
  return min + math.Random().nextInt(max - min + 1);
}

17.2 展示频次统计

当前 _numberFrequency 已经维护数据,可以新增一个统计卡片展示每个数字出现次数。

_numberFrequency.forEach((number, count) {
  // 构建统计行
});

17.3 动画节奏优化

可以让动画前快后慢,增强抽取仪式感。当前项目使用固定时长和监听刷新,后续可以结合曲线或间隔变化优化体验。

十八、常见问题与优化建议

18.1 为什么使用 AnimationController

因为项目需要一个明确的 2 秒转动过程,并在动画完成后生成结果。AnimationController 可以同时提供播放控制和状态监听。

18.2 为什么 _spin() 要判断 _isSpinning

它可以防止用户连续点击导致多轮动画重叠。按钮禁用是 UI 层保护,_spin() 判断是逻辑层保护。

18.3 为什么最终结果只取 0 到 9

源码使用 random.nextInt(10),所以结果范围固定为 0 到 9。这个范围适合单数字幸运号码展示。

18.4 为什么历史只展示最近 5 个

页面底部空间有限,展示 5 个 Chip 更紧凑。内部仍保留最多 10 条历史,方便后续扩展。

18.5 为什么频次统计没有出现在页面上

当前源码只维护 _numberFrequency,没有对应 UI。它更像为后续统计展示预留的数据基础。

18.6 为什么适合做鸿蒙适配示例

它同时包含动画、按钮禁用、数字布局、Chip、阴影和随机结果展示,能覆盖 Flutter 小型互动页面在鸿蒙侧的多个验证点。

总结

lucky_number 用一个 Flutter 页面完成了幸运数字生成器的完整交互闭环:点击按钮开始转动,动画监听持续刷新 5 个数字,动画完成后生成最终结果,写入历史记录并更新频次统计。

从工程角度看,这个项目的结构很适合学习 Flutter 动画状态管理。_isSpinning 控制按钮和展示状态,AnimationController 控制流程节奏,_history_numberFrequency 则为结果追踪提供数据基础。

从鸿蒙适配角度看,项目没有复杂原生依赖,主要验证动画流畅度、按钮禁用态、数字格布局、Chip 展示和字体渲染即可。处理好这些细节后,跨端体验会比较稳定。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐