在这里插入图片描述

📖 引言

打开支付宝首页,最醒目的是什么?——是那一排又一排的功能入口:扫一扫、付款、出行、电影票、充值中心…… 九宫格、十二宫格,密密麻麻的图标,每个都是一个功能入口。

为什么各大 App 都这么喜欢"功能入口网格"?因为它有几个不可替代的优势:

  • 信息密度高:一屏能放很多功能入口,用户一目了然
  • 查找效率高:图标 + 文字,扫一眼就能找到想要的
  • 拓展性好:想加新功能?往里塞一个图标就行
  • 用户习惯:大家都这么做,用户一看就知道"点这里进功能"

「民族图鉴」的首页也有一个快捷入口区域,一共 6 个入口,分两行三列:

  • 🧠 灵魂测试 → 跳转到测验页
  • 🏛️ 民族图鉴 → 跳转到个人页的图鉴入口
  • 🌍 民族分布 → 跳转到地图页
  • 🎵 民族音乐 → 音乐馆页面
  • 🤖 AI助手 → AI 问答页面
  • 🖼️ 图片工具 → 图片工具箱

每个入口都是一个图标 + 一行文字的卡片,整齐排列在网格里。

这一篇,我们就从这个快捷入口网格入手,深入讲解 Grid 网格布局的使用、图标按钮的设计、以及功能入口的组织方式。Grid 是一个非常强大的布局组件,掌握了它,你就能轻松搞定各种宫格布局、表格布局、不规则网格。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握 Grid 网格布局的核心概念与使用方法
  • ✅ 理解 rowsTemplate / columnsTemplate 的用法
  • ✅ 学会 GridItem 的布局控制(rowStart/columnStart)
  • ✅ 掌握图标按钮的设计规范
  • ✅ 理解 Grid 和 Row/Column 嵌套的区别与选择
  • ✅ 了解响应式网格的实现(不同屏幕不同列数)
  • ✅ 写出整齐、美观、可扩展的功能入口网格

💡 需求分析

功能入口网格的核心需求

需求点 说明 为什么重要
整齐排列 所有入口大小一致,行列对齐 视觉整齐,专业感强
图标+文字 每个入口有图标有文字 直观好认,记忆成本低
点击跳转 点击进入对应功能页面 最基本的交互
可扩展 加新功能不用大改布局 产品迭代快,经常加功能
视觉层级 重要的入口更醒目 引导用户使用核心功能
多端适配 不同屏幕大小都好看 手机、平板、折叠屏都要适配

「民族图鉴」快捷入口设计

🎯 快捷入口
┌──────────┬──────────┬──────────┐
│    🧠    │    🏛️    │    🌍    │
│ 灵魂测试 │ 民族图鉴 │ 民族分布 │
├──────────┼──────────┼──────────┤
│    🎵    │    🤖    │    🖼️    │
│ 民族音乐 │ AI助手  │ 图片工具 │
└──────────┴──────────┴──────────┘
    3列 × 2行,共6个入口

设计细节

设计要素 说明
列数 3 列 手机上 3 列最合适
行数 2 行 6个入口,2行就够了
入口卡片 96vp 高 图标 + 文字,高度适中
图标大小 44vp 圆形图标,手指好点
图标颜色 6种不同颜色 每个功能一种颜色,辨识度高
文字大小 12fp 入口名称,小而清晰
横向间距 12vp 卡片之间的距离
纵向间距 12vp 行之间的距离
卡片圆角 16vp 大圆角,现代感

网格布局的几种实现方式

在鸿蒙里,做网格布局有好几种方式,该选哪个?

方式 优点 缺点 适用场景
Row + Column 嵌套 简单直观,容易理解 写起来啰嗦,不方便动态控制 固定的少量入口(2-3行)
Grid 组件 专业网格,功能强大,性能好 概念稍多,需要学习 大量网格、不规则网格、动态数据
Flex 布局 灵活,自动换行 对齐不如 Grid 精准 瀑布流、标签云等不规则布局

「民族图鉴」的快捷入口只有 6 个,Row + Column 也能做,但我们还是用 Grid——因为 Grid 更专业、扩展性更好,以后加新功能直接加数据就行,不用改布局结构。


🛠️ 核心实现

步骤1:Grid 组件基础

Grid 是专门用来做网格布局的容器组件。它的核心概念很简单:

  • 行(Row):横向的一行
  • 列(Column):纵向的一列
  • 单元格(Cell):行和列交叉的格子
1.1 最简单的 Grid
Grid() {
  GridItem() { Text('1').textAlign(TextAlign.Center) }
  GridItem() { Text('2').textAlign(TextAlign.Center) }
  GridItem() { Text('3').textAlign(TextAlign.Center) }
  GridItem() { Text('4').textAlign(TextAlign.Center) }
  GridItem() { Text('5').textAlign(TextAlign.Center) }
  GridItem() { Text('6').textAlign(TextAlign.Center) }
}
.columnsTemplate('1fr 1fr 1fr')  // 3 列,每列等宽
.rowsTemplate('1fr 1fr')         // 2 行,每行等高
.rowsGap(12)                      // 行间距
.columnsGap(12)                   // 列间距
.width('100%')
.height(200)

关键属性

属性 类型 说明
columnsTemplate string 列模板,定义每列的宽度
rowsTemplate string 行模板,定义每行的高度
columnsGap number 列之间的间距
rowsGap number 行之间的间距
onClick (event: GridItemClickEvent) => void 点击事件
1.2 模板语法:fr 是什么?

columnsTemplate('1fr 1fr 1fr') 里的 fr 是什么意思?

frfraction(分数/比例)的意思。1fr 1fr 1fr 就是"三等分,每列占 1 份"。

