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,
          ),
        ),
      ],
    );
  }
}
相关推荐
Tadas-Gao6 小时前
TCP粘包现象的深度解析:从协议本质到工程实践
网络·网络协议·云原生·架构·tcp
xkxnq6 小时前
第五阶段:Vue3核心深度深挖(第74天)(Vue3计算属性进阶)
前端·javascript·vue.js
三小河6 小时前
Agent Skill与Rules的区别——以Cursor为例
前端·javascript·后端
Hilaku6 小时前
不要在简历上写精通 Vue3?来自面试官的真实劝退
前端·javascript·vue.js
三小河6 小时前
前端视角详解 Agent Skill
前端·javascript·后端
子春一6 小时前
Flutter for OpenHarmony:语桥 - 基于Flutter的离线多语言短语速查工具实现与国际化设计理念
flutter
Aniugel7 小时前
单点登录(SSO)系统
前端
切糕师学AI7 小时前
Helm Chart 是什么?
云原生·kubernetes·helm chart
鹏多多7 小时前
移动端H5项目,还需要react-fastclick解决300ms点击延迟吗?
前端·javascript·react.js