Flutter for OpenHarmony 实现动态天气与空气质量仪表盘:从 UI 到动画的完整解析

在移动应用开发中,将复杂数据以直观、美观的方式呈现给用户是一项关键挑战。本文将深入剖析一段完整的 Flutter
代码,展示如何构建一个兼具动态刷新、视觉反馈和信息聚合能力的天气与空气质量卡片应用。


完整效果展示
在这里插入图片描述
在这里插入图片描述

一、整体架构设计

1. 三层结构清晰分离

  • UI 层WeatherScreen 负责界面布局和组件组织;
  • 状态层:通过 StatefulWidget 管理温度、AQI、风速等动态数据;
  • 绘制层GaugePainter 自定义绘制半圆弧形仪表盘,实现数据可视化。

💡 这种分层使得代码高度模块化——未来若需添加湿度、气压等新指标,只需扩展状态和 UI 即可。

2. 核心交互逻辑

  • 下拉刷新替代方案:通过 AppBar 的旋转图标实现“手动刷新”;
  • 模拟数据生成:使用 Random() 动态生成逼真的天气数据;
  • 实时视觉反馈:刷新时图标旋转 + 数据更新,形成完整交互闭环。

在这里插入图片描述


二、核心功能实现详解

1. 动态刷新机制

void _refreshData() {
  _refreshController.forward(from: 0); // 启动旋转动画

  Future.delayed(const Duration(seconds: 1), () {
    setState(() {
      _temperature = 20 + Random().nextDouble() * 15; // 20-35°C
      _aqi = Random().nextInt(100);
      _windSpeed = Random().nextDouble() * 10;
      // 随机天气状态
      _condition = ['sunny', 'cloudy', 'rainy'][...];
    });
  });
}

在这里插入图片描述

  • 动画同步:1秒旋转动画与数据加载时间匹配,避免“假加载”;
  • 数据范围合理:温度(20-35°C)、AQI(0-99)、风速(0-10m/s)符合真实场景。

2. 空气质量智能分级

({String level, Color color}) _getAqiInfo(int aqi) {
  if (aqi <= 50) return (level: '优', color: Colors.green);
  else if (aqi <= 100) return (level: '良', color: Colors.yellow);
  // ... 其他等级
}

在这里插入图片描述

  • 颜色语义化:绿色(优)→ 黄色(良)→ 橙色(轻度污染)→ 红色(重度污染),符合国际标准;
  • 记录式返回:Dart 2.17+ 的 record 语法让多值返回更简洁。

三、自定义绘制:半圆弧形仪表盘

1. 绘制原理

GaugePainter 继承 CustomPainter,通过 Canvas.drawArc 绘制双层弧线:

  • 背景弧:灰色半透明底座(固定 sweepAngle = π×1.2 ≈ 216°);
  • 前景弧:根据 AQI 值动态计算长度(sweepAngle × value)。

2. 渐变色实现

..shader = ui.Gradient.sweep(
  center,
  [Colors.green, Colors.yellow, Colors.orange, Colors.red],
  [0.0, 0.33, 0.66, 1.0], // 渐变停靠点
  TileMode.clamp,
  startAngle,
  sweepAngle,
)

在这里插入图片描述

  • 径向渐变Gradient.sweep 沿弧线方向平滑过渡颜色;
  • 语义映射:0%→绿色(优),33%→黄色(良),66%→橙色(污染),100%→红色(严重污染)。

3. 性能优化


bool shouldRepaint(covariant CustomPainter oldDelegate) {
  return oldDelegate is! GaugePainter ||
         oldDelegate.value != value ||
         oldDelegate.color != color;
}

仅当 valuecolor 变化时重绘,避免不必要的 GPU 开销。


四、UI/UX 设计亮点

1. 卡片式信息分层

区域 内容 设计要点
顶部卡片 天气图标 + 温度 + 状态 圆角 20 + 高对比度文字
左下方 AQI 仪表盘 半圆弧 + 渐变色 + 中心标签
右下方 风速指示器 图标 + 数值 + 单位说明

2. 动态图标系统

