HarmonyOS开发:卡片UI设计——多尺寸适配与布局策略

举报
Jack20 发表于 2026/06/27 22:34:06 2026/06/27
【摘要】 HarmonyOS开发:卡片UI设计——多尺寸适配与布局策略📌 核心要点:卡片有1×2、2×2、2×4、4×4四种尺寸规格,每种尺寸的信息密度和交互方式完全不同,一套布局走天下行不通,必须做响应式适配。 背景与动机你做了一张漂亮的4×4天气卡片,信息满满当当——温度、湿度、风速、空气质量、未来三天预报、日出日落时间……然后用户选了2×2尺寸。全挤在一起?字小到看不清。只显示温度?那2×2...

HarmonyOS开发:卡片UI设计——多尺寸适配与布局策略

📌 核心要点:卡片有1×2、2×2、2×4、4×4四种尺寸规格,每种尺寸的信息密度和交互方式完全不同,一套布局走天下行不通,必须做响应式适配。

背景与动机

你做了一张漂亮的4×4天气卡片,信息满满当当——温度、湿度、风速、空气质量、未来三天预报、日出日落时间……然后用户选了2×2尺寸。

全挤在一起?字小到看不清。只显示温度?那2×2和4×4有什么区别?

这就是卡片多尺寸适配的核心难题:同样的数据,不同尺寸下要展示不同层次的信息。2×2只显示最核心的数据,2×4加一点辅助信息,4×4才能展示完整内容。

这不是简单的缩放问题,而是信息架构问题。你得想清楚:每种尺寸下,用户最想看到什么?哪些信息可以省略?哪些交互可以简化?

核心原理

卡片尺寸规格

鸿蒙卡片有四种标准尺寸:

尺寸 网格占比 实际像素(参考值) 适用场景
1×2 1行2列 约360×174 简单状态条
2×2 2行2列 约360×360 核心信息展示
2×4 2行4列 约720×360 信息+列表
4×4 4行4列 约720×720 完整信息面板

注意:实际像素因设备DPI不同而变化。上面的值是720设计宽度下的参考值。你的布局应该用百分比和弹性布局,不要硬编码像素值。

信息密度递进策略

flowchart LR
    A[1×2<br/>状态条] --> B[2×2<br/>核心信息]
    B --> C[2×4<br/>信息+列表]
    C --> D[4×4<br/>完整面板]
    
    A1["仅显示最关键<br/>的1个数据"] --> B1["2-3个核心<br/>数据指标"]
    B1 --> C1["核心数据+<br/>3-5条列表"]
    C1 --> D1["完整数据+<br/>图表+交互"]
    
    classDef size fill:#E3F2FD,stroke:#1565C0,color:#0D47A1
    classDef info fill:#FFF3E0,stroke:#E65100,color:#BF360C
    
    class A,B,C,D size
    class A1,B1,C1,D1 info

核心原则:小尺寸做减法,大尺寸做加法。每种尺寸只展示该尺寸下用户最需要的信息,不要试图把4×4的内容硬塞进2×2。

布局适配方案对比

方案 优点 缺点 适用场景
一个布局文件+条件渲染 维护一份代码 布局逻辑复杂 数据结构简单的卡片
多个布局文件 每种尺寸独立优化 维护多份代码 数据结构复杂的卡片
多个formName 完全独立 FormAbility逻辑复杂 信息层次差异大的卡片

代码实战

基础用法:条件渲染适配多尺寸

最简单的方案:一个布局文件,根据宽度条件渲染不同的UI。

// WeatherCard.ets —— 天气卡片,支持2×2和4×4
@Entry
@Component
struct WeatherCard {
  @State city: string = ''
  @State temperature: string = ''
  @State weather: string = ''
  @State humidity: string = ''
  @State aqi: string = ''
  @State wind: string = ''
  @State updateTime: string = ''
  @State forecast: string = ''  // JSON字符串,4×4用