┌─────────┬─────────┬─────────┐
│   1fr   │   1fr   │   1fr   │  =  3 列,等宽
└─────────┴─────────┴─────────┘

再比如 2fr 1fr 1fr 就是"第一列占 2 份,第二第三列各占 1 份",第一列是其他列的两倍宽。

┌─────────────┬─────────┬─────────┐
│     2fr     │   1fr   │   1fr   │  =  3 列,第一列两倍宽
└─────────────┴─────────┴─────────┘

也可以混合固定值和 fr:

.columnsTemplate('100vp 1fr 2fr')
// 第一列固定 100vp,剩下的空间分成 3 份,第二列 1 份,第三列 2 份

💡 fr 和 Flex 的 layoutWeight 很像。Flex 布局里的 layoutWeight(1) 就是占剩余空间的比例,Grid 里的 1fr 也是占剩余空间的比例。原理是一样的,只是写法不同。

1.3 「民族图鉴」快捷入口的 Grid 实现
// pages/Index.ets

@Builder
buildQuickEntryGrid(): void {
  Column({ space: $r('app.float.spacing_md') }) {
    // ---- 标题 ----
    Row() {
      Text('\u{1F3AF}')  // 🎯 靶心
        .fontSize($r('app.float.icon_size_md'))
      Text($r('app.string.home_quick_access'))  // "快捷入口"
        .fontSize($r('app.float.font_size_lg'))
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.text_primary'))
    }
    .width('100%')

    // ---- 入口网格 ----
    Grid() {
      GridItem() {
        this.buildQuickEntryItem(
          '\u{1F9E0}',                          // 🧠
          this.getLocalizedText('灵魂测试', 'Soul Test'),
          $r('app.color.primary_color'),
          () => { this.currentIndex = 3; }       // 跳转到测验Tab
        )
      }

      GridItem() {
        this.buildQuickEntryItem(
          '\u{1F3DB}\u{FE0F}',                   // 🏛️
          this.getLocalizedText('民族图鉴', 'Collection'),
          $r('app.color.legendary_color'),
          () => { this.currentIndex = 4; }       // 跳转到我的Tab
        )
      }

      GridItem() {
        this.buildQuickEntryItem(
          '\u{1F30D}',                           // 🌍
          this.getLocalizedText('民族分布', 'Distribution'),
          $r('app.color.accent_color'),
          () => { this.currentIndex = 2; }       // 跳转到地图Tab
        )
      }

      GridItem() {
        this.buildQuickEntryItem(
          '\u{1F3B5}',                           // 🎵
          this.getLocalizedText('民族音乐', 'Music'),
          '#E74C3C',
          () => { router.pushUrl({ url: 'pages/MusicPage' }); }
        )
      }

      GridItem() {
        this.buildQuickEntryItem(
          '\u{1F916}',                           // 🤖
          this.getLocalizedText('AI助手', 'AI Helper'),
          '#9B59B6',
          () => { router.pushUrl({ url: 'pages/AIChatPage' }); }
        )
      }

      GridItem() {
        this.buildQuickEntryItem(
          '\u{1F5BC}\u{FE0F}',                   // 🖼️
          this.getLocalizedText('图片工具', 'Image Tools'),
          '#1ABC9C',
          () => { router.pushUrl({ url: 'pages/ImageToolPage' }); }
        )
      }
    }
    .columnsTemplate('1fr 1fr 1fr')  // 3 列,等宽
    .rowsGap($r('app.float.spacing_sm'))
    .columnsGap($r('app.float.spacing_sm'))
    .width('100%')
  }
  .width('90%')
}

用 Grid 的好处是:如果以后要加第 7 个、第 8 个入口,只要多加几个 GridItem 就行了,Grid 会自动排列到第三行、第四行。布局代码完全不用改。如果用 Row + Column,加一行就要多套一层 Row,麻烦多了。


步骤2:入口卡片设计——图标 + 文字

每个功能入口都是一个"圆形图标 + 文字标签"的卡片,结构很简单,但要做得好看也有讲究。

2.1 入口卡片实现
// pages/Index.ets

/**
 * 构建单个快捷入口项
 * @param icon 图标(emoji)
 * @param label 标签文字
 * @param iconColor 图标背景色
 * @param onClickAction 点击回调
 */
