在这里插入图片描述

个人主页:ujainu

引言

在全球化日益加深的今天,世界时钟(World Clock) 已成为商务人士、远程工作者和旅行者不可或缺的工具。一个优秀的世界时钟应用不仅要能精准显示多时区时间,还需具备直观的 UI、流畅的交互、灵活的城市管理以及优雅的视觉呈现

本文将基于 Flutter + Material Design 3,带您从零构建一个功能完整、体验流畅、可直接运行的世界时钟模块。我们将围绕以下核心能力展开深度解析:

  • 全球城市数据库设计:支持 UTC 偏移(含半小数如 +5.5);
  • 双模式时间显示:点击切换模拟指针钟表数字时间
  • 动态主城市切换:任意城市设为主时钟,其他城市自动计算时差;
  • 智能城市搜索:集成 showSearch 实现高效添加;
  • 实时秒级更新:通过 Timer.periodic 驱动界面刷新;
  • 深色/浅色主题适配:完美响应系统外观设置。

最终,我们将得到一个结构清晰、扩展性强、视觉精致的世界时钟页面,可无缝集成到 OpenHarmony 或跨平台 Flutter 项目中。


一、数据模型设计:CityData 与 DisplayCity

1. 城市基础数据:CityData

class CityData {
  final String name;
  final double utcOffset;

  CityData(this.name, this.utcOffset);
}
  • name:城市中文名,便于用户识别;
  • utcOffset:UTC 偏移量,单位为小时,支持整数(如北京 +8)和小数(如德里 +5.5)。

💡 为什么用 double
全球有多个地区使用半小时或四分之三小时时区(如印度 +5:30、尼泊尔 +5:45)。使用 double 可精确表示这些偏移。

我们预置了 39 个全球主要城市:

final List<CityData> _allCities = [
  CityData('北京', 8),
  CityData('伦敦', 0),
  CityData('德里', 5.5),
  // ... 其他城市
];

2. 显示层数据:DisplayCity

class DisplayCity {
  final String name;
  final double utcOffset;

  DisplayCity(this.name, this.utcOffset);

  String getDescription(double mainUtcOffset) {
    final diff = utcOffset - mainUtcOffset;
    if (diff == 0) return '本地时间';
    if (diff > 0) {
      return '早${diff.toStringAsFixed(diff % 1 == 0 ? 0 : 1)}小时';
    } else {
      return '晚${(-diff).toStringAsFixed((-diff) % 1 == 0 ? 0 : 1)}小时';
    }
  }
}
  • getDescription() 方法根据主城市 UTC 偏移,动态计算并返回友好提示(如“早 8 小时”、“晚 5.5 小时”);
  • 使用 toStringAsFixed(0)(1) 自动隐藏不必要的 .0,提升阅读体验。

二、状态管理:_WorldClockPageState 核心逻辑

1. 初始化与定时刷新


void initState() {
  super.initState();
  _timer = Timer.periodic(const Duration(seconds: 1), (_) {
    if (mounted) setState(() {});
  });
}
  • 每秒调用 setState,触发整个页面重绘;
  • if (mounted) 确保在页面销毁后不再更新,防止异常。

⚠️ 性能说明:虽然每秒刷新看似开销大,但 Flutter 的 Widget diff 机制确保只有变化部分重绘,实际性能优异。

2. 时间计算:精准转换 UTC 偏移

DateTime _getTimeByUtcOffset(double utcOffset) {
  final now = DateTime.now();
  final localOffset = now.timeZoneOffset.inMilliseconds;
  final targetTime = DateTime.fromMillisecondsSinceEpoch(
    now.millisecondsSinceEpoch - localOffset + (utcOffset * 3600000).toInt(),
  );
  return targetTime;
}
🔍 计算原理:
  1. 获取当前设备本地时间 now
  2. 获取设备本地 UTC 偏移 localOffset(毫秒);
  3. now 转换为 UTC 时间戳now.millisecondsSinceEpoch - localOffset
  4. 加上目标城市的 UTC 偏移(转为毫秒),得到目标城市本地时间。

✅ 此方法不依赖网络,完全基于本地时钟,适用于离线场景。


三、UI 构建:双模式时间显示 + 城市列表

1. 主时钟区域:点击切换指针/数字

