Flutter 实战:lucky_number 幸运数字生成器的滚动动画、历史记录与鸿蒙适配解析
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 运行验证要点
- 应用能正常启动到首页。
- 点击按钮后进入
Spinning...状态。 - 转动期间数字持续变化。
- 动画结束后中间数字高亮。
- 结果卡片能正常显示。
- 最近历史 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 展示和字体渲染即可。处理好这些细节后,跨端体验会比较稳定。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐




所有评论(0)