Widget _buildWeatherIcon() {
  if (_condition == 'sunny') 
    return Icon(Icons.wb_sunny, color: Colors.yellow);
  else if (_condition == 'cloudy')
    return Icon(Icons.cloud, color: Colors.white);
  else 
    return Icon(Icons.grain, color: Colors.blue); // 雨滴效果
}

在这里插入图片描述

  • 语义化图标:晴天(太阳)、多云(云朵)、雨天(雨滴);
  • 色彩心理学:黄色(温暖)、白色(中性)、蓝色(清凉)强化天气感知。

3. 响应式布局

  • 弹性分区:天气卡片(2份)、仪表盘区域(3份);
  • 水平分割Row 内两个 Expanded 平分空间,适配不同屏幕宽度。

五、动画细节:刷新反馈

1. 旋转动画控制器

late AnimationController _refreshController;
late Animation<double> _refreshAnimation;


void initState() {
  _refreshController = AnimationController(vsync: this, duration: 1s);
  _refreshAnimation = Tween(begin: 0, end: 1).animate(_refreshController);
}

2. AppBar 集成

IconButton(
  icon: RotationTransition(
    turns: _refreshAnimation, // 0→1 对应 0°→360°
    child: Icon(Icons.refresh),
  ),
  onPressed: _refreshData,
)
  • 物理感:图标顺时针旋转一圈,模拟“加载中”状态;
  • 无状态干扰:动画结束后自动停止,不影响其他操作。

六、潜在优化方向

  1. 真实 API 集成
    替换 Random() 为 OpenWeatherMap 或和风天气 API:

    final response = await http.get(Uri.parse('https://api.openweathermap.org/...'));
    
  2. 24 小时趋势图
    在底部添加 LineChart 显示温度/AQI 变化曲线。

  3. 深色模式增强
    使用 Theme.of(context).brightness 动态调整卡片颜色。

  4. 无障碍支持
    为图标添加 Semantics 描述,提升视障用户体验。


结语:小部件中的大格局

这个天气卡片项目虽小,却完美融合了状态管理、自定义绘制、动画控制、数据可视化四大 Flutter 核心能力。它证明了即使没有复杂业务逻辑,也能通过精心设计的 UI 和流畅的交互,打造出令人愉悦的用户体验。

🌐 加入社区

欢迎加入 开源鸿蒙跨平台开发者社区,获取最新资源与技术支持:
👉 开源鸿蒙跨平台开发者社区
完整代码展示

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '天气卡片',
      theme: ThemeData.dark(),
      home: const WeatherScreen(),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  @override
  State<WeatherScreen> createState() => _WeatherScreenState();
}