@Builder
buildQuickEntryItem(
  icon: string,
  label: string,
  iconColor: ResourceStr,
  onClickAction: () => void
): void {
  Column({ space: 8 }) {
    // ---- 图标 ----
    Text(icon)
      .fontSize(24)
      .fontColor('#FFFFFF')
      .width(44)
      .height(44)
      .borderRadius(22)
      .backgroundColor(iconColor)
      .textAlign(TextAlign.Center)

    // ---- 文字标签 ----
    Text(label)
      .fontSize($r('app.float.font_size_xs'))
      .fontColor($r('app.color.text_primary'))
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .layoutWeight(1)
  .height(96)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .border({ width: 1, color: $r('app.color.border_color') })
  .onClick(onClickAction)
}

卡片结构

Column(整个入口卡片,高度96vp)
├── Text(圆形图标,44vp圆,emoji图标)
└── Text(文字标签,12fp,最多1行)

设计要点

  1. 圆形图标:44vp 的正圆,刚好是最小触控目标的尺寸,点起来舒服
  2. 彩色背景:每个入口一个颜色,辨识度高,视觉也丰富
  3. 文字在下面:图标在上,文字在下,符合用户的认知习惯
  4. 卡片化:整个入口是一个白色圆角卡片,有边框,有点击区域
  5. 居中对齐:图标和文字都居中,整齐好看
2.2 颜色搭配的艺术

6 个入口 6 种颜色,怎么选颜色?有几个原则:

原则 说明 例子
功能语义 颜色要和功能的"感觉"匹配 音乐用红色(热情)、AI用紫色(科技感)
饱和度适中 不要太艳也不要太灰 主色调是调整后的,不是纯红纯蓝
明度接近 所有颜色的亮度差不多 不会有的特别亮有的特别暗
数量控制 不要太多颜色,5-6 种够了 太多了显得乱

「民族图鉴」的 6 个入口颜色:

功能 颜色 色值 感觉
灵魂测试 主色红 #E74C3C 热情、活力
民族图鉴 金色 #F39C12 珍贵、收藏
民族分布 绿色 #27AE60 地理、自然
民族音乐 橙色红 #E74C3C 音乐、热情
AI助手 紫色 #9B59B6 科技、神秘
图片工具 青色 #1ABC9C 创意、艺术

💡 颜色搭配小技巧

  • 去 Dribbble、Behance 上看优秀设计,直接"借鉴"它们的配色
  • 用 Adobe Color、Coolors 等配色工具生成和谐的配色方案
  • 记住几个经典配色:马卡龙色、莫兰迪色、北欧风
  • 同一个 App 里的颜色,饱和度和明度要统一,不然会像拼凑的

步骤3:动态数据——用 ForEach 渲染网格

如果入口是从数据来的(而不是写死的),用 ForEach 更方便。以后加新入口,只要往数组里加一条数据就行。

3.1 数据模型
// models/QuickEntry.ts
export interface QuickEntryItem {
  id: string;           // 唯一标识
  icon: string;         // 图标(emoji 或 图片)
  labelZh: string;      // 中文标签
  labelEn: string;      // 英文标签
  color: string;        // 图标背景色
  targetType: 'tab' | 'page';  // 跳转类型:切Tab / 跳页面
  targetValue: string;  // 跳转目标:tab索引 或 页面url
  sortOrder: number;    // 排序
}
3.2 数据配置
// constants/QuickEntryConfig.ts
import { QuickEntryItem } from '../models/QuickEntry';

export const QUICK_ENTRIES: QuickEntryItem[] = [
  {
    id: 'quiz',
    icon: '\u{1F9E0}',
    labelZh: '灵魂测试',
    labelEn: 'Soul Test',
    color: '#E74C3C',
    targetType: 'tab',
    targetValue: '3',
    sortOrder: 1
  },
  {
    id: 'collection',
    icon: '\u{1F3DB}\u{FE0F}',
    labelZh: '民族图鉴',
    labelEn: 'Collection',
    color: '#F39C12',
    targetType: 'tab',
    targetValue: '4',
    sortOrder: 2
  },
  // ... 更多
];
3.3 用 ForEach 渲染
Grid() {
  ForEach(this.quickEntries, (entry: QuickEntryItem) => {
    GridItem() {
      QuickEntryCard({ entry: entry })
    }
  }, (entry: QuickEntryItem) => entry.id)
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(12)
.columnsGap(12)
.width('100%')

这样布局代码就很简洁了——只管布局,不管具体有多少个入口。入口数据在配置文件里管理,职责分离。

💡 数据和视图分离的好处

  1. 好维护:改入口名称、颜色、排序,去数据文件改,不用动布局代码
  2. 好扩展:加新入口,往数组里加一条就行
  3. 好测试:数据可以单独测试
  4. 好复用:同样的数据可以在不同地方展示

步骤4:Grid 进阶——不规则网格

Grid 最强大的地方不是整齐的网格,而是——可以做不规则网格。一个单元格可以跨多行、跨多列。

4.1 跨列(columnStart / columnEnd)
Grid() {
  // 第一个占 2 列
  GridItem() {
    Text('大卡片')
      .width('100%')
      .height('100%')
      .backgroundColor('#FF6B6B')
  }
  .columnStart(1)  // 从第1列开始
  .columnEnd(2)    // 到第2列结束(占2列)

  // 后面的正常排
  GridItem() { Text('2').backgroundColor('#4ECDC4') }
  GridItem() { Text('3').backgroundColor('#45B7D1') }
  GridItem() { Text('4').backgroundColor('#96CEB4') }
  GridItem() { Text('5').backgroundColor('#FFEAA7') }
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')

效果:

┌─────────────────────┬──────────┐
│      大卡片         │    2     │
├──────────┬──────────┼──────────┤
│    3     │    4     │    5     │
└──────────┴──────────┴──────────┘
4.2 跨行(rowStart / rowEnd)
Grid() {
  // 第一个占 2 行
  GridItem() {
    Text('侧边栏')
      .width('100%')
      .height('100%')
      .backgroundColor('#FF6B6B')
  }
  .rowStart(1)
  .rowEnd(2)

  // 右边正常排
  GridItem() { Text('2').backgroundColor('#4ECDC4') }
  GridItem() { Text('3').backgroundColor('#45B7D1') }
  GridItem() { Text('4').backgroundColor('#96CEB4') }
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')

效果:

┌──────────┬──────────┬──────────┐
│          │    2     │    3     │
│  侧边栏  ├──────────┼──────────┤
│          │    4     │          │
└──────────┴──────────┴──────────┘
4.3 实战案例:首页 Banner 布局

比如首页顶部有个大 Banner,旁边两个小入口:

Grid() {
  // 大 Banner,占 2 列 2 行
  GridItem() {
    Image('banner.jpg')
      .width('100%')
      .height('100%')
      .objectFit(ImageFit.Cover)
  }
  .columnStart(1)
  .columnEnd(2)
  .rowStart(1)
  .rowEnd(2)

  // 右上角小卡片
  GridItem() {
    SmallCard({ title: '活动1' })
  }
  .columnStart(3)
  .rowStart(1)

  // 右下角小卡片
  GridItem() {
    SmallCard({ title: '活动2' })
  }
  .columnStart(3)
  .rowStart(2)
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')
.height(200)

效果:

┌─────────────────────┬──────────┐
│                     │  活动1   │
│     Banner 大图     ├──────────┤
│                     │  活动2   │
└─────────────────────┴──────────┘

这就是 Grid 比 Row/Column 强大的地方——不规则布局用 Grid 特别方便,用 Row/Column 嵌套的话,得写好多层,还容易乱。


步骤5:响应式网格——不同屏幕不同列数

手机上 3 列合适,但平板呢?平板屏幕宽,3 列的话每个卡片太大了,5-6 列才合适。折叠屏展开了更宽,可能要 8 列。

这就是响应式布局——根据屏幕宽度,动态调整列数。

5.1 实现思路
  1. 获取屏幕宽度
  2. 根据宽度判断当前是什么设备(手机/平板/大屏)
  3. 动态设置 columnsTemplate 的列数
5.2 代码实现
@Entry
@Component
struct QuickEntryGrid {
  @State columns: number = 3;  // 默认 3 列(手机)

  aboutToAppear(): void {
    this.updateColumns();
  }

  onPageShow(): void {
    this.updateColumns();  // 页面显示时也检查一下(折叠屏展开/折叠)
  }

  private updateColumns(): void {
    const screenWidth = display.getWindowProperties().windowRect.width;
    
    if (screenWidth > 840) {
      this.columns = 6;      // 大屏/折叠屏展开:6列
    } else if (screenWidth > 600) {
      this.columns = 4;      // 平板:4列
    } else {
      this.columns = 3;      // 手机:3列
    }
  }

  // 生成 columnsTemplate 字符串
  private getColumnsTemplate(): string {
    return Array(this.columns).fill('1fr').join(' ');
    // 比如 3 列:'1fr 1fr 1fr'
    // 比如 5 列:'1fr 1fr 1fr 1fr 1fr'
  }

  build() {
    Grid() {
      ForEach(this.entries, (entry) => {
        GridItem() {
          QuickEntryCard({ entry: entry })
        }
      }, e => e.id)
    }
    .columnsTemplate(this.getColumnsTemplate())  // 动态列数
    .rowsGap(12)
    .columnsGap(12)
    .width('100%')
  }
}

断点设计(Breakpoint):

屏幕宽度 列数 设备类型
< 600vp 3 列 手机(竖屏)
600-840vp 4 列 小平板 / 手机横屏
> 840vp 6 列 大平板 / 折叠屏展开

这样在任何尺寸的屏幕上,入口网格都能以合适的列数显示,不会太挤也不会太松。

💡 响应式设计的核心思想:不是为每个设备单独做一套界面,而是用一套代码,根据屏幕尺寸自动调整布局。Grid + 动态列数是实现响应式网格最简单有效的方式。


⚠️ 常见问题与解决方案

问题1:Grid 里的内容大小不一致,对不齐

现象
Grid 里每个 GridItem 的内容高度不一样,有的高有的矮,网格看起来歪歪扭扭的。

原因
GridItem 默认是"内容撑大"的,内容高的格子就高,内容矮的格子就矮。

解决方案

设置 GridItem 的高度为 100%,让它填满单元格

GridItem() {
  Column() {
    // 内容
  }
  .width('100%')
  .height('100%')  // 填满整个 GridItem
}

或者直接设置 Grid 的行高为固定值或 1fr

Grid() {
  // ...
}
.rowsTemplate('1fr 1fr')  // 每行等高,各占一半

1fr 的行就是"所有行平分高度",每行一样高,GridItem 默认会填满行高,自然就对齐了。


问题2:Grid 的点击事件怎么获取点击了哪个

现象
想给整个 Grid 加一个点击事件,知道用户点了第几项,但是不知道怎么获取索引。

解决方案

方案1:给每个 GridItem 单独加 onClick(推荐)

ForEach(this.items, (item: Item, index: number) => {
  GridItem() {
    ItemCard({ item: item })
  }
  .onClick(() => {
    console.info('点击了第 ' + index + ' 个');
    this.handleItemClick(item);
  })
}, item => item.id)

每个 GridItem 自己处理自己的点击事件,最直观,最灵活。

方案2:用 Grid 的 onItemClick

Grid() {
  ForEach(this.items, (item) => {
    GridItem() { ItemCard({ item: item }) }
  }, item => item.id)
}
.onItemClick((event: GridItemClickEvent) => {
  const rowIndex = event.rowIndex;    // 行索引
  const columnIndex = event.columnIndex;  // 列索引
  console.info(`点击了第${rowIndex}行,第${columnIndex}`);
})

onItemClick 可以拿到行号和列号,适合做一些全局的处理。


问题3:Grid 嵌套 Scroll,滚动方向冲突

现象
Grid 放在一个纵向滚动的页面里,Grid 内容多了想让它纵向滚,但是页面也在纵向滚,冲突了。

解决方案

大多数情况下,Grid 不要滚动,让外面的 Scroll 滚动

Grid 本身是布局容器,不是滚动容器。如果内容很多,用 Scroll 包裹 Grid:

Scroll() {
  Grid() {
    // 很多很多 GridItem
  }
  .columnsTemplate('1fr 1fr 1fr')
  .width('100%')
}
.scrollable(ScrollDirection.Vertical)

外面的 Scroll 负责滚动,Grid 负责布局,各司其职。

Grid 默认是不滚动的,它只是一个布局容器。如果你想让 Grid 本身滚动,需要设置 scrollBar 和相关属性,但一般不推荐——用 Scroll 包裹更灵活,也更符合组件职责分离的原则。


问题4:入口图标太小,点不到

现象
快捷入口的图标太小(比如 24vp),文字也小,很难点中,经常误触。

解决方案

保证触控区域至少 44×44vp

这是移动端设计的黄金法则——所有可点击元素,触控目标至少 44×44vp(dp/pt)。

我们的设计是:

  • 圆形图标:44vp(刚好满足最小触控目标)
  • 整个卡片:宽度自适应,高度 96vp(更大,更好点)

整个卡片都是可点击的,不只是图标那一点。用户点图标周围的空白区域,也能触发点击。这样就好点多了。

// 整个 Column 都加 onClick,不是只给 Text 加
Column() {
  Text(icon)  // 图标只是视觉上的
    .width(44)
    .height(44)
  Text(label)
}
.height(96)        // 整个卡片高度 96
.width('100%')     // 宽度充满
.onClick(() => {   // 整个卡片可点击
  // ...
})

记住:用户看到的是图标,但点击的是整个卡片。视觉上可以小,但点击区域一定要够大。这是 UI 和交互的区别——UI 管好看,交互管好⽤。


问题5:功能太多,一屏放不下

现象
功能入口越来越多,从 6 个加到 12 个、20 个,一屏放不下了。

解决方案

方案1:分页 + "更多"按钮(推荐)

第一页放最常用的 8-10 个,最后放一个"更多"按钮,点进去看全部。

// 前 5 个显示在首页
const displayEntries = allEntries.slice(0, 5);

// 第 6 个是"更多"
const moreEntry = {
  icon: '···',
  label: '更多',
  onClick: () => { router.pushUrl({ url: 'pages/AllEntriesPage' }); }
};

好处:首页不拥挤,核心功能一眼能看到;更多功能藏在二级页面,想用的人自己找。

方案2:横向滚动的网格

做成可以左右滑动的网格,一行 4 个,一共 3 行,可以翻页。

Scroll() {
  Grid() {
    // 很多入口
  }
  .columnsTemplate('1fr 1fr 1fr 1fr')
  .rowsTemplate('1fr 1fr 1fr')
  .width('200%')  // 宽度设大,让它能横滑
}
.scrollable(ScrollDirection.Horizontal)

好处:可以放很多入口,又不占纵向空间;用户左右滑就能看更多。

方案3:分类 Tab

入口太多就分类——“常用”、“生活服务”、“金融理财”、“交通出行”…… 上面一排 Tab 切换分类。

支付宝就是这么做的——首页一排分类 Tab,每个分类下面是对应的功能入口。

💡 功能入口的"二八定律"
80% 的用户只会用 20% 的功能。所以把最核心的 20% 放在最显眼的位置,剩下的 80% 藏起来,想用的人能找到就行。不要把所有功能都堆在首页,那样看起来很杂乱,核心功能反而被淹没了。


🧠 进阶拓展:宫格导航的深度设计

6.1 宫格导航的设计原则

宫格导航(也叫"九宫格"、“快捷入口”)是移动端最常见的导航形式之一。但要做好并不简单——不是把图标往格子里一放就行。

6.1.1 数量原则:多少个入口最合适?
数量 排列方式 适用场景 优缺点
4个 1×4 或 2×2 核心功能很少,极简产品 ✅ 每个都很醒目 ❌ 太少了,不够用
6个 2×3 中等功能数量 ✅ 刚好一屏,不挤不松 ❌ 一般不太够
8个 2×4 或 4×2 功能较多 ✅ 数量适中,大部分产品够用 ❌ 两排,需要稍微往下看
9个 3×3 功能很多 ✅ 经典九宫格,信息密度高 ❌ 太多了,找起来费劲
12个 3×4 超级APP ✅ 功能多 ❌ 太拥挤,视觉负担重

「民族图鉴」的选择:8个(2行×4列)

为什么是8个?因为我们有8个主要功能模块:

  1. 🧠 灵魂测试
  2. 🏛️ 民族图鉴
  3. 🌍 民族分布
  4. 🎵 民族音乐
  5. 🤖 AI助手
  6. 🖼️ 图片工具
  7. 📚 文化学堂
  8. 🎮 知识问答

8个不多不少,刚好两排,用户一眼能扫完,又能覆盖主要功能。

💡 二八定律在宫格里的应用
20% 的功能承担了 80% 的使用量。把最常用的 2-3 个功能放在最显眼的位置(通常是第一行前几个),其他功能按重要程度排列。不要平均分配注意力。

6.1.2 排列原则:顺序怎么定?

入口的排列顺序不是随便排的,有讲究:

1. 使用频率排序(最常用)

  • 用得越多的,放得越靠前
  • 符合用户习惯,找起来快

2. 用户认知排序(心智模型)

  • 按用户对功能的认知分类排列
  • 相关的功能放在一起

3. 产品目标排序(运营导向)

  • 想推什么功能,就放前面
  • 新功能、重要功能放显眼位置

「民族图鉴」的排序是混合策略:

  • 灵魂测试放第一个——因为这是我们的特色功能,想让用户第一眼就看到
  • 民族图鉴放第二个——这是核心功能,用户最常用
  • 然后按功能类型排列:工具类(分布、音乐)、互动类(AI、图片)、学习类(学堂、问答)
6.1.3 文字规范:入口名称怎么起?

入口下面的文字标签,看起来简单,其实很有学问:

原则 说明 反例
字数统一 最好都是 2-4 个字,长短一致 一个叫"测试",一个叫"民族文化知识问答"
语义清晰 一看就知道是什么功能 “探索”、“发现”——啥意思?
词性统一 都是名词,或都是动词 有的叫"音乐"(名词),有的叫"去测试"(动词)
避免歧义 不要有多种理解 “工具”——什么工具?

「民族图鉴」的入口名称:

  • 都是4个字:灵魂测试、民族图鉴、民族分布、民族音乐……
  • 都是"名词+名词"或"形容词+名词"结构
  • 一看就知道是什么功能

别小看这几个字。用户打开 App,扫一眼宫格,0.5 秒内要判断出"我要的功能在哪"。名字起得不好,用户找不到,功能再好用也白搭。


6.2 图标的设计规范

图标是宫格的灵魂——好的图标,用户一眼就知道是什么功能;不好的图标,用户看了文字才明白,甚至看完文字还不明白。

6.2.1 图标风格的选择
风格 特点 适用场景 代表产品
线性图标 只有线条,没有填充 简约、现代、轻量 iOS、微信
填充图标 实心填充,有重量感 醒目、有力量感 Material Design
扁平图标 纯色 + 简单形状 简洁、现代 大部分 App
拟物图标 仿真物体,有阴影有质感 真实、有温度 早期 iOS
插画图标 手绘风格,有细节 有个性、有温度 小众精品 App

「民族图鉴」的选择:扁平圆形图标 + emoji 符号

为什么用 emoji?

  • 开发快:不用找设计师画图标,直接用 emoji
  • 风格统一:所有 emoji 都是一个风格,不会乱
  • 辨识度高:emoji 大家都认识,学习成本低
  • 有温度:emoji 比纯图标更活泼、更有趣

当然,如果有设计师资源,还是建议画一套专属图标——更有品牌感。但在项目初期,emoji 是性价比很高的选择。

6.2.2 图标的颜色设计

图标颜色不是随便选的,要考虑:

1. 颜色的语义

不同颜色会给人不同的心理感受:

  • 🔴 红色:热情、危险、重要
  • 🟠 橙色:活力、温暖、美食
  • 🟡 黄色:快乐、阳光、警告
  • 🟢 绿色:自然、健康、成功
  • 🔵 蓝色:科技、信任、冷静
  • 🟣 紫色:神秘、高贵、创意
  • 🟤 棕色:传统、质朴、土地

「民族图鉴」的图标颜色:

  • 灵魂测试:紫色(神秘、探索)
  • 民族图鉴:蓝色(知识、信任)
  • 民族分布:绿色(自然、地理)
  • 民族音乐:橙色(活力、艺术)
  • AI助手:青色(科技、未来)
  • 图片工具:粉色(创意、美学)
  • 文化学堂:棕色(传统、学问)
  • 知识问答:红色(挑战、热情)

2. 颜色的统一性

虽然每个图标颜色不一样,但要保持统一性:

  • 饱和度一致:不要有的特别鲜艳,有的特别灰
  • 明度一致:不要有的特别亮,有的特别暗
  • 数量控制:颜色种类不要太多,6-8种就够了

💡 配色小技巧
如果你不知道怎么选颜色,可以去 Dribbble、Pinterest 上搜"app icon color",看看别人是怎么配的。找一套你喜欢的,直接拿来用——配色这东西,不要自己瞎琢磨,站在巨人的肩膀上。

6.2.3 图标的大小与留白

图标不是越大越好——周围要有留白,不然会显得拥挤。

元素 大小 说明
图标尺寸 44vp 最小触控目标
图标外圆形 56vp 比图标大一圈,有呼吸感
图标与圆形间距 6vp 四周留白
图标与文字间距 8vp 图文间距
整个卡片高度 96vp 图标 + 文字 + 上下留白

留白不是浪费空间——留白是为了让内容更突出、更易读。


6.3 点击反馈与微动效

用户点了一下入口,要不要给点反馈?——要的!而且反馈的质量直接影响用户对 App 的感受。

6.3.1 为什么需要点击反馈?

想象一下:你点了一个按钮,什么反应都没有。你会怎么想?

  • “我点到了吗?”
  • “是不是卡了?”
  • “这个按钮是不是坏的?”
  • 然后你又点了一下

没有反馈的按钮,用户会困惑、会重复点击、会失去信任感。

好的点击反馈,要满足三个条件:

  1. 即时:点下去立刻有反应,不能有延迟
  2. 明显:用户能清楚地看到变化
  3. 自然:动效要自然,不能太夸张
6.3.2 三种常见的点击反馈

1. 透明度变化(最简单)

按下的时候透明度变低,松开恢复。最简单,也最常用。

@State isPressed: boolean = false;

Column() {
  // 图标 + 文字
}
.opacity(this.isPressed ? 0.7 : 1)
.animation({ duration: 100 })
.onTouch((event: TouchEvent) => {
  if (event.type === TouchType.Down) {
    this.isPressed = true;
  } else if (event.type === TouchType.Up || 
             event.type === TouchType.Cancel) {
    this.isPressed = false;
  }
})

优点:实现简单,效果不错
缺点:比较平淡,没什么特色

2. 缩放效果(最有质感)

按下的时候缩小一点,松开弹回来。像按一个真实的按钮一样,很有手感。

.scale({ x: this.isPressed ? 0.92 : 1, y: this.isPressed ? 0.92 : 1 })
.animation({ duration: 100, curve: Curve.EaseInOut })

优点:手感好,有质感
缺点:如果周围有其他元素,缩放可能会导致位置变化

3. 背景色变化(最清晰)

按下的时候背景色变深或变灰。最适合按钮、卡片。

.backgroundColor(this.isPressed ? '#F0F0F0' : '#FFFFFF')
.animation({ duration: 100 })

优点:反馈清晰,不影响布局
缺点:需要背景色,透明图标不适用

「民族图鉴」的选择:缩放 + 透明度,双重反馈

我们把缩放和透明度结合起来——按下时缩到 95% + 透明度 80%,既有手感又有层次感。

.opacity(this.isPressed ? 0.8 : 1)
.scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
.animation({ duration: 120, curve: Curve.EaseInOut })

💡 动效时长的秘密
点击反馈的动画时长一定要短——100-150 毫秒就够了。太长了会觉得"迟饨"、“慢”。记住:用户按下的那一瞬间就要有反应,不能等。

6.3.3 入口点击后的跳转动效

用户点了入口,跳转到新页面,这个过程也要有动效——不然会觉得突兀。

常见的页面转场动效:

动效 说明 适用场景
右侧滑入 新页面从右边滑进来 层级导航(大部分场景)
底部滑入 新页面从底部推上来 弹窗、模态页
淡入淡出 新页面渐渐出现 平级切换、Tab切换
缩放淡入 新页面从小变大,同时淡入 点击图标/卡片跳转

「民族图鉴」用的是系统默认的右侧滑入——这是最经典、用户最熟悉的转场方式。

如果你想做的更有特色一点,可以做"图标展开"的动效——点击哪个图标,新页面就从那个图标的位置展开出来。这种动效很有空间感,但实现起来也更复杂。


6.4 宫格的多种布局形式

3×3 九宫格只是最基础的形式。宫格布局还有很多变化。

6.4.1 不同列数的布局

3列布局(手机端最常用)

┌───┬───┬───┐
│   │   │   │
├───┼───┼───┤
│   │   │   │
└───┴───┴───┘
  • 每个格子比较宽,图标可以大一点
  • 文字空间充足,可以放 4 个字
  • 适合功能不多(6-9个)的产品

4列布局(功能较多时)

┌──┬──┬──┬──┐
│  │  │  │  │
├──┼──┼──┼──┤
│  │  │  │  │
└──┴──┴──┴──┘
  • 格子小一些,信息密度高
  • 文字最好 2-3 个字,不然放不下
  • 适合功能很多(8-12个)的产品
  • 「民族图鉴」用的就是 4 列

5列布局(超级APP)

┌─┬─┬─┬─┬─┐
│ │ │ │ │ │
├─┼─┼─┼─┼─┤
│ │ │ │ │ │
└─┴─┴─┴─┴─┘
  • 格子很小,图标要简洁
  • 文字最好 2 个字,或者干脆不要文字
  • 适合功能超级多的超级 APP
  • 支付宝、微信就是 5 列
6.4.2 瀑布流布局

不是所有宫格都是整齐的行列。瀑布流(Masonry)也是一种常见的宫格变体:

┌───┐ ┌───┐ ┌───┐
│   │ │   │ │   │
│   │ └───┘ │   │
└───┘ ┌───┐ │   │
      │   │ └───┘
┌───┐ │   │ ┌───┐
│   │ └───┘ │   │
└───┘       └───┘

特点:

  • 每个卡片高度不一样,根据内容自适应
  • 错落有致,视觉上更活泼
  • 适合图片类、内容类产品
  • 小红书、Pinterest 就是瀑布流

瀑布流的实现比普通 Grid 复杂,因为需要计算每列的高度,把下一张图放到最短的那一列下面。

6.4.3 混合布局:大卡 + 小宫格

有时候,最重要的功能想让它更醒目,可以做"大卡 + 小宫格"的混合布局:

┌───────────┬─────┐
│           │  1  │
│  大卡片   ├─────┤
│           │  2  │
├─────┬─────┼─────┤
│  3  │  4  │  5  │
└─────┴─────┴─────┘

第一个功能是个大卡片,占 2×2 的位置,其他的是小格子。这样第一个功能特别醒目,点击率会高很多。

用 Grid 实现很简单:

Grid() {
  // 第一个:跨2行2列
  GridItem() {
    BigCard()
  }
  .rowStart(0)
  .columnStart(0)
  .rowEnd(1)
  .columnEnd(1)
  
  // 后面的:普通格子
  ForEach(this.otherEntries, (entry) => {
    GridItem() {
      SmallCard({ entry: entry })
    }
  }, e => e.id)
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')

💡 布局是为内容服务的
不要为了炫技而用复杂的布局。布局的目的是让用户更快找到想要的功能。什么布局最清晰、最好用,就用什么。大多数情况下,简单的 3-4 列宫格就够了。


6.5 「民族图鉴」8个快捷入口的设计思路

最后,我们来聊聊「民族图鉴」的 8 个快捷入口是怎么设计出来的。

6.5.1 功能选型:为什么是这 8 个?

我们选功能的标准有三个:

  1. 有代表性:能代表民族文化的不同方面
  2. 有趣味性:用户愿意点进去玩
  3. 可实现:开发难度适中,不会太复杂

基于这三个标准,我们选出了 8 个功能:

功能 为什么选它 定位
🧠 灵魂测试 趣味性强,容易传播,拉新效果好 引流功能
🏛️ 民族图鉴 核心功能,App 的基础 核心功能
🌍 民族分布 可视化,直观,有教育意义 特色功能
🎵 民族音乐 听觉体验,丰富内容形式 内容功能
🤖 AI助手 科技感,互动性强,有话题性 亮点功能
🖼️ 图片工具 实用性强,用户有需求 工具功能
📚 文化学堂 深度内容,提升 App 价值 学习功能
🎮 知识问答 游戏化,提升留存和活跃 留存功能

可以看到,这 8 个功能覆盖了不同的类型:有趣的、有用的、有深度的、有科技感的…… 搭配起来,用户总能找到自己感兴趣的。

6.5.2 排序逻辑:为什么是这个顺序?

排序是综合考虑了三个因素:

  1. 产品目标:灵魂测试是我们的特色和引流点,放第一个
  2. 核心功能:民族图鉴是 App 的基础,放第二个
  3. 类型搭配:不同类型的功能穿插排列,不要把同类型的放一起

你可以看到:

  • 第1个:趣味型(灵魂测试)
  • 第2个:核心型(民族图鉴)
  • 第3个:特色型(民族分布)
  • 第4个:内容型(民族音乐)
  • 第5个:亮点型(AI助手)
  • 第6个:工具型(图片工具)
  • 第7个:学习型(文化学堂)
  • 第8个:留存型(知识问答)

这样排列,用户从上到下扫一遍,能感受到 App 的丰富性——既有好玩的,又有用的,还有能学习的。

6.5.3 未来演进:入口会怎么变?

随着产品迭代,入口肯定会变。可能的演进方向:

1. 入口数量增加

  • 从 8 个增加到 12 个
  • 增加"更多"按钮,第二页放不常用的

2. 个性化排序

  • 根据用户的使用习惯,自动调整入口顺序
  • 常用的功能放前面,不常用的放后面

3. 可编辑入口

  • 让用户自己选择哪些功能放在首页
  • 用户可以自定义排序

但那都是以后的事了。现在我们只需要把这 8 个功能做好——把最核心的体验做到极致,比堆一堆功能有用多了。

💡 产品设计的哲学
少即是多。一个 App 只要有一个功能让用户觉得"哇,这个好",就成功了。功能再多,没有一个突出的,用户转头就忘了。「民族图鉴」现阶段的目标,就是把"民族图鉴"这个核心功能做到极致,其他功能都是锦上添花。


📝 本章小结

核心知识点

本文深入讲解了 Grid 网格布局和功能入口的设计:

1. Grid 基础

  • 核心概念:行、列、单元格
  • columnsTemplate / rowsTemplate:定义行列布局
  • fr 单位:比例分配空间,和 Flex 的 layoutWeight 类似
  • rowsGap / columnsGap:行列间距

2. 入口卡片设计

  • 结构:圆形图标 + 文字标签
  • 图标 44vp 最小触控目标,整个卡片可点击
  • 颜色搭配:功能语义、饱和度适中、明度接近
  • 卡片化设计:白色圆角背景,有边框,有层次

3. 数据驱动

  • 把入口数据和布局代码分离
  • 用 ForEach 渲染 GridItem
  • 好处:好维护、好扩展、好测试、好复用

4. Grid 进阶:不规则网格

  • columnStart / columnEnd:跨列
  • rowStart / rowEnd:跨行
  • 可以做各种复杂的布局:Banner 布局、侧边栏布局、瀑布流

5. 响应式网格

  • 根据屏幕宽度动态调整列数
  • 断点设计:手机 3 列、平板 4 列、大屏 6 列
  • 一套代码适配多种设备

6. Grid vs Row/Column 嵌套

  • 整齐的少量网格:都可以,Row/Column 更直观
  • 大量网格:Grid 更专业,性能更好
  • 不规则网格:Grid 完胜,Row/Column 写起来太麻烦

最佳实践总结

功能入口用 Grid,不要用 Row/Column 嵌套

// ✅ Grid:简洁、可扩展
Grid() {
  ForEach(entries, entry => {
    GridItem() { EntryCard({ entry }) }
  }, e => e.id)
}
.columnsTemplate('1fr 1fr 1fr')

图标 44vp,卡片 96vp,保证点击区域

// 图标 44vp(最小触控目标)
Text(icon).width(44).height(44).borderRadius(22)

// 整个卡片更高更大,好点
Column() { /* 图标 + 文字 */ }
.height(96)
.onClick(() => { ... })

颜色要有语义,饱和度明度统一

每个功能一个颜色,但风格统一
不要有的特别艳有的特别灰
不要超过 5-6 种主色,不然乱

数据和视图分离,用配置驱动

// 数据在配置文件里
export const QUICK_ENTRIES = [
  { id: 'xxx', icon: '...', label: '...', ... },
  // ...
];

// 视图只管渲染
ForEach(QUICK_ENTRIES, entry => <GridItem>...</GridItem>)

响应式适配:手机3列、平板4列、大屏6列

// 根据屏幕宽度动态算列数
if (width > 840) columns = 6;
else if (width > 600) columns = 4;
else columns = 3;

二八定律:核心功能放首页,更多藏二级

首页放最常用的 5-6 个
更多功能放"全部"二级页面
不要把首页堆得满满当当

下一篇预告

首页的内容就全部讲完了——搜索框、冷知识、精选民族、快捷入口,一个完整的首页就搭好了。

接下来,我们进入第二个 Tab —— 百科页。

下一篇(第17篇)我们将讲解民族列表页——分类筛选与字母索引

  • 列表页的整体架构
  • 分类筛选 Tab 的实现
  • 字母索引(A-Z 快速定位)
  • 列表头部吸顶效果

列表页是内容型 App 最核心的页面之一,也是交互最复杂的页面之一。


🔗 相关链接


💡 提示:功能入口网格看起来简单——不就是几个图标排排坐吗?但真要做好,学问不少。图标选什么?颜色怎么配?排序怎么定?哪些放首页、哪些藏起来?这些都不是技术问题,是产品问题。技术只是实现手段,真正决定体验的,是背后的产品思考。作为开发者,不要只想着"把图画出来就行了",多想想"为什么这么设计"、“有没有更好的方式”。带着产品思维写代码,你成长得会更快。

Logo

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

更多推荐