Flutter for OpenHarmony天气卡片应用:用枚举与动画打造沉浸式多城市天气浏览体验

Flutter for OpenHarmony天气卡片应用:用枚举与动画打造沉浸式多城市天气浏览体验

在移动应用设计中,信息展示的美感与效率 往往决定了用户的留存意愿。天气应用作为最常见的一类工具,如何在简洁中体现个性、在静态中注入动态?答案在于------上下文感知的视觉反馈流畅的交互过渡

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


完整效果


一、设计理念:天气即氛围

该应用的核心思想是 "天气决定界面基调"

  • 每种天气类型拥有专属的背景色、文字色、图标与描述
  • 切换城市时,整个屏幕背景平滑过渡到新天气对应的主题;
  • 信息层级清晰:城市名 → 天气图标/描述 → 温度 → 湿度/风速。

💡 目标:让用户一眼感受到当前城市的天气氛围,而不仅是读取数据。


二、架构亮点:枚举(Enum)驱动 UI

1. WeatherCondition 枚举:单一数据源原则

dart 复制代码
enum WeatherCondition {
  sunny, cloudy, rainy, stormy, snowy, foggy;

  String get icon => ...;
  String get description => ...;
  Color get backgroundColor => ...;
  Color get textColor => ...;
}
  • 封装性:将天气相关的所有 UI 属性(图标、文字、颜色)集中管理;
  • 类型安全:避免字符串硬编码或魔法数字,编译器可检查完整性;
  • 可维护性:新增天气类型只需扩展枚举,无需修改多处 UI 逻辑。

✅ 这是 "面向对象设计"在 Flutter 中的优雅实践

2. 数据模型:CityWeather

dart 复制代码
class CityWeather {
  final String name;
  final int temperature;
  final WeatherCondition condition;
  final int humidity;
  final double windSpeed;
}
  • 使用 final 字段确保不可变性;
  • 通过构造函数参数明确依赖,便于测试与扩展。

三、动态视觉系统

1. 背景渐变动画:AnimatedContainer

dart 复制代码
AnimatedContainer(
  duration: const Duration(milliseconds: 400),
  decoration: BoxDecoration(
    gradient: LinearGradient(
      colors: [
        condition.backgroundColor.withOpacity(0.7),
        condition.backgroundColor.withOpacity(0.95),
      ],
    ),
  ),
  // ...
)
  • 400ms 过渡:足够流畅,又不至于拖沓;
  • 垂直渐变:模拟天空到地面的自然光感;
  • 透明度微调 :避免纯色背景过于刺眼,保留底层 scaffoldBackgroundColor 的柔和底色。

2. 文字阴影增强可读性

dart 复制代码
shadows: [
  Shadow(color: Colors.black.withOpacity(0.1), offset: Offset(0, 2), blurRadius: 4)
]
  • 在浅色背景下(如晴天淡黄),轻微阴影提升文字对比度;
  • 避免使用纯黑描边,保持整体清新风格。

四、交互设计:多模态导航

1. 手势滑动切换

dart 复制代码
onHorizontalDragEnd: (details) {
  if (details.primaryVelocity! > 0) _prevCity(); // 右滑
  else if (details.primaryVelocity! < 0) _nextCity(); // 左滑
}
  • 符合移动端"左滑下一页,右滑上一页"的直觉;
  • 使用 primaryVelocity 判断方向,避免误触。

2. 悬浮按钮辅助操作

  • 居中浮动按钮centerFloat):醒目且不遮挡内容;
  • 白色背景 + 蓝色图标:与整体浅色主题协调;
  • 仅提供"下一页":简化操作,用户可通过滑动返回。

3. 底部指示器

  • 圆形小点清晰显示当前城市位置;
  • 白色高亮 + 半透明白色非激活态,简洁不抢戏。

五、UI 细节与色彩心理学

天气 背景色 文字色 心理暗示
☀️ 晴朗 #FFF8E1(暖黄) #5D4037(深棕) 温暖、明亮、活力
⛅ 多云 #ECEFF1(灰蓝) #455A64(冷灰) 平静、温和、中性
🌧️ 小雨 #E3F2FD(浅蓝) #0D47A1(深蓝) 清凉、湿润、宁静
⛈️ 雷暴 #BBDEFB(深蓝) #01579B(更深蓝) 强烈、能量、紧张
❄️ 小雪 #E1F5FE(冰蓝) #01579B 寒冷、纯净、空灵
🌫️ 雾 #E0E0E0(中灰) #212121(纯黑) 模糊、朦胧、神秘

🎨 所有配色均来自 Material Design 调色板,确保视觉和谐。


六、响应式布局技巧

  • Wrap 布局:自动换行湿度与风速信息,适配不同屏幕宽度;
  • Spacer():将底部指示器推至视图底部,保证主信息区垂直居中;
  • Padding + SizedBox:精确控制元素间距,形成呼吸感。

七、应用场景与扩展可能

