HarmonyOS开发:卡片UI设计——多尺寸适配与布局策略
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适配说明
-
新增2×1+尺寸规格:6.0新增了2×1+(加宽版1×2)尺寸,给状态条类卡片更多空间。你在supportDimensions里可以添加"2*1+"。
-
自适应圆角:6.0的卡片渲染引擎支持自适应圆角,不再需要你手动处理圆角裁剪问题。内容会自动避开圆角区域。
-
动态主题色:6.0支持卡片跟随系统主题色变化。你可以在布局中使用
$r('sys.color.ohos_id_color_emphasize')等强调色,系统会自动根据用户选择的主题色渲染。 -
卡片预览增强:6.0的DevEco Studio支持同时预览多种尺寸的卡片效果,方便你对比不同尺寸下的布局差异。
-
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数据限制下,字段名要精简
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐ 信息架构设计比代码更难 |
| 使用频率 | ⭐⭐⭐⭐⭐ 每个卡片都要适配多尺寸 |
| 重要程度 | ⭐⭐⭐⭐ 尺寸适配不好,卡片审核都过不了 |
下一篇聊卡片数据共享——多卡片之间怎么共享数据、联动更新、保持一致性。
- 点赞
- 收藏
- 关注作者
评论(0)