很多 ArkUI 开发者陷入"能用就行"——白底黑字、单调纯色、矩形卡片。本文把 HarmonyOS NEXT 的 LinearGradient(线性渐变)、Progress(进度条)和卡片设计组合成一个精美的"数据看板",展示如何用声明式 API 做出真正的视觉品质感。


一、为什么需要关注视觉效果?

声明式 UI 框架(ArkUI、SwiftUI、Flutter)都有一个共同特点:默认样式很朴素。纯色背景、直角矩形、系统字体——这让开发者的第一个 MVP 看起来"够用",但离"好看"还有距离。

但用户对"好看"的感知非常敏锐。一个渐变背景的卡片比纯色的看起来"更专业",一个有动画的进度条比静态的"更流畅",一个圆角阴影的白卡片比纯文本"更有品质感"。

ArkUI 在视觉效果上提供了足够的 API,只是很多开发者没用到。 本文聚焦三个关键效果:

  1. LinearGradient — 线性渐变,把纯色变成流动的色彩
  2. Progress — 进度条,从百分比到色彩映射,传递"进展感"
  3. 卡片设计 — 圆角、间隙、底色的组合,形成信息层级

在这里插入图片描述

二、LinearGradient:渐变背景的使用

2.1 基本 API

Column() { ... }
  .linearGradient({
    angle: 135,                       // 渐变角度(0=右, 90=下, 180=左, 270=上)
    colors: [
      ['#4FACFE', 0.0],              // 起始色(位置 0%)
      ['#00F2FE', 1.0]              // 结束色(位置 100%)
    ]
  })

关键参数:

  • angle:渐变方向角度。0 是从左到右,90 是从上到下,135 是从左上到右下(对角线渐变,最有立体感),180 是从右到左,270 是从下到上。
  • colors:颜色数组,每项是 [颜色字符串, 位置比例]。位置从 0.0 到 1.0,可以在中间插入更多颜色停靠点做出多色渐变。

2.2 角度选择的视觉效果

角度 方向 视觉效果 推荐场景
0 左→右 平淡的横向变化 横幅、标题栏
90 上→下 自然的纵向变化 页面背景、列表头
135 左上→右下 最具立体感和纵深感 卡片、按钮、统计块
180 右→左 倒置的横向变化 特殊强调
270 下→上 倒置的纵向变化 底部导航、页脚

对于统计卡片,135° 对角线渐变效果最好——它让卡片看起来有光影从左上角照射下来的立体感。

2.3 多色渐变

不像 CSS 的 linear-gradient 只有两个端点,ArkUI 的 colors 数组支持任意多个颜色停靠点:

.linearGradient({
  angle: 180,
  colors: [
    ['#1a1a2e', 0.0],   // 0% — 深蓝黑
    ['#16213e', 0.5],   // 50% — 深蓝
    ['#0f3460', 1.0]    // 100% — 亮蓝
  ]
})

这给页面背景创造了丰富的色彩层次——从深色过渡到亮色,让整个页面的"品质感"大幅提升。

2.4 渐变 vs 纯色的感知差异

纯色卡片:
┌─────────────┐
│  ¥128,930   │  ← 视觉单调,像"表单"
│  总收入      │
└─────────────┘

渐变卡片:
┌─────────────┐
│  ░░░░░░░░░░ │  ← 有光影流动,像"仪表盘"
│  ¥128,930░░ │
│  总收入░░░░░ │
└─────────────┘

同样的布局、同样的字体——渐变版本的卡片看起来"贵"了很多。这就是为什么几乎所有现代 SaaS 产品(Stripe、Vercel、Linear)的仪表盘都在用渐变卡片。


在这里插入图片描述

三、Progress:进度条的正确用法

3.1 基本 API

Progress({ value: 75, total: 100, type: ProgressType.Linear })
  .width('100%')
  .height(8)
  .color('#4FACFE')        // 前景色(已完成的颜色)
  .backgroundColor('#F0F0F0')  // 背景色(轨道的颜色)