适用场景

  • 天气 Widget 原型:快速验证 UI 概念;
  • 旅游 App 子页面:展示目的地实时天气;
  • 健康 App 集成:根据天气建议穿衣或运动。

可扩展方向

  • 真实 API 对接:替换虚拟数据为 OpenWeatherMap 等服务;
  • 24 小时趋势图:展示温度变化曲线;
  • 空气质量指数(AQI):增加更多环境指标;
  • 夜间模式:根据时间或系统设置切换深色主题。

八、结语:小卡片,大思考

这个天气卡片应用虽小,却完整体现了 "数据驱动 UI" 的现代开发理念。通过枚举将业务逻辑与视觉表现解耦,通过动画赋予静态信息以生命力,通过手势与按钮提供自然的交互路径。

完整代码

bash 复制代码
import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: '🌤️ 天气卡片',
      theme: ThemeData(
        brightness: Brightness.light,
        primarySwatch: Colors.blue,
        scaffoldBackgroundColor: const Color(0xFFE6F4FF),
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.transparent,
          foregroundColor: Colors.blue,
          elevation: 0,
        ),
      ),
      home: const WeatherCardScreen(),
    );
  }
}

// 天气类型枚举
enum WeatherCondition {
  sunny,
  cloudy,
  rainy,
  stormy,
  snowy,
  foggy;

  String get icon {
    switch (this) {
      case WeatherCondition.sunny:
        return '☀️';
      case WeatherCondition.cloudy:
        return '⛅';
      case WeatherCondition.rainy:
        return '🌧️';
      case WeatherCondition.stormy:
        return '⛈️';
      case WeatherCondition.snowy:
        return '❄️';
      case WeatherCondition.foggy:
        return '🌫️';
    }
  }

  String get description {
    switch (this) {
      case WeatherCondition.sunny:
        return '晴朗';
      case WeatherCondition.cloudy:
        return '多云';
      case WeatherCondition.rainy:
        return '小雨';
      case WeatherCondition.stormy:
        return '雷暴';
      case WeatherCondition.snowy:
        return '小雪';
      case WeatherCondition.foggy:
        return '雾';
    }
  }

  Color get backgroundColor {
    switch (this) {
      case WeatherCondition.sunny:
        return const Color(0xFFFFF8E1); // 淡黄
      case WeatherCondition.cloudy:
        return const Color(0xFFECEFF1); // 浅灰蓝
      case WeatherCondition.rainy:
        return const Color(0xFFE3F2FD); // 雨天蓝
      case WeatherCondition.stormy:
        return const Color(0xFFBBDEFB); // 雷暴深蓝
      case WeatherCondition.snowy:
        return const Color(0xFFE1F5FE); // 雪天冰蓝
      case WeatherCondition.foggy:
        return const Color(0xFFE0E0E0); // 雾天灰
    }
  }

  Color get textColor {
    switch (this) {
      case WeatherCondition.sunny:
        return const Color(0xFF5D4037); // 深棕
      case WeatherCondition.cloudy:
        return const Color(0xFF455A64); // 灰蓝
      case WeatherCondition.rainy:
        return const Color(0xFF0D47A1); // 深蓝
      case WeatherCondition.stormy:
        return const Color(0xFF01579B); // 更深蓝
      case WeatherCondition.snowy:
        return const Color(0xFF01579B);
      case WeatherCondition.foggy:
        return const Color(0xFF212121); // 纯黑
    }
  }
}

// 城市数据模型
class CityWeather {
  final String name;
  final int temperature;
  final WeatherCondition condition;
  final int humidity;
  final double windSpeed; // m/s

  CityWeather({
    required this.name,
    required this.temperature,
    required this.condition,
    required this.humidity,
    required this.windSpeed,
  });
}

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

  @override
  State<WeatherCardScreen> createState() => _WeatherCardScreenState();
}

class _WeatherCardScreenState extends State<WeatherCardScreen> {
  // 预设 6 个虚拟城市天气(覆盖所有天气类型)
  static final List<CityWeather> _cities = [
    CityWeather(
        name: '杭州',
        temperature: 28,
        condition: WeatherCondition.sunny,
        humidity: 60,
        windSpeed: 2.3),
    CityWeather(
        name: '成都',
        temperature: 22,
        condition: WeatherCondition.cloudy,
        humidity: 75,
        windSpeed: 1.8),
    CityWeather(
        name: '广州',
        temperature: 31,
        condition: WeatherCondition.rainy,
        humidity: 85,
        windSpeed: 3.1),
    CityWeather(
        name: '武汉',
        temperature: 33,
        condition: WeatherCondition.stormy,
        humidity: 90,
        windSpeed: 5.7),
    CityWeather(
        name: '哈尔滨',
        temperature: -5,
        condition: WeatherCondition.snowy,
        humidity: 50,
        windSpeed: 4.2),
    CityWeather(
        name: '重庆',
        temperature: 25,
        condition: WeatherCondition.foggy,
        humidity: 88,
        windSpeed: 0.9),
  ];