GestureDetector(
  onTap: _toggleClockType,
  child: Column(
    children: [
      Container(
        alignment: Alignment.center,
        padding: const EdgeInsets.all(20),
        child: _isAnalog
            ? CustomPaint(painter: AnalogClockPainter(hour, minute, second), size: const Size(300, 300))
            : Container(
                // 数字时间样式
              ),
      ),
      // 日期与星期
    ],
  ),
)
  • 使用 GestureDetector 包裹,实现点击切换
  • _isAnalog 控制显示模式,状态由 _toggleClockType() 切换。
🎨 数字模式优化:
  • 使用等宽字体 'Courier New',确保时间对齐;
  • 添加圆角卡片与阴影,提升层次感;
  • 动态适配深/浅色背景。

2. 模拟指针钟表:CustomPainter 绘制

class AnalogClockPainter extends CustomPainter {
  final int hour, minute, second;
  // ...
}
绘制细节:
  • 刻度:60 条短线,每 5 分钟加粗加长;
  • 数字:1~12 点位,使用 TextPainter 手动绘制;
  • 指针
    • 时针:长度 50%,宽度 8;
    • 分针:长度 70%,宽度 5;
    • 秒针:长度 80%,蓝色,宽度 2;
  • 角度计算:统一以 12 点方向为 0°,通过 -90 调整坐标系。

🌟 动画潜力:当前为静态绘制。若需平滑移动,可引入 AnimationController 驱动指针插值。

3. 城市列表:滑动删除 + 点击设为主城市

Dismissible(
  key: Key(city.name),
  onDismissed: (_) => _deleteCity(city.name),
  background: Container(color: Colors.red, child: Icon(Icons.delete)),
  child: Card(
    child: ListTile(
      title: Text(city.name, style: TextStyle(color: isSelected ? Colors.blue : null)),
      subtitle: Text(city.getDescription(_mainCity.utcOffset)),
      trailing: Text(timeStr),
      onTap: () => _setAsMainCity(city),
    ),
  ),
)
  • 滑动删除:使用 Dismissible,提供直观操作反馈;
  • 主城市高亮:标题文字变蓝,强化焦点;
  • 实时时间更新:每秒刷新 trailing 中的时间字符串。

四、城市管理:搜索与添加

1. 集成系统搜索:showSearch + SearchDelegate

void _addCity() async {
  final selected = await showSearch<CityData>(
    context: context,
    delegate: CitySearchDelegate(_allCities),
  );
  // 处理结果
}
  • 调用 Flutter 内置 showSearch,弹出标准搜索界面;
  • CitySearchDelegate 封装搜索逻辑。

2. 搜索代理实现:CitySearchDelegate

class CitySearchDelegate extends SearchDelegate<CityData> {
  
  Widget buildSuggestions(BuildContext context) {
    final suggestions = query.isEmpty
        ? cities
        : cities.where((city) => city.name.toLowerCase().startsWith(query.toLowerCase())).toList();
    return _buildCityList(suggestions);
  }

  
  Widget buildResults(BuildContext context) {
    final results = cities.where((city) => city.name.toLowerCase().contains(query.toLowerCase())).toList();
    return _buildCityList(results);
  }
}
  • 建议(Suggestions):输入时匹配前缀,提升效率;
  • 结果(Results):回车后匹配全文包含,确保查全率;
  • 去重校验:添加前检查是否已存在,避免重复。

用户体验:支持拼音首字母、全拼、甚至错别字模糊匹配(可通过扩展实现)。


五、主题与样式:Material Design 3 适配

theme: ThemeData(
  useMaterial3: true,
  brightness: Brightness.light,
  scaffoldBackgroundColor: Colors.grey[100],
),
darkTheme: ThemeData(
  useMaterial3: true,
  brightness: Brightness.dark,
  scaffoldBackgroundColor: Colors.grey[900],
),
  • 启用 useMaterial3: true,使用最新设计语言;
  • 浅色模式:背景 grey[100],柔和不刺眼;
  • 深色模式:背景 grey[900],符合夜间使用习惯;
  • 卡片、按钮、文字颜色均通过 Theme.of(context) 动态获取,确保一致性。

六、内存安全与边界处理


void dispose() {
  _timer.cancel(); // 关键!防止内存泄漏
  super.dispose();
}

// 删除城市时,若主城市被删,自动切换到第一个
if (_mainCity.name == name && _displayCities.isNotEmpty) {
  _mainCity = _displayCities.first;
}
  • 定时器必须在 dispose 中取消;
  • 主城市删除后自动 fallback,避免空引用。

