
个人主页:ujainu
文章目录
-
- 引言
- [一、状态管理:_StopwatchPageState 核心逻辑](#一、状态管理:_StopwatchPageState 核心逻辑)
-
- [1. 数据状态定义](#1. 数据状态定义)
- [2. 定时器初始化与销毁](#2. 定时器初始化与销毁)
- 二、时间格式化:从毫秒到可读字符串
-
-
- [🔍 格式说明:](#🔍 格式说明:)
-
- [三、UI 构建:双表盘 + 控制按钮 + 计次列表](#三、UI 构建:双表盘 + 控制按钮 + 计次列表)
-
- [1. 主表盘区域:Stack 布局实现多层叠加](#1. 主表盘区域:Stack 布局实现多层叠加)
- [2. 控制按钮:语义化 + 动态配色](#2. 控制按钮:语义化 + 动态配色)
- [3. 计次列表:显示累计与单圈时间](#3. 计次列表:显示累计与单圈时间)
- [四、自定义绘制:StopwatchMainPainter 与 SubSecondPainter](#四、自定义绘制:StopwatchMainPainter 与 SubSecondPainter)
-
- [1. 主表盘绘制:StopwatchMainPainter](#1. 主表盘绘制:StopwatchMainPainter)
-
- [🎨 设计细节:](#🎨 设计细节:)
- [2. 副表盘绘制:SubSecondPainter(0.01 秒)](#2. 副表盘绘制:SubSecondPainter(0.01 秒))
- [五、主题与样式:Material Design 3 适配](#五、主题与样式:Material Design 3 适配)
- 六、设置页面:保持结构完整(虽非核心,但体现工程规范)
- 七、完全代码实现
- 八、优化亮点总结
- 结语
引言
在运动、实验、编程调试等场景中,秒表(Stopwatch) 是一个高频使用的工具。一个优秀的秒表应用不仅需要毫秒级精度 ,还应具备直观的视觉反馈、流畅的交互体验、完整的计次功能 以及对深色/浅色模式的完美支持。
本文将基于 Flutter + Material Design 3 ,带您从零构建一个高保真、可运行、可扩展的秒表界面。我们将围绕以下核心能力展开深度解析:
- ✅ 双表盘设计:主表盘显示分:秒.百分之一秒,副表盘专注 0.01 秒指针;
- ✅ 精准时间格式化 :支持
MM:SS.HH格式(如02:35.47); - ✅ 完整控制逻辑:开始、暂停、重置、计次(Lap);
- ✅ 计次列表管理:显示单圈耗时与累计时间,支持删除;
- ✅ 动态主题适配:自动响应系统亮/暗模式;
- ✅ 自定义绘制(CustomPainter):手绘刻度、数字与指针。
最终,我们将得到一个视觉精致、逻辑严谨、体验流畅的秒表模块,可直接用于 OpenHarmony 或跨平台 Flutter 项目中。
一、状态管理:_StopwatchPageState 核心逻辑
1. 数据状态定义
dart
int _elapsedMilliseconds = 0; // 累计经过的毫秒数
bool _isRunning = false; // 是否正在运行
List<int> _laps = []; // 计次记录(存储毫秒值)
- 使用 毫秒 作为最小单位,确保精度;
_laps存储每次"计次"时的累计毫秒值,便于后续计算单圈时间。
2. 定时器初始化与销毁
dart
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(milliseconds: 10), (_) {
if (_isRunning) {
setState(() {
_elapsedMilliseconds += 10;
});
}
});
}
@override
void dispose() {
_timer.cancel(); // 关键!防止内存泄漏
super.dispose();
}
- 10ms 刷新频率:平衡精度与性能(100Hz 足够人眼识别);
- 条件更新 :仅在
_isRunning为 true 时累加,避免无效 setState; - 资源释放 :
dispose中取消定时器,防止页面销毁后仍在后台运行。
⚠️ 注意:虽然每 10ms 触发一次,但 Flutter 的渲染帧率通常为 60fps(≈16.7ms),实际 UI 更新仍受限于屏幕刷新率,不会造成性能问题。
二、时间格式化:从毫秒到可读字符串
dart
String _formatTime(int milliseconds) {
final totalSeconds = milliseconds ~/ 1000;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
final hundredths = (milliseconds % 1000) ~/ 10;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${hundredths.toString().padLeft(2, '0')}';
}
🔍 格式说明:
minutes:总分钟数;seconds:剩余秒数(0~59);hundredths:百分之一秒(0~99),通过(ms % 1000) ~/ 10获取;- 使用
padLeft(2, '0')确保两位数显示(如05而非5)。
✅ 输出示例:00:00.00 → 01:23.45 → 10:59.99
三、UI 构建:双表盘 + 控制按钮 + 计次列表
1. 主表盘区域:Stack 布局实现多层叠加
dart
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// 背景圆盘
Container(...),
// 自定义绘制:刻度与秒针
CustomPaint(painter: StopwatchMainPainter(...)),
// 时间文本(上移避免遮挡)
Positioned(top: 100, child: Text(timeStr)),
// 小副盘(百分之一秒)
Positioned(top: 190, left: 130, child: Container(...)),
],
),
)
- 使用
Stack实现背景、刻度、文字、副盘的精确叠加; Positioned控制各元素位置,避免相互遮挡;- 时间文本上移至顶部,为下方副盘留出空间。
2. 控制按钮:语义化 + 动态配色
dart
FloatingActionButton.extended(
onPressed: _isRunning ? _pause : _start,
backgroundColor: _isRunning
? (isDark ? Colors.orange[800]! : Colors.orange[400]!)
: (isDark ? Colors.green[700]! : Colors.green[400]!),
icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow),
label: Text(_isRunning ? '暂停' : '开始'),
),
- 颜色语义 :
- 开始:绿色(表示启动);
- 暂停:橙色(表示中断);
- 圈/重置:灰色系(辅助操作);
- 图标+文字:提升可访问性,符合 Material Design 规范。
3. 计次列表:显示累计与单圈时间
dart
final lapTime = _formatTime(_laps[index]); // 累计时间
final diffFromPrev = index == 0
? _formatTime(_laps[0])
: _formatTime(_laps[index] - _laps[index - 1]); // 单圈时间
- 首圈:单圈时间 = 累计时间;
- 后续圈:单圈时间 = 当前累计 - 上一圈累计;
- 删除功能:每项右侧提供删除按钮,支持灵活管理。
💡 体验优化:使用紫色圆标标注圈数,增强列表可读性。
四、自定义绘制:StopwatchMainPainter 与 SubSecondPainter
1. 主表盘绘制:StopwatchMainPainter
dart
class StopwatchMainPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 1. 绘制 60 条刻度线
for (int i = 0; i < 60; i++) {
final isMajor = i % 5 == 0;
// ...
}
// 2. 绘制数字(5, 10, ..., 55)
for (int i = 5; i <= 55; i += 5) {
// 使用 TextPainter 手动绘制文本
}
// 3. 绘制秒针(蓝色)
final secondAngle = (totalSeconds % 60) * 6.0;
canvas.drawLine(center, Offset(secX, secY), secPaint);
}
}
🎨 设计细节:
- 刻度区分:每 5 秒加粗加长,提升可读性;
- 数字定位 :仅显示
5, 10, ..., 55,避免拥挤; - 秒针颜色 :使用
Colors.blue,与背景形成对比; - 角度计算 :以 12 点为 0°,通过
-90调整坐标系。
2. 副表盘绘制:SubSecondPainter(0.01 秒)
dart
class SubSecondPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 100 条刻度(每 0.01 秒一条)
for (int i = 0; i < 100; i++) {
final isMajor = i % 10 == 0;
// ...
}
// 黑色指针指向当前百分之一秒
final angle = (subMilliseconds ~/ 10) * 3.6;
canvas.drawLine(center, Offset(x, y), paint);
}
}
- 高密度刻度:100 条线对应 0~99 百分之一秒;
- 指针联动 :实时反映
_elapsedMilliseconds % 1000的变化; - 尺寸小巧:直径仅 60px,嵌入主表盘空白区域,不喧宾夺主。
🌟 视觉价值:副表盘不仅提升专业感,更让用户直观感知"百分之一秒"的流逝。
五、主题与样式:Material Design 3 适配
dart
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
scaffoldBackgroundColor: Colors.white,
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.black,
),
- 启用
useMaterial3: true,使用最新设计语言; - 浅色模式:白色背景 + 黑色文字;
- 深色模式:黑色背景 + 白色文字;
- 所有颜色(刻度、文字、按钮)均通过
isDark动态切换,确保一致性。
示例:表盘背景
dart
color: isDark ? Colors.grey[900]! : Colors.white,
border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!, width: 2),
六、设置页面:保持结构完整(虽非核心,但体现工程规范)
尽管本文聚焦秒表界面,但代码中包含了一个完整的 SettingsPage,展示了:
- 对话框选择 :
showDialog+SimpleDialog; - 开关控件 :
SwitchSettingItem封装复用; - 信息展示:版本号、关于等静态项。
这体现了良好的工程习惯:即使次要页面也保持代码整洁与可维护性。
七、完全代码实现
dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '秒表',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: Colors.white,
appBarTheme: const AppBarTheme(backgroundColor: Colors.white, foregroundColor: Colors.black),
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: Colors.black,
appBarTheme: const AppBarTheme(backgroundColor: Colors.black, foregroundColor: Colors.white),
),
home: const StopwatchPage(),
);
}
}
// ===========================
// 秒表主页面
// ===========================
class StopwatchPage extends StatefulWidget {
const StopwatchPage({super.key});
@override
State<StopwatchPage> createState() => _StopwatchPageState();
}
class _StopwatchPageState extends State<StopwatchPage> {
late Timer _timer;
int _elapsedMilliseconds = 0;
bool _isRunning = false;
List<int> _laps = [];
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(milliseconds: 10), (_) {
if (_isRunning) {
setState(() {
_elapsedMilliseconds += 10;
});
}
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
void _start() {
setState(() {
_isRunning = true;
});
}
void _pause() {
setState(() {
_isRunning = false;
});
}
void _reset() {
setState(() {
_isRunning = false;
_elapsedMilliseconds = 0;
_laps.clear();
});
}
void _lap() {
if (_isRunning) {
setState(() {
_laps.add(_elapsedMilliseconds);
});
}
}
String _formatTime(int milliseconds) {
final totalSeconds = milliseconds ~/ 1000;
final minutes = totalSeconds ~/ 60;
final seconds = totalSeconds % 60;
final hundredths = (milliseconds % 1000) ~/ 10;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${hundredths.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final timeStr = _formatTime(_elapsedMilliseconds);
return Scaffold(
appBar: AppBar(
backgroundColor: isDark ? Colors.black : Colors.white,
elevation: 0,
title: const Text('秒表', style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (ctx) => const SettingsPage()),
);
},
),
],
),
body: Column(
children: [
// 表盘区域
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// 主表盘背景
Container(
width: 320,
height: 320,
decoration: BoxDecoration(
color: isDark ? Colors.grey[900]! : Colors.white,
shape: BoxShape.circle,
border: Border.all(color: isDark ? Colors.grey[700]! : Colors.grey[300]!, width: 2),
boxShadow: [
BoxShadow(
color: isDark ? Colors.black.withOpacity(0.5) : Colors.grey.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
),
// 刻度与数字
CustomPaint(
painter: StopwatchMainPainter(_elapsedMilliseconds, isDark),
size: const Size(320, 320),
),
// 时间文本:缩小 + 上移
Positioned(
top: 100, // 上移
left: 0,
right: 0,
child: Center(
child: Text(
timeStr,
style: TextStyle(
fontSize: 40, // 从 48 减小
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
fontFamily: 'Courier New, monospace',
),
),
),
),
// 小副盘(百分之一秒)------放在主表盘内下方空白处
Positioned(
top: 190, // 在主表盘内部,时间下方
left: 130,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: isDark ? Colors.grey[800]! : Colors.grey[100]!,
shape: BoxShape.circle,
border: Border.all(color: isDark ? Colors.grey[600]! : Colors.grey[300]!, width: 1),
boxShadow: [
BoxShadow(
color: isDark ? Colors.black.withOpacity(0.3) : Colors.grey.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: CustomPaint(
painter: SubSecondPainter(_elapsedMilliseconds % 1000, isDark),
size: const Size(60, 60),
),
),
),
],
),
),
// 控制按钮(圈、开始、重置)------使用柔和配色
Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton.extended(
onPressed: _lap,
backgroundColor: isDark ? Colors.blueGrey[700]! : Colors.blueGrey[300]!,
icon: const Icon(Icons.flag, color: Colors.white),
label: const Text('圈', style: TextStyle(color: Colors.white)),
),
FloatingActionButton.extended(
onPressed: _isRunning ? _pause : _start,
backgroundColor: _isRunning
? (isDark ? Colors.orange[800]! : Colors.orange[400]!)
: (isDark ? Colors.green[700]! : Colors.green[400]!),
icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow, color: Colors.white),
label: Text(_isRunning ? '暂停' : '开始', style: const TextStyle(color: Colors.white)),
),
FloatingActionButton.extended(
onPressed: _reset,
backgroundColor: isDark ? Colors.grey[700]! : Colors.grey[400]!,
icon: const Icon(Icons.restart_alt, color: Colors.white),
label: const Text('重置', style: TextStyle(color: Colors.white)),
),
],
),
),
// 计次列表
Expanded(
child: _laps.isEmpty
? const Center(child: Text('暂无计次记录', style: TextStyle(color: Colors.grey)))
: ListView.builder(
itemCount: _laps.length,
itemBuilder: (context, index) {
final lapTime = _formatTime(_laps[index]);
final diffFromPrev = index == 0
? _formatTime(_laps[0])
: _formatTime(_laps[index] - _laps[index - 1]);
return ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.purple.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.purple.withOpacity(0.4), width: 1),
),
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(color: Colors.purple, fontSize: 12),
),
),
),
title: Text(lapTime, style: const TextStyle(fontSize: 16)),
subtitle: Text('+${diffFromPrev}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
trailing: IconButton(
icon: const Icon(Icons.delete, size: 18, color: Colors.grey),
onPressed: () {
setState(() {
_laps.removeAt(index);
});
},
),
);
},
),
),
],
),
);
}
}
// ===========================
// 主表盘绘制
// ===========================
class StopwatchMainPainter extends CustomPainter {
final int elapsedMilliseconds;
final bool isDark;
StopwatchMainPainter(this.elapsedMilliseconds, this.isDark);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 20;
final textColor = isDark ? Colors.white : Colors.black;
// 刻度线
for (int i = 0; i < 60; i++) {
final angle = i * 6.0;
final start = center + Offset(cos(angle * pi / 180) * (radius - 15), sin(angle * pi / 180) * (radius - 15));
final end = center + Offset(cos(angle * pi / 180) * radius, sin(angle * pi / 180) * radius);
final paint = Paint()
..color = i % 5 == 0 ? textColor : textColor.withOpacity(0.4)
..strokeWidth = i % 5 == 0 ? 3 : 1;
canvas.drawLine(start, end, paint);
}
// 数字(5, 10, ..., 55)
for (int i = 5; i <= 55; i += 5) {
final angle = (i - 15) * 6.0;
final x = center.dx + cos(angle * pi / 180) * (radius - 35);
final y = center.dy - sin(angle * pi / 180) * (radius - 35);
final text = TextSpan(
text: i.toString(),
style: TextStyle(
fontSize: 14,
color: textColor,
fontWeight: FontWeight.bold,
),
);
final metrics = TextPainter(
text: text,
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
)..layout(minWidth: 0, maxWidth: 20);
metrics.paint(canvas, Offset(x - 10, y - 10));
}
// 秒针(蓝色)
final totalSeconds = elapsedMilliseconds ~/ 1000;
final secondAngle = (totalSeconds % 60) * 6.0;
final secX = center.dx + cos((secondAngle - 90) * pi / 180) * radius * 0.85;
final secY = center.dy + sin((secondAngle - 90) * pi / 180) * radius * 0.85;
final secPaint = Paint()..color = Colors.blue..strokeWidth = 2.5;
canvas.drawLine(center, Offset(secX, secY), secPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
// ===========================
// 小副盘(0.01秒)
// ===========================
class SubSecondPainter extends CustomPainter {
final int subMilliseconds;
final bool isDark;
SubSecondPainter(this.subMilliseconds, this.isDark);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 8;
final textColor = isDark ? Colors.white : Colors.black;
for (int i = 0; i < 100; i++) {
final angle = i * 3.6;
final start = center + Offset(cos(angle * pi / 180) * (radius - 8), sin(angle * pi / 180) * (radius - 8));
final end = center + Offset(cos(angle * pi / 180) * radius, sin(angle * pi / 180) * radius);
final paint = Paint()
..color = i % 10 == 0 ? textColor : textColor.withOpacity(0.3)
..strokeWidth = i % 10 == 0 ? 1.5 : 0.8;
canvas.drawLine(start, end, paint);
}
final angle = (subMilliseconds ~/ 10) * 3.6;
final x = center.dx + cos((angle - 90) * pi / 180) * radius * 0.8;
final y = center.dy + sin((angle - 90) * pi / 180) * radius * 0.8;
final paint = Paint()..color = Colors.black..strokeWidth = 1.5;
canvas.drawLine(center, Offset(x, y), paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
// ===========================
// 设置页面(保持不变)
// ===========================
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
String _ringDuration = '5分钟';
String _closeMethod = '按钮关闭';
bool _enablePreAlarm = true;
bool _fadeRingtone = true;
bool _morningBriefing = false;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('设置'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text('闹钟', style: TextStyle(fontSize: 14, color: Colors.grey)),
const SizedBox(height: 12),
SettingItem(
title: '响铃时长',
value: _ringDuration,
onTap: () async {
final result = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('响铃时长'),
children: ['5分钟', '10分钟', '15分钟', '20分钟', '30分钟'].map((item) {
return SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx, item);
},
child: Text(item),
);
}).toList(),
),
);
if (result != null) {
setState(() {
_ringDuration = result;
});
}
},
),
SettingItem(
title: '响铃关闭方式',
value: _closeMethod,
onTap: () async {
final result = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('关闭方式'),
children: ['按钮关闭', '上滑关闭'].map((item) {
return SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx, item);
},
child: Text(item),
);
}).toList(),
),
);
if (result != null) {
setState(() {
_closeMethod = result;
});
}
},
),
SettingItem(title: '默认铃声', value: '放假', onTap: () {}),
SwitchSettingItem(
title: '即将响铃通知',
subtitle: '响铃前15分钟在通知栏提醒,可提前关闭闹钟',
value: _enablePreAlarm,
onChanged: (v) => setState(() => _enablePreAlarm = v),
),
SwitchSettingItem(
title: '铃声渐响',
subtitle: '闹钟响铃时逐渐增大至设定音量',
value: _fadeRingtone,
onChanged: (v) => setState(() => _fadeRingtone = v),
),
SwitchSettingItem(
title: '晨间播报',
subtitle: '在关闭04:00-10:00之间的首个闹钟后,播放天气与新闻。此功能需开启"小布助手"的通知权限。',
value: _morningBriefing,
onChanged: (v) => setState(() => _morningBriefing = v),
),
const SizedBox(height: 24),
const Text('其他', style: TextStyle(fontSize: 14, color: Colors.grey)),
const SizedBox(height: 12),
SettingItem(title: '日期和时间', subtitle: '系统时间、双时钟', onTap: () {}),
SettingItem(title: '版本号', value: '15.10.8_5525868_250807', onTap: null),
SettingItem(title: '关于时钟', onTap: () {}),
],
),
),
);
}
}
// ===========================
// 设置项组件(保持不变)
// ===========================
class SettingItem extends StatelessWidget {
final String title;
final String? value;
final String? subtitle;
final VoidCallback? onTap;
const SettingItem({
super.key,
required this.title,
this.value,
this.subtitle,
this.onTap,
});
@override
Widget build(BuildContext context) {
final canTap = onTap != null;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: ListTile(
title: Text(title, style: const TextStyle(fontSize: 16)),
subtitle: subtitle != null ? Text(subtitle!, style: const TextStyle(fontSize: 14, color: Colors.grey)) : null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (value != null) Text(value!, style: const TextStyle(color: Colors.grey)),
if (canTap) const Icon(Icons.arrow_forward, size: 18, color: Colors.grey),
],
),
onTap: canTap ? onTap : null,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
}
class SwitchSettingItem extends StatelessWidget {
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const SwitchSettingItem({
super.key,
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
child: ListTile(
title: Text(title, style: const TextStyle(fontSize: 16)),
subtitle: subtitle != null ? Text(subtitle!, style: const TextStyle(fontSize: 14, color: Colors.grey)) : null,
trailing: Switch(value: value, onChanged: onChanged),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
}
运行界面

开始计时并计次

设置界面

八、优化亮点总结
| 功能 | 优化点 |
|---|---|
| 时间精度 | 10ms 定时器 + 毫秒存储,支持百分之一秒显示 |
| 视觉设计 | 主副表盘联动、刻度分级、动态配色 |
| 交互体验 | 语义化按钮、计次删除、圈数标识 |
| 性能安全 | 条件更新、定时器释放、mounted 检查(隐含) |
| 可维护性 | CustomPainter 拆分、组件封装(SettingItem) |
结语
通过本文,我们不仅实现了一个功能完整的秒表界面,更展示了 Flutter 和OpenHarmony在自定义绘制、状态管理、主题适配、用户体验设计等方面的强大能力。该模块代码结构清晰、扩展性强,可轻松集成到您的时钟应用中。
未来,您可以在此基础上进一步扩展:
- 添加振动反馈(计次时短震);
- 支持后台运行(需平台通道);
- 实现历史记录持久化 (使用
shared_preferences); - 增加圈速图表可视化。
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net