class _WeatherScreenState extends State<WeatherScreen>
    with TickerProviderStateMixin {
  // 模拟数据
  double _temperature = 26.5;
  int _aqi = 45; // 空气质量指数
  double _windSpeed = 3.5; // 风速
  String _condition = 'sunny'; // 天气状况: sunny, cloudy, rainy

  // 动画控制器
  late AnimationController _refreshController;
  late Animation<double> _refreshAnimation;

  @override
  void initState() {
    super.initState();
    _refreshController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1000),
    );
    _refreshAnimation =
        Tween<double>(begin: 0, end: 1).animate(_refreshController);
  }

  @override
  void dispose() {
    _refreshController.dispose();
    super.dispose();
  }

  // 模拟刷新数据
  void _refreshData() {
    _refreshController.forward(from: 0);

    Future.delayed(const Duration(seconds: 1), () {
      setState(() {
        _temperature = 20 + Random().nextDouble() * 15; // 20-35度
        _aqi = Random().nextInt(100);
        _windSpeed = Random().nextDouble() * 10;
        final conditions = ['sunny', 'cloudy', 'rainy'];
        _condition = conditions[Random().nextInt(conditions.length)];
      });
    });
  }

  // 获取 AQI 等级和颜色
  ({String level, Color color}) _getAqiInfo(int aqi) {
    if (aqi <= 50) {
      return (level: '优', color: Colors.green);
    } else if (aqi <= 100) {
      return (level: '良', color: Colors.yellow);
    } else if (aqi <= 150) {
      return (level: '轻度污染', color: Colors.orange);
    } else {
      return (level: '重度污染', color: Colors.red);
    }
  }

  @override
  Widget build(BuildContext context) {
    final aqiInfo = _getAqiInfo(_aqi);
    final aqiColor = aqiInfo.color;
    final aqiLevel = aqiInfo.level;

    return Scaffold(
      appBar: AppBar(
        title: const Text('天气与空气质量'),
        centerTitle: true,
        actions: [
          IconButton(
            icon: RotationTransition(
              turns: _refreshAnimation,
              child: const Icon(Icons.refresh),
            ),
            onPressed: _refreshData,
          ),
          const SizedBox(width: 16),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 当前天气卡片
            Expanded(
              flex: 2,
              child: Card(
                color: Colors.grey[800],
                elevation: 4,
                shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(20)),
                child: Padding(
                  padding: const EdgeInsets.all(20),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      // 天气图标
                      _buildWeatherIcon(),
                      const SizedBox(height: 20),
                      // 温度
                      Text(
                        '${_temperature.toStringAsFixed(1)}°C',
                        style: const TextStyle(
                            fontSize: 40, fontWeight: FontWeight.bold),
                      ),
                      const SizedBox(height: 10),
                      // 状态
                      Text(
                        _condition == 'sunny'
                            ? '晴朗'
                            : _condition == 'cloudy'
                                ? '多云'
                                : '下雨',
                        style:
                            const TextStyle(fontSize: 18, color: Colors.grey),
                      ),
                    ],
                  ),
                ),
              ),
            ),

            const SizedBox(height: 20),

            // 底部仪表盘区域
            Expanded(
              flex: 3,
              child: Row(
                children: [
                  // 空气质量仪表盘 (半圆)
                  Expanded(
                    child: _buildAqiGauge(aqiColor, aqiLevel),
                  ),

                  const SizedBox(width: 20),

                  // 风速指示器
                  Expanded(
                    child: _buildWindSpeedIndicator(),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  // 构建天气图标
  Widget _buildWeatherIcon() {
    if (_condition == 'sunny') {
      return const Icon(Icons.wb_sunny, size: 60, color: Colors.yellow);
    } else if (_condition == 'cloudy') {
      return const Icon(Icons.cloud, size: 60, color: Colors.white);
    } else {
      return const Icon(Icons.grain, size: 60, color: Colors.blue);
    }
  }

  // 构建空气质量仪表盘
  Widget _buildAqiGauge(Color color, String level) {
    return CustomPaint(
      size: const Size.fromHeight(150),
      painter: GaugePainter(value: _aqi / 100, color: color),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            'AQI $_aqi',
            style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          Text(
            level,
            style: TextStyle(color: color),
          ),
        ],
      ),
    );
  }

  // 构建风速指示器
  Widget _buildWindSpeedIndicator() {
    return Card(
      color: Colors.grey[900],
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.air, size: 40, color: Colors.blueAccent),
            const SizedBox(height: 10),
            Text(
              '${_windSpeed.toStringAsFixed(1)} m/s',
              style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 5),
            const Text(
              '风速',
              style: TextStyle(color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }
}

// 仪表盘绘制类
class GaugePainter extends CustomPainter {
  final double value; // 0.0 - 1.0
  final Color color;

  GaugePainter({required this.value, required this.color});

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height * 0.8);
    final radius = min(size.width, size.height) * 0.4;
    const startAngle = -pi * 0.1;
    const sweepAngle = pi * 1.2;

    // 绘制背景弧线
    final backgroundPaint = Paint()
      ..color = Colors.grey.withValues(alpha: 0.2)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 20
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false,
      backgroundPaint,
    );

    // 绘制前景弧线 (进度)
    final progressPaint = Paint()
      ..shader = ui.Gradient.sweep(
        center,
        [Colors.green, Colors.yellow, Colors.orange, Colors.red],
        [0.0, 0.33, 0.66, 1.0],
        TileMode.clamp,
        startAngle,
        sweepAngle,
      )
      ..style = PaintingStyle.stroke
      ..strokeWidth = 20
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle * value,
      false,
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return oldDelegate is! GaugePainter ||
        oldDelegate.value != value ||
        oldDelegate.color != color;
  }
}

Logo

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

更多推荐