七、完整代码:

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

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

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Clock on OpenHarmony',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.light,
        primarySwatch: Colors.blue,
        scaffoldBackgroundColor: Colors.grey[100],
        appBarTheme: const AppBarTheme(backgroundColor: Colors.blue, foregroundColor: Colors.white),
      ),
      darkTheme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        primarySwatch: Colors.blue,
        scaffoldBackgroundColor: Colors.grey[900],
        appBarTheme: const AppBarTheme(backgroundColor: Colors.grey, foregroundColor: Colors.white),
      ),
      home: const WorldClockPage(),
    );
  }
}

// 全球主要城市数据库(城市名, UTC偏移小时)
final List<CityData> _allCities = [
  CityData('阿姆斯特丹', 1),
  CityData('北京', 8),
  CityData('柏林', 1),
  CityData('布宜诺斯艾利斯', -3),
  CityData('开罗', 2),
  CityData('芝加哥', -6),
  CityData('达拉斯', -6),
  CityData('德里', 5.5),
  CityData('迪拜', 4),
  CityData('都柏林', 0),
  CityData('法兰克福', 1),
  CityData('香港', 8),
  CityData('伊斯坦布尔', 3),
  CityData('雅加达', 7),
  CityData('吉隆坡', 8),
  CityData('伦敦', 0),
  CityData('洛杉矶', -8),
  CityData('马德里', 1),
  CityData('墨尔本', 10),
  CityData('墨西哥城', -6),
  CityData('迈阿密', -5),
  CityData('莫斯科', 3),
  CityData('孟买', 5.5),
  CityData('纽约', -5),
  CityData('奥斯陆', 1),
  CityData('巴黎', 1),
  CityData('里约热内卢', -3),
  CityData('罗马', 1),
  CityData('圣保罗', -3),
  CityData('圣彼得堡', 3),
  CityData('上海', 8),
  CityData('新加坡', 8),
  CityData('斯德哥尔摩', 1),
  CityData('悉尼', 10),
  CityData('东京', 9),
  CityData('多伦多', -5),
  CityData('温哥华', -8),
  CityData('维也纳', 1),
  CityData('苏黎世', 1),
];

class CityData {
  final String name;
  final double utcOffset;

  CityData(this.name, this.utcOffset);
}

class DisplayCity {
  final String name;
  final double utcOffset;

  DisplayCity(this.name, this.utcOffset);

  String getDescription(double mainUtcOffset) {
    final diff = utcOffset - mainUtcOffset;
    if (diff == 0) return '本地时间';
    if (diff > 0) {
      return '早${diff.toStringAsFixed(diff % 1 == 0 ? 0 : 1)}小时';
    } else {
      return '晚${(-diff).toStringAsFixed((-diff) % 1 == 0 ? 0 : 1)}小时';
    }
  }
}

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

  
  State<WorldClockPage> createState() => _WorldClockPageState();
}

