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,
),
),
],
);
}
}