  build() {
    Column() {
      // 通用顶部:城市 + 更新时间
      this.Header()
      
      // 根据宽度判断尺寸
      if (this.isSmallSize()) {
        // 2×2布局:只显示核心信息
        this.SmallContent()
      } else {
        // 4×4布局:显示完整信息
        this.LargeContent()
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
  
  // 通用头部
  @Builder
  Header() {
    Row() {
      Text(this.city)
        .fontSize(14)
        .fontColor('#333333')
        .fontWeight(FontWeight.Bold)
      
      Blank()
      
      Text(this.updateTime)
        .fontSize(10)
        .fontColor('#AAAAAA')
    }
    .width('100%')
  }
  
  // 小尺寸内容(2×2)
  @Builder
  SmallContent() {
    Column() {
      // 温度 + 天气
      Row() {
        Text(this.temperature)
          .fontSize(40)
          .fontColor('#FF6B35')
          .fontWeight(FontWeight.Bold)
        
        Text(this.weather)
          .fontSize(14)
          .fontColor('#666666')
          .margin({ left: 8, top: 16 })
      }
      .margin({ top: 16 })
      
      // 湿度
      Text(`💧 ${this.humidity}`)
        .fontSize(12)
        .fontColor('#999999')
        .margin({ top: 8 })
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
  }
  
  // 大尺寸内容(4×4)
  @Builder
  LargeContent() {
    Column() {
      // 温度 + 天气 + 详细信息
      Row() {
        Column() {
          Text(this.temperature)
            .fontSize(48)
            .fontColor('#FF6B35')
            .fontWeight(FontWeight.Bold)
          
          Text(this.weather)
            .fontSize(16)
            .fontColor('#666666')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        
        Blank()
        
        // 详细信息网格
        Column() {
          this.InfoItem('💧', '湿度', this.humidity)
          this.InfoItem('🌬️', '风力', this.wind)
          this.InfoItem('🛡️', '空气', this.aqi)
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .margin({ top: 16 })
      
      // 未来三天预报(仅4×4显示)
      Row() {
        this.ForecastItem('今天', this.weather, this.temperature)
        // 更多预报项...
      }
      .width('100%')
      .margin({ top: 16 })
      .justifyContent(FlexAlign.SpaceAround)
    }
    .layoutWeight(1)
  }
  
  @Builder
  InfoItem(icon: string, label: string, value: string) {
    Row() {
      Text(icon).fontSize(12)
      Text(`${label} ${value}`)
        .fontSize(12)
        .fontColor('#666666')
        .margin({ left: 4 })
    }
    .margin({ bottom: 4 })
  }
  
  @Builder
  ForecastItem(day: string, weather: string, temp: string) {
    Column() {
      Text(day)
        .fontSize(11)
        .fontColor('#999999')
      Text(weather)
        .fontSize(12)
        .fontColor('#333333')
        .margin({ top: 4 })
      Text(temp)
        .fontSize(14)
        .fontColor('#FF6B35')
        .fontWeight(FontWeight.Bold)
        .margin({ top: 2 })
    }
  }
  
  // 判断是否为小尺寸
  // 静态卡片无法直接获取宽度,通过数据字段判断
  private isSmallSize(): boolean {
    // 方案1:通过form_config中不同尺寸传不同的dimension标记
    // 方案2:通过一个专门的尺寸字段
    return this.wind === ''  // 大尺寸才有wind数据
  }
}

等一下,你可能发现了问题——静态卡片里怎么判断当前是什么尺寸?

这是个好问题。静态卡片没有API直接获取卡片尺寸。有两种间接方案:

方案1:在FormBindingData中传入尺寸标记

// FormAbility中根据dimension传不同数据
onAddForm(want: Want): formBindingData.FormBindingData {
  const dimension = want.parameters?.['ohos.extra.param.key.form_dimension'] as number
  
  if (dimension === 2) {
    // 2×2,不传wind/forecast数据
    return formBindingData.createFormBindingData({
      city: '北京', temperature: '26°', weather: '晴',
      humidity: '45%', aqi: '42', updateTime: '14:30'
    })
  } else {
    // 4×4,传完整数据
    return formBindingData.createFormBindingData({
      city: '北京', temperature: '26°', weather: '晴',
      humidity: '45%', aqi: '42', wind: '3级',
      updateTime: '14:30', forecast: JSON.stringify([...])
    })
  }
}

方案2:为不同尺寸声明不同的布局文件

进阶用法:多布局文件适配

每种尺寸有独立的布局文件,是最干净的适配方式。

// form_config.json
{
  "forms": [
    {
      "name": "weatherSmall",
      "displayName": "天气概要",
      "src": "./ets/widget/pages/WeatherSmall.ets",
      "defaultDimension": "2*2",
      "supportDimensions": ["1*2", "2*2"]
    },
    {
      "name": "weatherLarge",
      "displayName": "天气详情",
      "src": "./ets/widget/pages/WeatherLarge.ets",
      "defaultDimension": "4*4",
      "supportDimensions": ["2*4", "4*4"]
    }
  ]
}

小尺寸布局

// WeatherSmall.ets —— 2×2天气卡片
@Entry
@Component
struct WeatherSmall {
  @State city: string = ''
  @State temperature: string = ''
  @State weather: string = ''
  @State updateTime: string = ''

  build() {
    Column() {
      Row() {
        Text(this.city)
          .fontSize(12)
          .fontColor('#666666')
        
        Blank()
        
        Text(this.updateTime)
          .fontSize(9)
          .fontColor('#AAAAAA')
      }
      .width('100%')
      
      // 温度——2×2的核心信息,字号要大
      Text(this.temperature)
        .fontSize(44)
        .fontColor('#FF6B35')
        .fontWeight(FontWeight.Bold)
        .margin({ top: 12 })
      
      Text(this.weather)
        .fontSize(14)
        .fontColor('#666666')
        .margin({ top: 4 })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .justifyContent(FlexAlign.SpaceBetween)
  }
}

大尺寸布局

// WeatherLarge.ets —— 4×4天气卡片
@Entry
@Component
struct WeatherLarge {
  @State city: string = ''
  @State temperature: string = ''
  @State weather: string = ''
  @State humidity: string = ''
  @State aqi: string = ''
  @State aqiLevel: string = ''
  @State wind: string = ''
  @State sunrise: string = ''
  @State sunset: string = ''
  @State updateTime: string = ''
  @State day1: string = ''
  @State day1Weather: string = ''
  @State day1Temp: string = ''
  @State day2: string = ''
  @State day2Weather: string = ''
  @State day2Temp: string = ''
  @State day3: string = ''
  @State day3Weather: string = ''
  @State day3Temp: string = ''

  build() {
    Column() {
      // 头部:城市 + 更新时间
      Row() {
        Text(this.city)
          .fontSize(16)
          .fontColor('#333333')
          .fontWeight(FontWeight.Bold)
        
        Blank()
        
        Text(this.updateTime)
          .fontSize(10)
          .fontColor('#AAAAAA')
      }
      .width('100%')
      
      // 主信息区:温度 + 天气 + 详细指标
      Row() {
        // 左侧:温度和天气
        Column() {
          Text(this.temperature)
            .fontSize(52)
            .fontColor('#FF6B35')
            .fontWeight(FontWeight.Bold)
          
          Text(this.weather)
            .fontSize(16)
            .fontColor('#666666')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        
        Blank()
        
        // 右侧:详细指标
        Column() {
          this.DetailRow('💧', '湿度', this.humidity)
          this.DetailRow('🌬️', '风力', this.wind)
          this.DetailRow('🛡️', '空气', `${this.aqi} ${this.aqiLevel}`)
          this.DetailRow('🌅', '日出', this.sunrise)
          this.DetailRow('🌇', '日落', this.sunset)
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .layoutWeight(1)
      .alignItems(VerticalAlign.Center)
      
      // 底部:三天预报
      Row() {
        this.ForecastDay(this.day1, this.day1Weather, this.day1Temp)
        this.ForecastDay(this.day2, this.day2Weather, this.day2Temp)
        this.ForecastDay(this.day3, this.day3Weather, this.day3Temp)
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)
      .padding({ top: 12 })
      .borderWidth({ top: 1 })
      .borderColor('#F0F0F0')
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
  
  @Builder
  DetailRow(icon: string, label: string, value: string) {
    Row() {
      Text(icon).fontSize(12)
      Text(`${label} ${value}`)
        .fontSize(11)
        .fontColor('#666666')
        .margin({ left: 4 })
    }
    .margin({ bottom: 4 })
  }
  
  @Builder
  ForecastDay(day: string, weather: string, temp: string) {
    Column() {
      Text(day)
        .fontSize(11)
        .fontColor('#999999')
      Text(weather)
        .fontSize(12)
        .fontColor('#333333')
        .margin({ top: 4 })
      Text(temp)
        .fontSize(14)
        .fontColor('#FF6B35')
        .fontWeight(FontWeight.Bold)
        .margin({ top: 2 })
    }
  }
}

FormAbility根据formName返回不同数据

export default class WeatherFormAbility extends FormExtensionAbility {
  
  onAddForm(want: Want): formBindingData.FormBindingData {
    const formName = want.parameters?.['ohos.extra.param.key.form_name'] as string
    
    if (formName === 'weatherSmall') {
      return this.getSmallData()
    } else {
      return this.getLargeData()
    }
  }
  
  private getSmallData(): formBindingData.FormBindingData {
    return formBindingData.createFormBindingData({
      city: '北京',
      temperature: '26°',
      weather: '晴',
      updateTime: '14:30'
    })
  }
  
  private getLargeData(): formBindingData.FormBindingData {
    return formBindingData.createFormBindingData({
      city: '北京',
      temperature: '26°',
      weather: '晴',
      humidity: '45%',
      aqi: '42',
      aqiLevel: '优',
      wind: '3级',
      sunrise: '05:32',
      sunset: '19:48',
      updateTime: '14:30',
      day1: '今天', day1Weather: '晴', day1Temp: '28°/18°',
      day2: '明天', day2Weather: '多云', day2Temp: '25°/16°',
      day3: '后天', day3Weather: '小雨', day3Temp: '22°/14°'
    })
  }
}

完整示例:1×2状态条卡片

1×2是最小的卡片尺寸,只有一行两列的空间。适合做状态条——显示一个核心数据和一个操作按钮。

// StepCountCard.ets —— 1×2步数状态条
@Entry
@Component
struct StepCountCard {
  @State stepCount: string = '0'
  @State goal: string = '10000'
  @State percentage: number = 0

  build() {
    Row() {
      // 左侧:步数
      Column() {
        Text(this.stepCount)
          .fontSize(20)
          .fontColor('#FF6B35')
          .fontWeight(FontWeight.Bold)
        
        Text(`/ ${this.goal}`)
          .fontSize(9)
          .fontColor('#999999')
      }
      .alignItems(HorizontalAlign.Start)
      
      // 右侧:进度环
      Stack() {
        Progress({ value: this.percentage, total: 100, type: ProgressType.Ring })
          .width(36)
          .height(36)
          .color('#4CAF50')
          .style({ strokeWidth: 4 })
        
        Text(`${this.percentage}%`)
          .fontSize(8)
          .fontColor('#666666')
      }
    }
    .width('100%')
    .height('100%')
    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .justifyContent(FlexAlign.SpaceBetween)
    .alignItems(VerticalAlign.Center)
  }
}

1×2的设计要点:

  • 只放1个核心数据 + 1个辅助元素
  • 字号不能太小,至少16px起步
  • 不要放按钮,空间不够
  • 进度条/进度环比数字更直观

踩坑与注意事项

坑1:2×2和4×4不是简单的2倍关系

2×2的宽度是360,4×4的宽度是720。看起来是2倍,但信息密度不是2倍——4×4可以放4倍于2×2的内容(2行2列 vs 4行4列)。

所以4×4不是"2×2放大版",而是"2×2的信息+更多内容"。如果你只是把2×2的字号放大到4×4,那4×4就浪费了。

坑2:圆角裁剪会吃掉边距

桌面会对卡片做圆角裁剪,默认圆角16vp。如果你卡片内容的边距也是16vp,那角落的内容可能被裁掉。

// ❌ 边距太小,角落内容被裁剪
.padding(8)

// ✅ 留够边距,考虑圆角裁剪
.padding(16)

建议:卡片内边距至少16vp,重要内容不要放在角落。

坑3:深色模式下的对比度

卡片在深色模式下,背景色和文字色都要变。如果你硬编码了颜色,深色模式下可能看不清。

// ❌ 硬编码颜色
.fontColor('#333333')
.backgroundColor('#FFFFFF')

// ✅ 使用系统语义化颜色
.fontColor($r('sys.color.ohos_id_color_text_primary'))
.backgroundColor($r('sys.color.ohos_id_color_card_bg'))

但静态卡片里使用系统资源颜色有限制——不是所有系统颜色都能在卡片里用。最稳妥的方案是在FormBindingData里传入颜色值,根据系统主题动态切换。

坑4:1KB数据限制影响多尺寸适配

4×4卡片要展示更多数据,但FormBindingData只有1KB。如果你把4×4的所有数据都塞进去,可能超限。

解决方案:精简数据字段名,用短名代替长名。

// ❌ 字段名太长,浪费空间
const data = {
  currentTemperature: '26°',
  weatherDescription: '晴',
  humidityPercentage: '45%',
  airQualityIndex: '42'
}

// ✅ 字段名精简
const data = {
  temp: '26°',
  desc: '晴',
  hum: '45%',
  aqi: '42'
}

坑5:不同尺寸的交互策略不同

2×2空间有限,不适合放按钮。用户点击整个卡片跳转详情即可。4×4空间充足,可以放多个按钮,实现更丰富的交互。

// 2×2:整个卡片可点击(通过deepLink)
// 不需要单独的按钮

// 4×4:多个交互区域
Button('刷新')
  .onClick(() => { postCardAction(...) })

Button('设置')
  .onClick(() => { postCardAction(...) })

HarmonyOS 6适配说明

  1. 新增2×1+尺寸规格:6.0新增了2×1+(加宽版1×2)尺寸,给状态条类卡片更多空间。你在supportDimensions里可以添加"2*1+"。

  2. 自适应圆角:6.0的卡片渲染引擎支持自适应圆角,不再需要你手动处理圆角裁剪问题。内容会自动避开圆角区域。

  3. 动态主题色:6.0支持卡片跟随系统主题色变化。你可以在布局中使用$r('sys.color.ohos_id_color_emphasize')等强调色,系统会自动根据用户选择的主题色渲染。

  4. 卡片预览增强:6.0的DevEco Studio支持同时预览多种尺寸的卡片效果,方便你对比不同尺寸下的布局差异。

  5. 4×4最小信息量要求:6.0的服务中心对4×4卡片有信息量审核——4×4卡片不能出现大面积空白,至少要展示4个以上的数据项。如果你的4×4卡片只有2×2的内容量,审核会被拒。

总结

卡片多尺寸适配不是"缩放",而是"信息架构"。每种尺寸有自己的信息密度和交互策略,2×2做减法、4×4做加法,1×2只放核心状态。

核心要点回顾:

  • 四种尺寸:1×2(状态条)、2×2(核心信息)、2×4(信息+列表)、4×4(完整面板)
  • 适配方案:条件渲染(简单)、多布局文件(干净)、多formName(独立)
  • 静态卡片无法直接获取尺寸,通过数据字段间接判断
  • 内边距至少16vp,考虑圆角裁剪
  • 深色模式必须处理对比度
  • 1KB数据限制下,字段名要精简
评估维度 说明
学习难度 ⭐⭐⭐ 信息架构设计比代码更难
使用频率 ⭐⭐⭐⭐⭐ 每个卡片都要适配多尺寸
重要程度 ⭐⭐⭐⭐ 尺寸适配不好,卡片审核都过不了

下一篇聊卡片数据共享——多卡片之间怎么共享数据、联动更新、保持一致性。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。