class _WorldClockPageState extends State<WorldClockPage> {
  late Timer _timer;
  final List<DisplayCity> _displayCities = [
    DisplayCity('北京', 8),
    DisplayCity('伦敦', 0),
    DisplayCity('悉尼', 10),
  ];
  bool _isAnalog = true;
  DisplayCity _mainCity = DisplayCity('北京', 8);

  
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (mounted) setState(() {});
    });
  }

  
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  void _addCity() async {
    final selected = await showSearch<CityData>(
      context: context,
      delegate: CitySearchDelegate(_allCities),
    );
    if (selected != null) {
      if (!_displayCities.any((city) => city.name == selected.name)) {
        setState(() {
          _displayCities.add(DisplayCity(selected.name, selected.utcOffset));
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('${selected.name} 已添加')),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('该城市已在列表中')),
        );
      }
    }
  }

  void _deleteCity(String name) {
    setState(() {
      _displayCities.removeWhere((city) => city.name == name);
      if (_mainCity.name == name && _displayCities.isNotEmpty) {
        _mainCity = _displayCities.first;
      }
    });
  }

  void _setAsMainCity(DisplayCity city) {
    setState(() {
      _mainCity = city;
    });
  }

  void _toggleClockType() {
    setState(() {
      _isAnalog = !_isAnalog;
    });
  }

  String _formatTimeWithSeconds(int hour, int minute, int second) {
    return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}:${second.toString().padLeft(2, '0')}';
  }

  DateTime _getTimeByUtcOffset(double utcOffset) {
    final now = DateTime.now();
    final localOffset = now.timeZoneOffset.inMilliseconds;
    final targetTime = DateTime.fromMillisecondsSinceEpoch(
      now.millisecondsSinceEpoch - localOffset + (utcOffset * 3600000).toInt(),
    );
    return targetTime;
  }

  
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final mainTime = _getTimeByUtcOffset(_mainCity.utcOffset);
    final hour = mainTime.hour;
    final minute = mainTime.minute;
    final second = mainTime.second;
    final weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];

    return Scaffold(
      appBar: AppBar(title: const Text('世界时钟')),
      body: GestureDetector(
        onTap: _toggleClockType,
        child: Column(
          children: [
            Container(
              alignment: Alignment.center,
              padding: const EdgeInsets.all(20),
              child: _isAnalog
                  ? CustomPaint(
                      painter: AnalogClockPainter(hour, minute, second),
                      size: const Size(300, 300),
                    )
                  : Container(
                      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
                      decoration: BoxDecoration(
                        color: isDark ? Colors.grey[800] : Colors.white,
                        borderRadius: BorderRadius.circular(16),
                        boxShadow: [
                          BoxShadow(
                            color: isDark ? Colors.black54 : Colors.grey.withOpacity(0.4),
                            blurRadius: 10,
                            offset: const Offset(0, 4),
                          ),
                        ],
                      ),
                      child: Text(
                        _formatTimeWithSeconds(hour, minute, second),
                        style: TextStyle(
                          fontSize: 48,
                          fontWeight: FontWeight.bold,
                          color: isDark ? Colors.white : Colors.black,
                          fontFamily: 'Courier New, monospace',
                        ),
                      ),
                    ),
            ),
            const SizedBox(height: 10),
            Text(
              '${_mainCity.name} | ${mainTime.month}${mainTime.day}${weekdayNames[mainTime.weekday - 1]}',
              style: const TextStyle(color: Colors.grey, fontSize: 14),
            ),
            const SizedBox(height: 20),
            Expanded(
              child: ListView.builder(
                itemCount: _displayCities.length,
                itemBuilder: (context, index) {
                  final city = _displayCities[index];
                  final cityTime = _getTimeByUtcOffset(city.utcOffset);
                  final timeStr = _formatTimeWithSeconds(cityTime.hour, cityTime.minute, cityTime.second);
                  final isSelected = city.name == _mainCity.name;

                  return Dismissible(
                    key: Key(city.name),
                    direction: DismissDirection.endToStart,
                    onDismissed: (_) => _deleteCity(city.name),
                    background: Container(
                      alignment: Alignment.centerRight,
                      padding: const EdgeInsets.only(right: 20),
                      color: Colors.red,
                      child: const Icon(Icons.delete, color: Colors.white),
                    ),
                    child: Card(
                      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                      child: ListTile(
                        title: Text(
                          city.name,
                          style: TextStyle(
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                            color: isSelected ? Colors.blue : null,
                          ),
                        ),
                        subtitle: Text(city.getDescription(_mainCity.utcOffset)),
                        trailing: Text(timeStr, style: const TextStyle(fontSize: 16)),
                        onTap: () => _setAsMainCity(city),
                      ),
                    ),
                  );
                },
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: FloatingActionButton(
                onPressed: _addCity,
                backgroundColor: Colors.blue,
                child: const Icon(Icons.add, size: 30),
              ),
            ),
          ],
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }
}

// 搜索代理
class CitySearchDelegate extends SearchDelegate<CityData> {
  final List<CityData> cities;

  CitySearchDelegate(this.cities);

  
  String get searchFieldLabel => '搜索城市';

  
  List<Widget>? buildActions(BuildContext context) {
    return [
      if (query.isNotEmpty)
        IconButton(
          icon: const Icon(Icons.clear),
          onPressed: () => query = '',
        ),
    ];
  }

  
  Widget? buildLeading(BuildContext context) {
    return IconButton(
      icon: const Icon(Icons.arrow_back),
      onPressed: () => close(context, null),
    );
  }

  
  Widget buildResults(BuildContext context) {
    final results = cities.where((city) {
      return city.name.toLowerCase().contains(query.toLowerCase());
    }).toList();

    return _buildCityList(results);
  }

  
  Widget buildSuggestions(BuildContext context) {
    final suggestions = query.isEmpty
        ? cities
        : cities.where((city) {
            return city.name.toLowerCase().startsWith(query.toLowerCase());
          }).toList();

    return _buildCityList(suggestions);
  }

  Widget _buildCityList(List<CityData> list) {
    return ListView.builder(
      itemCount: list.length,
      itemBuilder: (context, index) {
        final city = list[index];
        final offsetStr = city.utcOffset >= 0
            ? '+${city.utcOffset.toStringAsFixed(city.utcOffset % 1 == 0 ? 0 : 1)}'
            : '${city.utcOffset.toStringAsFixed(city.utcOffset % 1 == 0 ? 0 : 1)}';
        return ListTile(
          title: Text(city.name),
          subtitle: Text('UTC $offsetStr'),
          onTap: () => close(context, city),
        );
      },
    );
  }
}

// 指针钟表绘制
class AnalogClockPainter extends CustomPainter {
  final int hour;
  final int minute;
  final int second;

  AnalogClockPainter(this.hour, this.minute, this.second);

  
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 10;

    // 刻度
    for (int i = 0; i < 60; i++) {
      final angle = i * 6.0;
      final start = center + Offset(cos(angle * pi / 180) * (radius - 10), sin(angle * pi / 180) * (radius - 10));
      final end = center + Offset(cos(angle * pi / 180) * radius, sin(angle * pi / 180) * radius);
      final paint = Paint()
        ..color = i % 5 == 0 ? Colors.black : Colors.grey[400]!
        ..strokeWidth = i % 5 == 0 ? 4 : 2;
      canvas.drawLine(start, end, paint);
    }

    // 数字
    for (int i = 1; i <= 12; i++) {
      final angle = (i - 3) * 30.0;
      final x = center.dx + cos(angle * pi / 180) * (radius - 40);
      final y = center.dy - sin(angle * pi / 180) * (radius - 40);
      final text = TextSpan(text: i.toString(), style: const TextStyle(fontSize: 16));
      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 hourAngle = (hour % 12) * 30.0 + minute * 0.5;
    final hourX = center.dx + cos((hourAngle - 90) * pi / 180) * radius * 0.5;
    final hourY = center.dy + sin((hourAngle - 90) * pi / 180) * radius * 0.5;
    final hourPaint = Paint()..color = Colors.black..strokeWidth = 8;
    canvas.drawLine(center, Offset(hourX, hourY), hourPaint);

    // 分针
    final minuteAngle = minute * 6.0;
    final minX = center.dx + cos((minuteAngle - 90) * pi / 180) * radius * 0.7;
    final minY = center.dy + sin((minuteAngle - 90) * pi / 180) * radius * 0.7;
    final minPaint = Paint()..color = Colors.black..strokeWidth = 5;
    canvas.drawLine(center, Offset(minX, minY), minPaint);

    // 秒针
    final secondAngle = second * 6.0;
    final secX = center.dx + cos((secondAngle - 90) * pi / 180) * radius * 0.8;
    final secY = center.dy + sin((secondAngle - 90) * pi / 180) * radius * 0.8;
    final secPaint = Paint()..color = Colors.blue..strokeWidth = 2;
    canvas.drawLine(center, Offset(secX, secY), secPaint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

运行界面:
在这里插入图片描述

向左划动可以直接删除:
在这里插入图片描述

点击钟表界面可以切换指针和数字形态
在这里插入图片描述

点击加号可以搜索和加入新地区时间
在这里插入图片描述
加入后下方会提示加入成功
在这里插入图片描述

八、优化亮点总结

功能 优化点
时间精度 毫秒级计算,支持半小数时区
交互体验 点击切换钟表模式、滑动删除、搜索添加
视觉设计 指针钟表手绘、数字时间等宽字体、深色适配
数据管理 唯一城市名校验、主城市自动切换
性能安全 mounted 检查、定时器释放

结语

通过本文,我们不仅实现了一个功能强大的世界时钟界面,更展示了 Flutter 和OpenHarmony在自定义绘制、状态管理、搜索集成、主题适配等方面的卓越能力。该模块代码结构清晰、扩展性强,可轻松集成到您的时钟应用中。

未来,您可以在此基础上进一步扩展:

  • 支持夏令时(DST) 自动调整;
  • 添加城市国旗图标
  • 实现后台持续运行(需平台通道);
  • 集成地理位置自动推荐

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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