  int _currentIndex = 0;

  void _nextCity() {
    setState(() {
      _currentIndex = (_currentIndex + 1) % _cities.length;
    });
  }

  void _prevCity() {
    setState(() {
      _currentIndex = (_currentIndex - 1 + _cities.length) % _cities.length;
    });
  }

  @override
  Widget build(BuildContext context) {
    final city = _cities[_currentIndex];
    final condition = city.condition;

    return Scaffold(
      appBar: AppBar(
        title: const Text(
          '今日天气',
          style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
        ),
        centerTitle: true,
        backgroundColor: Colors.transparent,
      ),
      body: GestureDetector(
        onHorizontalDragEnd: (details) {
          if (details.primaryVelocity! > 0) {
            _prevCity(); // 右滑 → 上一个
          } else if (details.primaryVelocity! < 0) {
            _nextCity(); // 左滑 → 下一个
          }
        },
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 400),
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                condition.backgroundColor.withOpacity(0.7),
                condition.backgroundColor.withOpacity(0.95),
              ],
            ),
          ),
          child: Center(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 24),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  // 城市名
                  Text(
                    city.name,
                    style: TextStyle(
                      fontSize: 36,
                      fontWeight: FontWeight.bold,
                      color: condition.textColor,
                      shadows: [
                        Shadow(
                          color: Colors.black.withOpacity(0.1),
                          offset: const Offset(0, 2),
                          blurRadius: 4,
                        ),
                      ],
                    ),
                  ),
                  const SizedBox(height: 12),

                  // 天气图标 + 描述
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text(
                        condition.icon,
                        style: const TextStyle(fontSize: 64),
                      ),
                      const SizedBox(width: 16),
                      Text(
                        condition.description,
                        style: TextStyle(
                          fontSize: 28,
                          fontWeight: FontWeight.w500,
                          color: condition.textColor.withOpacity(0.9),
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 24),

                  // 温度
                  Text(
                    '${city.temperature}°',
                    style: TextStyle(
                      fontSize: 80,
                      fontWeight: FontWeight.bold,
                      color: condition.textColor,
                      height: 0.9,
                    ),
                  ),
                  const SizedBox(height: 16),

                  // 湿度 & 风速
                  Wrap(
                    spacing: 24,
                    runSpacing: 12,
                    alignment: WrapAlignment.center,
                    children: [
                      _buildDetailItem('💧 湿度', '${city.humidity}%'),
                      _buildDetailItem('💨 风速', '${city.windSpeed}m/s'),
                    ],
                  ),

                  const Spacer(),

                  // 切换指示器
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: List.generate(_cities.length, (index) {
                      return Container(
                        width: 8,
                        height: 8,
                        margin: const EdgeInsets.symmetric(horizontal: 4),
                        decoration: BoxDecoration(
                          shape: BoxShape.circle,
                          color: index == _currentIndex
                              ? Colors.white
                              : Colors.white.withOpacity(0.5),
                        ),
                      );
                    }),
                  ),
                  const SizedBox(height: 24),
                ],
              ),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _nextCity,
        backgroundColor: Colors.white,
        foregroundColor: Colors.blue,
        child: const Icon(Icons.navigate_next, size: 32),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }

  Widget _buildDetailItem(String label, String value) {
    final city = _cities[_currentIndex];
    return Column(
      children: [
        Text(
          label,
          style: TextStyle(
            fontSize: 16,
            color: city.condition.textColor.withOpacity(0.8),
          ),
        ),
        Text(
          value,
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
            color: city.condition.textColor,
          ),
        ),
      ],
    );
  }
}
相关推荐
HSunR24 分钟前
dify 搭建ai作业批改流
开发语言·前端·javascript
代码不加糖32 分钟前
2026 跨境电商独立站实战:从 0 到 1 搭建高转化 SaaS 商城(附源码)
开发语言·前端·javascript
AI攻城狮1 小时前
如何提高 RAG 的检索质量?这才是真正的瓶颈所在
云原生
亲亲小宝宝鸭1 小时前
拖一拖控件,拖出个问卷(低代码平台)
前端·低代码
江南十四行1 小时前
ReAct Agent 基本理论与项目实战(一)
前端·react.js·前端框架
maaath1 小时前
【maaath】Flutter for OpenHarmony 手表配饰应用实战开发
flutter·华为·harmonyos
We་ct1 小时前
LeetCode 72. 编辑距离:动态规划经典题解
前端·算法·leetcode·typescript·动态规划
小呆呆6662 小时前
Codex 穷鬼大救星
前端·人工智能·后端
maaath2 小时前
【maaath】Flutter for OpenHarmony 跨平台计算器应用开发实践
flutter·华为·harmonyos
当时只道寻常2 小时前
Vue3 + IntersectionObserver 实现高性能图片懒加载
前端