旅行工具箱里新加了一个天气入口,点进去是旅行天气页面。这个页面的核心设计思路是"选城市 → 看天气"——顶部横向滚动的城市选择栏,中间当前天气大卡片,下方四项天气指标,底部五天预报。实现上用了一个 Record<number, Weather> 的映射表来管理 mock 数据,这种"ID → 对象"的映射模式值得单独说一下。
完整效果
在这里插入图片描述

页面结构

从上到下四个区域:

  1. 城市选择栏:横向滚动的 Tab 栏,显示所有城市的 emoji + 名称
  2. 当前天气卡片:大号天气图标 + 温度 + 天气描述
  3. 四项指标:湿度、风力、能见度、紫外线,用竖线分隔的四列
  4. 五天预报:每日一行,显示日期、图标、高温、低温

整体布局用 Column 纵向排列,内容区在 Scroll 里可以上下滚动。

数据结构

interface Weather {
  temp: string; icon: string; desc: string
  humidity: string; wind: string
}

const WEATHER_MOCK: Record<number, Weather> = {
  1: { temp: '18°', icon: '⛅', desc: '多云转晴', humidity: '65%', wind: '3级' },
  2: { temp: '15°', icon: '🌤️', desc: '晴朗', humidity: '55%', wind: '2级' },
  3: { temp: '29°', icon: '☀️', desc: '炎热', humidity: '80%', wind: '4级' },
  4: { temp: '22°', icon: '⛅', desc: '多云', humidity: '60%', wind: '3级' },
  5: { temp: '20°', icon: '🌤️', desc: '晴转多云', humidity: '50%', wind: '2级' },
  6: { temp: '30°', icon: '☀️', desc: '晴朗炎热', humidity: '70%', wind: '2级' },
}

在这里插入图片描述

Record<number, Weather> 是一个以数字为 key、Weather 对象为 value 的字典。key 对应的是城市的 id——CITIES 数组里东京 id=1,巴黎 id=2,以此类推。

这种"ID → 对象"的映射方式比直接用数组更灵活。如果以后要从服务器获取天气数据,只需要按城市 ID 查询就行,不需要关心数据在数组里的位置。而且 Record 的查找是 O(1) 的哈希查找,比数组的 O(n) 遍历更快。

天气字段全部用 string

temphumiditywind 都是字符串而不是数字。原因是它们都带有单位——'18°''65%''3级'。如果用数字类型,单位需要另外处理,反而增加了代码复杂度。在原型阶段,字符串是最省事的选择。

数据获取:wx() 方法

private wx(): Weather {
  const def: Weather = { temp: '20°', icon: '⛅', desc: '晴朗', humidity: '60%', wind: '2级' }
  const w: Weather | undefined = WEATHER_MOCK[this.selectedCity]
  return w || def
}

wx() 方法根据当前选中的城市 ID 从 WEATHER_MOCK 里查找天气数据。如果找不到(比如城市 ID 不在映射表里),返回一个默认的 def 对象。

w || def 是一个防御性处理——WEATHER_MOCK[this.selectedCity] 可能返回 undefined(key 不存在时),|| 运算符会在左侧为 falsy 时返回右侧的默认值。这保证了 wx() 永远不会返回 undefined,调用方不需要做空值检查。

这个方法在 build() 里被多次调用:

Text(this.wx().icon).fontSize(64)
Text(this.wx().temp).fontSize(48)
Text(this.wx().desc).fontSize(16)
this.WDetail('💧', '湿度', this.wx().humidity)
this.WDetail('🌬️', '风力', this.wx().wind)

每次调用都会从映射表里重新查找。因为 wx() 没有缓存机制,如果 build() 被多次调用(比如切换城市时),每次都会执行查找。对于这种小数据量的场景,重复查找的性能开销可以忽略不计。但如果数据量增大或查找逻辑变复杂,可以考虑加一个 @State 变量缓存当前天气对象。

城市选择栏

Scroll() {
  Row({ space: 8 }) {
    ForEach(CITIES, (c: City) => {
      Text(c.emoji + ' ' + c.name).fontSize(12)
        .fontWeight(this.selectedCity === c.id ? FontWeight.Bold : FontWeight.Regular)
        .fontColor(this.selectedCity === c.id ? '#FFFFFF' : T2)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(this.selectedCity === c.id ? A : '#F5F5F7')
        .onClick(() => { this.selectedCity = c.id })
    })
  }
}
.width('100%').scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)

在这里插入图片描述

六个城市用 Row 横向排列,超出屏幕宽度时用 Scroll 横向滚动。选中态用橙色背景 + 白色文字,未选中态用灰色背景 + 灰色文字——和旅行短语页面的语言切换 Tab 是同一套视觉模式。

点击某个城市时,this.selectedCity = c.id 更新 @State 变量,触发 build() 重新渲染,wx() 用新的 ID 查找对应的天气数据,UI 自动更新。

scrollBar(BarState.Off) 隐藏了滚动条。横向滚动的 Tab 栏通常不需要显示滚动条——用户可以通过滑动感知到内容可以滚动,滚动条反而会干扰视觉。