Progress 不是只能做文件上传进度条。在仪表盘中,它用来可视化任何"部分/整体"的数据关系——项目完成度、目标达成率、预算使用比例。

3.2 进度 → 色彩的语义映射

不要所有的进度条都用同一个颜色。用颜色传递"进展状态":

getProgressColor(percent: number): string {
  if (percent >= 80) return '#43E97B';   // 绿色:快完成了,健康
  if (percent >= 60) return '#4FACFE';   // 蓝色:正常推进
  if (percent >= 40) return '#FFB347';   // 橙色:需要关注
  return '#FA709A';                       // 红色:滞后,有风险
}

这种"从颜色读取状态"的能力,是仪表盘区别于普通列表的核心优势——不需要读数字,扫一眼颜色就知道哪里有问题。

3.3 进度条 + 标签的组合模式

Column() {
  Row() {
    Text('App 3.0 重构')       // 项目名称
    Blank()                      // 撑开间距
    Text('75%')                  // 百分比数字
      .fontColor('#4FACFE')     // 颜色和进度条一致
  }

  Progress({ value: 75, total: 100, type: ProgressType.Linear })
    .width('100%').height(8)
    .color('#4FACFE')
    .backgroundColor('#F0F0F0')

  Text('更新于 2 小时前')        // 辅助信息
    .fontColor('#999')
}

每一组进度卡片的完整信息层级:

  • 第一行:项目名 + 百分比(主要信息)
  • 第二行:进度条(视觉信号)
  • 第三行:更新时间(辅助文本,灰色弱化)

四、卡片设计:信息层级的建立

4.1 纯白卡片 + 浅灰背景的组合