天气主卡片

Column() {
  Text(this.wx().icon).fontSize(64).margin({ bottom: 8 })
  Text(this.wx().temp).fontSize(48).fontWeight(FontWeight.Bold).fontColor(A)
  Text(this.wx().desc).fontSize(16).fontColor(T2).margin({ top: 4 })
}
.width('100%').padding(30).backgroundColor('#FFFFFF').borderRadius(18)
.margin({ left: 16, right: 16, bottom: 16 }).alignItems(HorizontalAlign.Center)

三个元素纵向居中排列:64px 的天气图标、48px 的橙色温度、16px 的灰色描述。alignItems(HorizontalAlign.Center) 让所有文字水平居中。

温度用 fontSize(48) 的超大号字体,是整个页面视觉重心最大的元素。天气图标虽然也是大号(64px),但它是一个 emoji,视觉重量比文字轻,所以温度才是真正的"视觉锚点"。

四项指标的分隔线

Row({ space: 0 }) {
  this.WDetail('💧', '湿度', this.wx().humidity)
  Row() {}.width(1).height(40).backgroundColor('#F0EEF4')
  this.WDetail('🌬️', '风力', this.wx().wind)
  Row() {}.width(1).height(40).backgroundColor('#F0EEF4')
  this.WDetail('👁️', '能见度', '10km')
  Row() {}.width(1).height(40).backgroundColor('#F0EEF4')
  this.WDetail('☀️', '紫外线', '中等')
}

在这里插入图片描述

四项指标用 Row 横向排列,每两项之间放一个 1px 宽的竖线分隔。Row({ space: 0 }) 设置间距为 0,分隔线的位置完全由 Row() 元素控制。

竖线的高度是 40px,比指标卡片矮一点——指标卡片有图标 + 数值 + 标签三层内容,高度大约 60px。40px 的竖线居中对齐后,上下各留 10px 的空白,视觉上不会太挤。

能见度和紫外线两项目前是硬编码的('10km''中等'),没有从 Weather 接口里读取。这是因为 mock 数据里没有这两个字段,但 UI 上需要展示。如果以后接真实 API,可以在 Weather 接口里加上 visibilityuv 字段。

WDetail Builder

@Builder WDetail(icon: string, label: string, value: string) {
  Column() {
    Text(icon).fontSize(20).margin({ bottom: 4 })
    Text(value).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)
    Text(label).fontSize(10).fontColor(T3)
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
}

三个元素纵向居中:图标、数值、标签。layoutWeight(1) 让每个 WDetail 平均分配水平空间——四个 WDetail 各占 25% 的宽度。

这种"等宽多列"的布局用 layoutWeight 比用固定宽度更灵活。如果以后增加或减少指标数量,不需要手动调整每个列的宽度。

五天预报

@Builder ForecastRow(day: string, icon: string, high: string, low: string) {
  Row() {
    Text(day).fontSize(13).fontColor(T1).width(40)
    Text(icon).fontSize(20).width(40).textAlign(TextAlign.Center)
    Row() {}.layoutWeight(1)
    Text(high).fontSize(14).fontWeight(FontWeight.Bold).fontColor(A).width(40).textAlign(TextAlign.End)
    Text(low).fontSize(13).fontColor(T3).width(40).textAlign(TextAlign.End)
  }.width('100%')
}

每行预报分五列:日期(左对齐)、天气图标(居中)、弹性空白、高温(右对齐)、低温(右对齐)。

Row() {}.layoutWeight(1) 是一个空的弹性元素——它不显示任何内容,但占据所有剩余空间,把高温和低温推到右边。这种"用空 Row 做弹性间隔"的技巧在横向布局里很常见。

日期列和温度列都设了固定宽度 width(40),保证不同日期的文字不会错位。图标列也是 width(40),加了 textAlign(TextAlign.Center) 居中显示。

硬编码的预报数据

this.ForecastRow('今天', '⛅', '18°', '12°')
this.ForecastRow('明天', '☀️', '20°', '14°')
this.ForecastRow('后天', '🌧️', '16°', '10°')
this.ForecastRow('周四', '⛅', '19°', '13°')
this.ForecastRow('周五', '☀️', '22°', '15°')

五天预报是硬编码的,没有从数据源读取。真正的天气 App 会从天气 API 获取未来几天的预报数据,但在原型阶段,写死数据足够验证 UI 布局。

和其他页面的模式对比

天气页面和旅行短语页面有一个共同点——选择 → 展示的交互模式:

页面 选择器 数据源 展示内容
WeatherPage 城市 Tab 栏 WEATHER_MOCK 当前天气 + 预报
PhraseBook 语言 Tab 栏 PHRASES 字典 短语列表
CurrencyPage 货币列表 rateMap 换算结果

三个页面都用了"Tab 切换 + 数据更新"的模式,区别只在数据源和展示形式。选哪个 Tab 就显示对应的数据,切换时 @State 变更触发 build() 重新渲染。

这种模式可以抽象为:选择器改变状态 → 状态驱动数据查询 → 数据驱动 UI 渲染。理解了这个链条,大多数"选择 → 展示"类的页面都能套用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