┌──────────────────────────────────┐
│  浅灰页面背景 (#F2F3F7)           │
│  ┌────────────┐ ┌────────────┐   │
│  │ 白卡片     │ │ 白卡片     │   │ ← 白色从灰底中"浮起来"
│  └────────────┘ └────────────┘   │
│  ┌────────────────────────────┐  │
│  │ 白卡片                     │  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

页面背景用浅灰(#F2F3F7#F5F6FA),内容卡片用纯白(#FFFFFF),这是最经典的"分层"手法——白色卡片从灰色背景中"浮起来",用户自然知道卡片是一个独立的信息单元。

4.2 圆角半径的选择

.borderRadius(BorderRadius.SM)    // 8vp  — 小圆角,列表项
.borderRadius(BorderRadius.MD)    // 12vp — 中圆角,普通卡片
.borderRadius(BorderRadius.LG)    // 16vp — 大圆角,统计卡片、弹窗

统计卡片用大圆角(16vp),普通列表卡片用中圆角(12vp),标签/徽章用小圆角(8vp)。一致的圆角层级让设计看起来"有章法"。

4.3 内边距的一致性

// 统计卡片 — 大内边距,给数字呼吸空间
.padding({ top: Spacing.XL, bottom: Spacing.XL, left: Spacing.LG, right: Spacing.LG })

// 列表卡片 — 中等内边距
.padding({ top: Spacing.MD, bottom: Spacing.MD, left: Spacing.LG, right: Spacing.LG })

内边距不是随便给的。大的元素需要更大的 padding 来避免"拥挤感",小的元素需要较小的 padding 来避免"空洞感"。


五、Demo:数据看板

本 Demo 构建一个精美的数据看板——顶部深色渐变标题,中间四张渐变色统计卡片,下方项目进度和最近动态。

页面结构

DashboardPage (~200行)
├── Header(深色渐变背景 + 标题 + 日期)
│   └── linearGradient: 深蓝 → 深蓝 → 亮蓝(多色渐变)
├── Section 1:统计卡片(2×2 Grid)
│   ├── 总收入 — 蓝色渐变(angle: 135°)
│   ├── 活跃用户 — 紫色渐变(angle: 135°)
│   ├── 订单数 — 粉橙渐变(angle: 135°)
│   └── 增长率 — 绿色渐变(angle: 135°)
├── Section 2:项目进度
│   ├── App 3.0 重构(75%)
│   ├── 支付系统对接(60%)
│   ├── 官网营销页(92%)
│   ├── 用户画像系统(45%)
│   └── 自动化测试覆盖(68%)
│   └── progressColor: 绿/蓝/橙/红 按比例映射
└── Section 3:最近动态
    └── 时间线式活动列表(彩色圆点 + 标题 + 时间)

统计卡片的设计细节

每张卡片包含三个元素:图标(emoji,30号字)、数值(加粗白色,22号字)、标签(半透明白色,12号字)。

Column() {
  Text('💰').fontSize(30).margin({ bottom: Spacing.SM })
  Text('¥128,930')
    .fontSize(22).fontWeight(FontWeight.Bold)
    .fontColor(Color.White)
    .margin({ bottom: 2 })
  Text('总收入')
    .fontSize(FontSize.CAPTION)
    .fontColor('#FFFFFF99')
}
.width('100%')
.padding({ top: Spacing.XL, bottom: Spacing.XL,
  left: Spacing.LG, right: Spacing.LG })
.borderRadius(BorderRadius.LG)
.linearGradient({
  angle: 135,
  colors: [['#4FACFE', 0.0], ['#00F2FE', 1.0]]
})

这些卡片使用 135° 对角线渐变——最经典的"光影从左上方来"的立体感。每张卡片用不同的渐变色对来区分:蓝→青(收入)、紫→粉(用户)、粉→黄(订单)、绿→青(增长)。

所有文字都是白色——在渐变背景上,白色文字通过半透明度#FFFFFF99 = 60% 不透明度)来建立层级:

  • 数值:纯白 #FFFFFF — 最重要的信息
  • 标签:半透明白 #FFFFFF99 — 辅助信息,视觉上后退

项目进度的色彩映射

getProgressColor(percent: number): string {
  if (percent >= 80) return '#43E97B';   // 绿色:接近完成
  if (percent >= 60) return '#4FACFE';   // 蓝色:正常
  if (percent >= 40) return '#FFB347';   // 橙色:关注
  return '#FA709A';                       // 红色:落后
}

这不是随意的颜色映射——它利用了颜色本身的语义(绿=好,红=差)来让用户不看数字就能感知状态

活动时间线

每条活动用彩色圆点 + 标题 + 时间的横向布局:

Row() {
  Row().width(10).height(10).borderRadius(5).backgroundColor('#4FACFE')
  // 竖向居中圆点
  Column() {
    Text('新版本 v2.3.1 发布上线')  // 活动标题
    Text('2 小时前')                // 时间(灰色弱化)
  }
}

圆点颜色从预定义的调色板中取(蓝、绿、粉、紫、橙),增强了视觉节奏感。

三个交互点

  1. 统计卡片点击 — 弹窗展示详情 + “查看详情”/"关闭"按钮
  2. 进度条颜色自动映射 — 无需点击,颜色随百分比自动变化
  3. 日期实时显示 — 页面加载时显示当天日期

六、完整代码

import { AppColors, BorderRadius, FontSize, Spacing } from '../common/Constants';
import { promptAction } from '@kit.ArkUI';

class StatItem {
  label: string;
  value: string;
  icon: string;
  gradientStart: string;
  gradientEnd: string;

  constructor(label: string, value: string, icon: string, gradientStart: string,
    gradientEnd: string) {
    this.label = label;
    this.value = value;
    this.icon = icon;
    this.gradientStart = gradientStart;
    this.gradientEnd = gradientEnd;
  }
}

class ProgressItem {
  title: string;
  percent: number;
  color: string;
  updatedAt: string;

  constructor(title: string, percent: number, color: string, updatedAt: string) {
    this.title = title;
    this.percent = percent;
    this.color = color;
    this.updatedAt = updatedAt;
  }
}

class ActivityItem {
  title: string;
  time: string;
  dotColor: string;

  constructor(title: string, time: string, dotColor: string) {
    this.title = title;
    this.time = time;
    this.dotColor = dotColor;
  }
}

const STATS: StatItem[] = [
  new StatItem('总收入', '¥128,930', '💰', '#4FACFE', '#00F2FE'),
  new StatItem('活跃用户', '8,562', '👥', '#A18CD1', '#FBC2EB'),
  new StatItem('订单数', '3,241', '📦', '#FA709A', '#FEE140'),
  new StatItem('增长率', '+24.5%', '📈', '#43E97B', '#38F9D7'),
];

const PROJECTS: ProgressItem[] = [
  new ProgressItem('App 3.0 重构', 75, '#4FACFE', '更新于 2 小时前'),
  new ProgressItem('支付系统对接', 60, '#A18CD1', '更新于 1 天前'),
  new ProgressItem('官网营销页', 92, '#43E97B', '更新于 3 小时前'),
  new ProgressItem('用户画像系统', 45, '#FA709A', '更新于 6 小时前'),
  new ProgressItem('自动化测试覆盖', 68, '#FFB347', '更新于 1 天前'),
];

const ACTIVITIES: ActivityItem[] = [
  new ActivityItem('新版本 v2.3.1 发布上线', '2 小时前', '#4FACFE'),
  new ActivityItem('服务器弹性扩容完成', '5 小时前', '#43E97B'),
  new ActivityItem('用户反馈工单处理完毕', '昨天 18:30', '#FA709A'),
  new ActivityItem('Q3 产品设计稿评审通过', '昨天 14:00', '#A18CD1'),
  new ActivityItem('数据库慢查询优化上线', '2 天前', '#FFB347'),
];

@Entry
@Component
struct DashboardPage {
  @State stats: StatItem[] = STATS;
  @State projects: ProgressItem[] = PROJECTS;
  @State activities: ActivityItem[] = ACTIVITIES;

  build() {
    Column() {
      Column() {
        Row() {
          Column() {
            Text('数据看板')
              .fontSize(FontSize.HEADLINE)
              .fontWeight(FontWeight.Bold)
              .fontColor(Color.White)
            Text('Dashboard')
              .fontSize(FontSize.CAPTION)
              .fontColor('#FFFFFF88')
              .margin({ top: 2 })
          }

          Blank()

          Column() {
            Text(this.getDateStr())
              .fontSize(FontSize.CAPTION)
              .fontColor('#FFFFFFAA')
          }
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor('#FFFFFF22')
          .borderRadius(9999)
        }
        .width('100%')
        .margin({ bottom: Spacing.LG })
      }
      .width('100%')
      .padding({ left: Spacing.XXL, right: Spacing.XXL, top: 44, bottom: Spacing.LG })
      .linearGradient({
        angle: 180,
        colors: [['#1a1a2e', 0.0], ['#16213e', 0.5], ['#0f3460', 1.0]]
      })

      Scroll() {
        Column() {
          Row() {
            ForEach(this.stats.slice(0, 2), (stat: StatItem) => {
              Column() {
                Text(stat.icon).fontSize(30).margin({ bottom: Spacing.SM })
                Text(stat.value)
                  .fontSize(22)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(Color.White)
                  .margin({ bottom: 2 })
                Text(stat.label)
                  .fontSize(FontSize.CAPTION)
                  .fontColor('#FFFFFF99')
              }
              .width('100%')
              .padding({ top: Spacing.XL, bottom: Spacing.XL,
                left: Spacing.LG, right: Spacing.LG })
              .borderRadius(BorderRadius.LG)
              .linearGradient({
                angle: 135,
                colors: [[stat.gradientStart, 0.0], [stat.gradientEnd, 1.0]]
              })
              .layoutWeight(1)
              .margin({ right: 8 })
              .onClick(() => {
                promptAction.showDialog({
                  title: stat.label,
                  message: `${stat.label}${stat.value}\n\n详细数据请查看报表中心`,
                  buttons: [{ text: '查看详情', color: stat.gradientStart },
                    { text: '关闭', color: AppColors.TEXT_TERTIARY }]
                });
              })
            })
          }
          .width('100%')
          .margin({ bottom: 8 })

          Row() {
            ForEach(this.stats.slice(2, 4), (stat: StatItem) => {
              Column() {
                Text(stat.icon).fontSize(30).margin({ bottom: Spacing.SM })
                Text(stat.value)
                  .fontSize(22)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(Color.White)
                  .margin({ bottom: 2 })
                Text(stat.label)
                  .fontSize(FontSize.CAPTION)
                  .fontColor('#FFFFFF99')
              }
              .width('100%')
              .padding({ top: Spacing.XL, bottom: Spacing.XL,
                left: Spacing.LG, right: Spacing.LG })
              .borderRadius(BorderRadius.LG)
              .linearGradient({
                angle: 135,
                colors: [[stat.gradientStart, 0.0], [stat.gradientEnd, 1.0]]
              })
              .layoutWeight(1)
              .margin({ right: 8 })
              .onClick(() => {
                promptAction.showDialog({
                  title: stat.label,
                  message: `${stat.label}${stat.value}\n\n详细数据请查看报表中心`,
                  buttons: [{ text: '查看详情', color: stat.gradientStart },
                    { text: '关闭', color: AppColors.TEXT_TERTIARY }]
                });
              })
            })
          }
          .width('100%')
          .margin({ bottom: Spacing.XL })

          Text('项目进度')
            .fontSize(FontSize.HEADLINE)
            .fontWeight(FontWeight.Bold)
            .fontColor(AppColors.TEXT_PRIMARY)
            .width('100%')
            .margin({ bottom: Spacing.MD })

          Column() {
            ForEach(this.projects, (proj: ProgressItem) => {
              Column() {
                Row() {
                  Text(proj.title)
                    .fontSize(FontSize.BODY)
                    .fontColor(AppColors.TEXT_PRIMARY)
                    .fontWeight(FontWeight.Medium)
                  Blank()
                  Text(`${proj.percent}%`)
                    .fontSize(FontSize.BODY)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(proj.color)
                }
                .width('100%')
                .margin({ bottom: 6 })

                Progress({ value: proj.percent, total: 100,
                  type: ProgressType.Linear })
                  .width('100%')
                  .height(8)
                  .color(this.getProgressColor(proj.percent))
                  .backgroundColor('#F0F0F0')

                Text(proj.updatedAt)
                  .fontSize(FontSize.CAPTION)
                  .fontColor(AppColors.TEXT_TERTIARY)
                  .width('100%')
                  .margin({ top: 4 })
              }
              .width('100%')
              .padding({ top: Spacing.MD, bottom: Spacing.MD,
                left: Spacing.LG, right: Spacing.LG })
              .backgroundColor(Color.White)
              .borderRadius(BorderRadius.SM)
              .margin({ bottom: Spacing.SM })
            })
          }
          .width('100%')
          .margin({ bottom: Spacing.XL })

          Text('最近动态')
            .fontSize(FontSize.HEADLINE)
            .fontWeight(FontWeight.Bold)
            .fontColor(AppColors.TEXT_PRIMARY)
            .width('100%')
            .margin({ bottom: Spacing.MD })

          Column() {
            ForEach(this.activities, (act: ActivityItem, index: number) => {
              Row() {
                Row() {
                  Row()
                    .width(10).height(10)
                    .borderRadius(5)
                    .backgroundColor(act.dotColor)
                }
                .width(24).height('100%')
                .justifyContent(FlexAlign.Center)

                Column() {
                  Text(act.title)
                    .fontSize(FontSize.BODY)
                    .fontColor(AppColors.TEXT_PRIMARY)
                    .fontWeight(FontWeight.Medium)
                  Text(act.time)
                    .fontSize(FontSize.CAPTION)
                    .fontColor(AppColors.TEXT_TERTIARY)
                    .margin({ top: 2 })
                }
                .layoutWeight(1)
              }
              .width('100%')
              .padding({ top: Spacing.MD, bottom: Spacing.MD,
                left: Spacing.LG, right: Spacing.LG })
              .backgroundColor(Color.White)
              .borderRadius(BorderRadius.SM)
              .margin({ bottom: 4 })
            })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ left: Spacing.LG, right: Spacing.LG,
          top: Spacing.XL, bottom: Spacing.XXL })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .backgroundColor('#F2F3F7')
    }
    .width('100%')
    .height('100%')
  }

  getProgressColor(percent: number): string {
    if (percent >= 80) return '#43E97B';
    if (percent >= 60) return '#4FACFE';
    if (percent >= 40) return '#FFB347';
    return '#FA709A';
  }

  getDateStr(): string {
    const d = new Date();
    return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-` +
      `${d.getDate().toString().padStart(2, '0')}`;
  }
}

七、常见面试题 / 踩坑点

7.1 LinearGradient 的 angle 和 direction 怎么选?

优先使用 angle 而不是 directionangle 的值是标准的 CSS 渐变角度(0=右,90=下,135=对角线,180=左),更直观且跨平台一致。GradientDirection 枚举在不同版本的 ArkUI 中名称可能不同(BottomTopLeftRight 是稳定值,对角线方向的枚举名各版本差异大)。

7.2 渐变卡片上的文字用什么颜色?

几乎总是白色。在彩色渐变背景上,白色文字通过不同的不透明度来建立层级:

  • 主要信息(数值):纯白 #FFFFFF 或 90%+ 不透明度
  • 辅助信息(标签):#FFFFFF99(60% 不透明度)
  • 弱化信息(时间、备注):#FFFFFF66(40% 不透明度)

暗色文字在渐变背景上容易在某个颜色区域看不清。

7.3 Progress 的 height 设置多大合适?

  • 列表中的进度条:4-6vp,不抢眼
  • 卡片中的进度条:6-8vp,有存在感但不突兀
  • 强调型进度条(如上传大文件):10-12vp,用户需要看清进度

低于 4vp 的进度条在手机上几乎看不清,高于 12vp 看起来像"粗条"而非"细线进度条"。

7.4 如何让页面看起来"贵"?

三个低成本技巧:

  1. 浅灰背景 + 白色卡片 — 灰底 #F2F3F7 让白卡片浮起来,成本 0
  2. 渐变替代纯色 — 统计卡片、标题栏用 linearGradient 对角线渐变,立刻"贵"一档
  3. 半透明白色文字 — 渐变背景上用 #FFFFFF99 做辅助文字,显得"高级"

7.5 slice 方法在 ForEach 中能用吗?

Array.slice() 返回新数组,在 ForEach 中直接使用没问题,不需要提前存 @State:

ForEach(this.stats.slice(0, 2), (stat: StatItem) => { ... })

但注意,slice 每次 render 都会创建新数组,数据量大时建议拆成两个 @State 数组。


八、总结

ArkUI 的视觉效果不是"加个颜色就行"——它需要理解渐变的角度、颜色的语义、圆角的层级、卡片的分层。本文用数据看板这一场景,展示了三个核心视觉技巧:

1. LinearGradient 是"质感"的来源。 纯色 → 渐变的改变,是 ArkUI 页面从"能用"到"好看"的分水岭。对角线渐变(135°)给卡片注入光影立体感,多色渐变给页面背景注入层次感。

2. Progress 是"数据故事"的载体。 不要把所有进度条都涂成蓝色。绿/蓝/橙/红的色彩映射让用户不看数字就能感知状态——这是仪表盘区别于普通列表的核心能力。

3. 卡片设计靠"分层"建立信息架构。 灰底 + 白卡 + 圆角 + 统一内边距——这四个元素的组合让信息从扁平变得有层次。不需要阴影(ArkUI 阴影性能开销大),灰底白卡足够让卡片"浮起来"。

数据看板 / Dashboard 特别适合以下场景:

  • 企业管理后台(收入、用户、订单概览)
  • 项目管理工具(迭代进度、任务完成率)
  • 个人数据面板(学习进度、健康数据、财务概览)
  • SaaS 产品首页(关键指标一览)

掌握了渐变、进度条和卡片设计这三个"视觉杠杆",ArkUI 页面的品质感可以从"内部工具"提升到"商用产品"的水平。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