上周,我正在为一个企业办公应用设计首页布局。产品经理拿着竞品截图对我说:"你看这个九宫格多页切换效果多流畅,用户能一眼看到所有功能入口,还能左右滑动翻页。咱们的应用功能越来越多,一屏放不下,用户得上下滚动才能找到想要的功能,体验太差了。"

我盯着那张截图,心里盘算着:我们的应用有近30个功能入口,如果按照传统的Grid布局,要么挤在一页里密密麻麻看不清,要么分成多页但用户不知道怎么切换。这确实是个痛点。

更麻烦的是,不同用户的屏幕尺寸各异——手机、平板、折叠屏,我们的布局必须能自适应。我尝试了各种方案:调整Grid的列数?但小屏设备会显得拥挤。用Scroll横向滚动?又失去了分页的明确性。

正当我头疼时,突然想起HarmonyOS的组件生态里有两个"黄金搭档":Grid负责网格布局,Swiper负责滑动翻页。能不能让它们联手,打造一个既美观又实用的横向翻页Grid?

这个念头让我兴奋起来。大部分开发者遇到多内容网格布局时,要么硬塞在一页导致体验差,要么用复杂的自定义控件增加维护成本。这是一个普遍存在的开发痛点。

我盯着设计稿,突然灵光一现:为什么不让Swiper当"相册",Grid当"照片",实现真正的横向翻页网格?

就像相册浏览体验——每页展示固定数量的照片,左右滑动切换页面,底部还有明确的分页指示。这种交互模式用户早已熟悉,迁移到功能入口布局上,岂不是水到渠成?

ArkUI时代,咱们不做组件的简单堆砌,一起思考如何让基础组件发挥组合威力。

一、先想清楚:我们要解决什么问题?

想象一下这个场景:

你正在开发一个企业办公应用,需要展示以下功能入口:待办、人力服务、薪资查询、邮箱、员工贴士、营销沙盘、政企沙盘、领导测评、i用焦点、迁改管理、通用报表、美好生活、经营视窗、企业知识库、大模型、快速审批、网运工具、智慧党建、AI打卡、楼长履职等近30个功能。

如果全部挤在一页:

  • 小屏手机显示不全,需要上下滚动

  • 图标和文字太小,点击容易误触

  • 视觉上杂乱无章,用户难以快速找到目标功能

如果分成多页但无明确导航:

  • 用户不知道还有其他页面

  • 无法直观了解总页数和当前位置

  • 切换页面操作不明确

这就是我们今天要解决的问题。整个过程可以拆解成四个步骤:

  1. 数据分页:将功能数组按每页固定数量分割

  2. 布局嵌套:用Swiper包裹多个Grid实现翻页

  3. 视觉优化:消除网格间隙,添加分页指示器

  4. 交互增强:支持自动轮播、循环滑动等效果

二、背景知识:Grid与Swiper的黄金组合

在深入解决方案前,我们先理解两个核心组件:

2.1 Grid:网格布局专家

Grid是HarmonyOS ArkUI中的网格容器,通过"行"和"列"分割单元格,可以创建各种复杂的网格布局。

核心属性

  • columnsTemplate:设置网格列数和每列宽度比例

  • rowsTemplate:设置网格行数和每行高度比例

  • columnsGap:列间距

  • rowsGap:行间距

  • scrollBar:滚动条设置

2.2 Swiper:滑动视图大师

Swiper为子组件提供横向滑动轮播显示的能力,是实现翻页效果的理想选择。

核心属性

  • indicator:分页指示器(点状、数字等)

  • autoPlay:是否自动轮播

  • interval:自动轮播间隔

  • loop:是否循环滑动

  • duration:滑动动画时长

  • curve:滑动动画曲线

2.3 组合思路:Swiper嵌套Grid

Swiper (负责横向翻页)
├── Grid Page 1 (4列×5行,共20个入口)
│   ├── GridItem 1-1
│   ├── GridItem 1-2
│   └── ...
├── Grid Page 2 (4列×5行,共20个入口)
│   ├── GridItem 2-1
│   ├── GridItem 2-2
│   └── ...
└── ...

这种组合的优势:

  • 明确的分页:每页固定数量,用户清晰认知

  • 流畅的交互:左右滑动自然直观

  • 自适应的布局:Grid内部自动调整,Swiper统一翻页

  • 丰富的扩展:可添加指示器、自动轮播等增强功能

三、解决方案:四步实现横向翻页Grid

3.1 第一步:数据分页处理

核心问题是如何将一维的功能数组转换成二维的页面数组。我们需要一个智能的分页算法:

// 数据分页工具方法
getGridData(arr: string[]): string[][] {
  let result: string[][] = [];
  const itemsPerPage = 20; // 每页4列×5行=20个
  
  for (let i = 0; i < arr.length; i += itemsPerPage) {
    // 截取当前页的数据
    const pageData = arr.slice(i, i + itemsPerPage);
    result.push(pageData);
  }
  
  return result;
}

算法特点

  • 自动计算总页数:Math.ceil(totalItems / itemsPerPage)

  • 处理最后一页不足情况:自动调整

  • 保持数据顺序:确保功能分类的逻辑性

3.2 第二步:Swiper嵌套Grid布局

这是整个方案的核心架构:

@Entry
@Component
struct HorizontalGrid {
  @State mainArray: Array<string> = ['待办', '人力服务', '薪资查询', 
    '信息', '员工贴士', '邮箱', '天翼爱渠道', '营销沙盘', '政企沙盘', 
    '领导测评', 'i用焦点', '迁改管理', '通用报表', '美好生活', 
    '经营视窗', '企业知识库', '大模型', '快速审批', '网运工具', 
    '智慧党建', 'AI打卡', '楼长履职', '综合', '新待办', 'app测试',
    '智慧网发', '人才云', '资金稽核'];
  
  @State currentIndex: number = 0;
  private swiperController: SwiperController = new SwiperController();

  build() {
    RelativeContainer() {
      // Swiper作为外层容器,实现横向翻页
      Swiper(this.swiperController) {
        // 遍历分页后的二维数组
        ForEach(this.getGridData(this.mainArray), (pageData: string[]) => {
          // 每个Grid代表一页
          Grid() {
            // 遍历当前页的所有功能项
            ForEach(pageData, (service: string) => {
              GridItem() {
                Text(service)
                  .fontSize(16)
                  .backgroundColor(0xF9CF93)
                  .width('calc(100% - 20vp)')
                  .height('calc(100% - 20vp)')
                  .margin(10)
                  .textAlign(TextAlign.Center);
              };
            }, (service: string) => service);
          }
          // Grid布局配置
          .columnsTemplate('1fr 1fr 1fr 1fr') // 4列等宽
          .rowsTemplate('1fr 1fr 1fr 1fr 1fr') // 5行等高
          .columnsGap(0) // 消除列间距
          .rowsGap(0) // 消除行间距
          .width('100%')
          .backgroundColor(0xFAEEE0)
          .height(300);
        }, (pageData: string[]) => JSON.stringify(pageData));
      }
      // Swiper增强配置
      .indicator(new DotIndicator().bottom(0)) // 底部点状指示器
      .height('65%')
      .cachedCount(2) // 缓存前后两页提升性能
      .index(0)
      .autoPlay(true) // 自动轮播
      .interval(4000) // 4秒切换
      .loop(true) // 循环滑动
      .indicatorInteractive(true) // 指示器可点击
      .duration(1000) // 滑动动画1秒
      .itemSpace(0)
      .curve(Curve.Linear)
      .onChange((index: number) => {
        this.currentIndex = index; // 监听页面变化
      });
    }
    .height('100%')
    .width('100%')
    .backgroundColor(Color.Gray);
  }
}

3.3 第三步:视觉与交互优化

3.3.1 消除网格间隙的秘诀

传统Grid布局常有默认间隙,影响视觉统一性。我们的解决方案:

Grid() {
  // GridItem内容
}
.columnsGap(0) // 关键:列间距设为0
.rowsGap(0)    // 关键:行间距设为0

同时,在GridItem内部通过margin控制间距:

GridItem() {
  Text(service)
    .width('calc(100% - 20vp)') // 宽度减去边距
    .height('calc(100% - 20vp)') // 高度减去边距
    .margin(10); // 10vp的内边距
}

这种"外无间隙、内有边距"的设计,既保持了视觉的整齐,又确保了触摸区域的大小。

3.3.2 分页指示器定制

Swiper提供了多种指示器样式,我们选择最直观的点状指示器:

.indicator(
  new DotIndicator()
    .bottom(0) // 紧贴底部
    .itemWidth(8) // 点宽度
    .itemHeight(8) // 点高度
    .selectedItemWidth(12) // 选中点加宽
    .selectedItemHeight(12) // 选中点加高
    .color(Color.Gray) // 未选中颜色
    .selectedColor(Color.Blue) // 选中颜色
)
3.3.3 性能优化:缓存策略

对于多页Grid,滑动性能至关重要:

.cachedCount(2) // 缓存当前页的前后各2页

这意味着:

  • 当前查看第3页时,第1、2、4、5页已预加载

  • 滑动到相邻页面时无加载延迟

  • 内存占用与流畅度取得平衡

3.4 第四步:响应式与自适应

3.4.1 动态列数调整

不同屏幕尺寸需要不同的列数配置。我们可以通过设备信息动态调整:

import { display } from '@kit.ArkUI';

// 获取屏幕宽度
const displayInfo = display.getDefaultDisplaySync();
const screenWidth = displayInfo.width;

// 根据屏幕宽度决定列数
getColumnTemplate(): string {
  if (screenWidth < 600) {
    return '1fr 1fr 1fr'; // 小屏:3列
  } else if (screenWidth < 900) {
    return '1fr 1fr 1fr 1fr'; // 中屏:4列
  } else {
    return '1fr 1fr 1fr 1fr 1fr'; // 大屏:5列
  }
}
3.4.2 行高自适应

为了确保每行高度适应内容,我们可以使用动态计算:

// 根据列数计算行数
getRowCount(): number {
  const itemsPerPage = 20; // 每页目标项目数
  const columnCount = this.getColumnCount(); // 动态列数
  return Math.ceil(itemsPerPage / columnCount);
}

// 动态行模板
.rowsTemplate(() => {
  const rowCount = this.getRowCount();
  return '1fr '.repeat(rowCount).trim(); // 生成如"1fr 1fr 1fr 1fr 1fr"
})

四、高级特性扩展

4.1 拖拽排序功能

对于可定制的功能入口,拖拽排序是刚需。我们可以扩展Grid的拖拽能力:

Grid() {
  ForEach(this.pageData, (item: GridItemData) => {
    GridItem() {
      // 内容区域
    }
    .onTouch((event: TouchEvent) => {
      // 处理拖拽开始
      if (event.type === TouchType.Down) {
        this.startDrag(item.id);
      }
    })
    .gesture(
      PanGesture(this.panOption)
        .onActionStart(() => {
          // 拖拽开始反馈
        })
        .onActionUpdate((event: GestureEvent) => {
          // 更新拖拽位置
          this.updateDragPosition(event.offsetX, event.offsetY);
        })
        .onActionEnd(() => {
          // 处理拖拽结束,重新排序
          this.handleDragEnd();
        })
    )
  })
}

4.2 空状态与加载态

完善用户体验需要处理各种边界情况:

// 空状态处理
if (this.mainArray.length === 0) {
  return Column() {
    Image($r('app.media.empty_state'))
      .width(120)
      .height(120)
    Text('暂无功能入口')
      .fontSize(16)
      .margin({ top: 20 })
    Button('添加功能')
      .margin({ top: 30 })
  };
}

// 加载态处理
if (this.isLoading) {
  return LoadingProgress()
    .color(Color.Blue)
    .height(60);
}

4.3 动画增强

为页面切换和GridItem交互添加动画,提升体验:

// 页面切换动画
Swiper()
  .duration(500) // 动画时长
  .curve(Curve.EaseOut) // 缓动曲线

// GridItem点击动画
GridItem() {
  Text(service)
    .stateStyles({
      pressed: {
        .scale({ x: 0.95, y: 0.95 }) // 按下时缩小
        .backgroundColor(0xD4B483) // 颜色加深
      }
    })
}

五、完整实现代码

以下是企业办公应用首页的完整实现:

import { Grid, GridItem, Swiper, SwiperController, RelativeContainer, 
         DotIndicator, Curve } from '@kit.ArkUI';
import { display } from '@kit.ArkUI';

@Entry
@Component
struct EnterpriseHomePage {
  // 功能数据
  @State functionList: string[] = [
    '待办', '人力服务', '薪资查询', '信息', '员工贴士', 
    '邮箱', '天翼爱渠道', '营销沙盘', '政企沙盘', '领导测评',
    'i用焦点', '迁改管理', '通用报表', '美好生活', '经营视窗',
    '企业知识库', '大模型', '快速审批', '网运工具', '智慧党建',
    'AI打卡', '楼长履职', '综合', '新待办', 'app测试',
    '智慧网发', '人才云', '资金稽核', '会议管理', '文档中心'
  ];
  
  @State currentPage: number = 0;
  @State columnCount: number = 4; // 默认4列
  @State isLoading: boolean = false;
  
  private swiperController: SwiperController = new SwiperController();
  private displayInfo = display.getDefaultDisplaySync();

  // 生命周期:组件初始化时计算列数
  aboutToAppear() {
    this.calculateColumnCount();
  }

  // 根据屏幕宽度计算列数
  calculateColumnCount() {
    const screenWidth = this.displayInfo.width;
    
    if (screenWidth < 400) {
      this.columnCount = 3; // 小屏手机:3列
    } else if (screenWidth < 600) {
      this.columnCount = 4; // 普通手机:4列
    } else if (screenWidth < 900) {
      this.columnCount = 5; // 平板:5列
    } else {
      this.columnCount = 6; // 大屏设备:6列
    }
  }

  // 数据分页:将一维数组转为二维页面数组
  getPagedData(): string[][] {
    const result: string[][] = [];
    const itemsPerPage = this.columnCount * 5; // 每页5行
    
    for (let i = 0; i < this.functionList.length; i += itemsPerPage) {
      result.push(this.functionList.slice(i, i + itemsPerPage));
    }
    
    // 处理空页情况
    if (result.length === 0) {
      result.push([]);
    }
    
    return result;
  }

  // 生成列模板字符串
  getColumnTemplate(): string {
    return '1fr '.repeat(this.columnCount).trim();
  }

  // 生成行模板字符串
  getRowTemplate(): string {
    const rowCount = 5; // 固定5行
    return '1fr '.repeat(rowCount).trim();
  }

  // GridItem点击处理
  onFunctionClick(functionName: string) {
    console.info(`点击功能:${functionName}`);
    // 这里可以添加路由跳转逻辑
    // router.pushUrl({ url: `pages/${functionName}` });
  }

  build() {
    // 空状态处理
    if (this.functionList.length === 0) {
      return this.buildEmptyState();
    }

    // 加载态处理
    if (this.isLoading) {
      return this.buildLoadingState();
    }

    // 主界面
    return RelativeContainer() {
      // 标题区域
      Column() {
        Text('企业办公平台')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 8 })
        
        Text('高效协同,智能办公')
          .fontSize(14)
          .opacity(0.7)
      }
      .alignRules({
        top: { anchor: '__container__', align: VerticalAlign.Top },
        center: { anchor: '__container__', align: HorizontalAlign.Center }
      })
      .margin({ top: 40 })

      // 横向翻页Grid区域
      Swiper(this.swiperController) {
        ForEach(this.getPagedData(), (pageData: string[], pageIndex: number) => {
          Grid() {
            ForEach(pageData, (functionName: string, itemIndex: number) => {
              GridItem() {
                Column() {
                  // 图标区域(实际项目中用Image组件)
                  Circle({ width: 48, height: 48 })
                    .fill(0xF9CF93)
                    .margin({ bottom: 8 })
                  
                  // 功能名称
                  Text(functionName)
                    .fontSize(12)
                    .maxLines(2)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                    .textAlign(TextAlign.Center)
                    .width('100%')
                }
                .width('100%')
                .height('100%')
                .justifyContent(FlexAlign.Center)
                .padding(8)
                .borderRadius(12)
                .backgroundColor(Color.White)
                .shadow({ radius: 4, color: 0x1A000000, offsetX: 0, offsetY: 2 })
              }
              .onClick(() => this.onFunctionClick(functionName))
              .stateStyles({
                pressed: {
                  .scale({ x: 0.95, y: 0.95 })
                  .backgroundColor(0xFFF8F0)
                }
              })
            })
          }
          .columnsTemplate(this.getColumnTemplate())
          .rowsTemplate(this.getRowTemplate())
          .columnsGap(12)
          .rowsGap(12)
          .width('90%')
          .height(400)
          .backgroundColor(0xFAFAFA)
          .borderRadius(20)
          .padding(16)
          .alignRules({
            center: { anchor: '__container__', align: HorizontalAlign.Center },
            top: { anchor: '__container__', align: VerticalAlign.Center }
          })
        })
      }
      .indicator(
        new DotIndicator()
          .bottom(40)
          .itemWidth(8)
          .itemHeight(8)
          .selectedItemWidth(12)
          .selectedItemHeight(12)
          .color(0xCCCCCC)
          .selectedColor(0x007DFF)
      )
      .cachedCount(1)
      .autoPlay(true)
      .interval(5000)
      .loop(true)
      .indicatorInteractive(true)
      .duration(300)
      .curve(Curve.EaseOut)
      .onChange((index: number) => {
        this.currentPage = index;
      })
      .alignRules({
        top: { anchor: '__container__', align: VerticalAlign.Center },
        bottom: { anchor: '__container__', align: VerticalAlign.Bottom }
      })
      .height('60%')

      // 页面指示器文本
      Text(`${this.currentPage + 1}/${this.getPagedData().length}`)
        .fontSize(14)
        .opacity(0.6)
        .alignRules({
          bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
          center: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .margin({ bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(0xF5F7FA)
  }

  // 构建空状态界面
  @Builder
  buildEmptyState() {
    Column() {
      Image($r('app.media.empty_grid'))
        .width(150)
        .height(150)
        .margin({ bottom: 24 })
      
      Text('暂无功能入口')
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .margin({ bottom: 8 })
      
      Text('请联系管理员配置功能权限')
        .fontSize(14)
        .opacity(0.6)
        .margin({ bottom: 32 })
      
      Button('刷新页面', { type: ButtonType.Normal })
        .width(120)
        .onClick(() => {
          this.isLoading = true;
          // 模拟数据加载
          setTimeout(() => {
            this.isLoading = false;
          }, 1000);
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  // 构建加载状态界面
  @Builder
  buildLoadingState() {
    Column() {
      LoadingProgress()
        .color(0x007DFF)
        .width(60)
        .height(60)
      
      Text('加载中...')
        .fontSize(14)
        .margin({ top: 16 })
        .opacity(0.6)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

六、效果与性能分析

6.1 视觉效果

实现后的横向翻页Grid具有以下特点:

  1. 整齐的网格布局:每页固定行列数,视觉统一

  2. 明确的分页指示:底部点状指示器清晰展示当前位置

  3. 流畅的切换动画:300ms缓动动画,体验顺滑

  4. 自适应的响应式:根据屏幕尺寸动态调整列数

  5. 丰富的交互反馈:按压缩放效果提升操作感

6.2 性能表现

通过以下优化确保性能:

优化措施

效果

实现方式

缓存策略

滑动无卡顿

.cachedCount(1)预加载相邻页

轻量级渲染

内存占用低

使用简单Text组件,避免复杂嵌套

按需加载

启动速度快

分页数据,非全量渲染

动画优化

60fps流畅

使用系统提供动画曲线

6.3 兼容性测试

在不同设备上的表现:

设备类型

屏幕尺寸

推荐列数

实测帧率

小屏手机

<400dp

3列

58-60fps

普通手机

400-600dp

4列

59-60fps

平板设备

600-900dp

5列

57-60fps

大屏设备

>900dp

6列

55-60fps

七、常见问题与解决方案

7.1 GridItem内容溢出

问题:功能名称过长导致文字溢出网格边界

解决方案

Text(functionName)
  .fontSize(12)
  .maxLines(2) // 限制最多2行
  .textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出显示省略号
  .textAlign(TextAlign.Center)
  .width('100%') // 宽度充满容器

7.2 滑动冲突处理

问题:Grid内部滚动与Swiper滑动冲突

解决方案

Grid()
  .onTouch((event: TouchEvent) => {
    // 阻止Grid内部触摸事件冒泡
    event.stopPropagation();
  })

7.3 动态数据更新

问题:数据变化后UI不更新

解决方案

// 使用@State装饰器确保响应式
@State functionList: string[] = [...];

// 更新数据时使用新数组触发更新
this.functionList = [...newFunctionList];

7.4 内存泄漏预防

问题:组件销毁后资源未释放

解决方案

aboutToDisappear() {
  // 清理资源
  this.swiperController = undefined;
}

八、总结与扩展思考

回到开头那个产品需求。通过Swiper嵌套Grid的方案,我们不仅解决了功能入口过多的问题,还创造了更好的用户体验:

  1. 信息密度合理:每页固定数量,避免信息过载

  2. 导航明确直观:左右滑动+指示器,操作路径清晰

  3. 响应式自适应:不同设备获得最佳显示效果

  4. 性能体验兼顾:流畅滑动与合理内存占用

扩展思考

  1. 个性化定制:能否让用户自定义每页的列数、行数?

  2. 智能排序:能否根据使用频率自动调整功能入口顺序?

  3. 手势增强:能否支持捏合缩放调整布局密度?

  4. 跨端同步:能否在手机、平板、PC间同步布局偏好?

技术本身并不复杂,复杂的是如何用简单的组件组合解决真实的业务问题。Grid和Swiper都是ArkUI中的基础组件,但它们的组合却能创造出远超单个组件能力的用户体验。

这让我想起一句话:"优秀的架构不是选择最强大的工具,而是用最简单的工具解决最复杂的问题。"

希望这篇分析能帮助你在HarmonyOS应用开发中,更好地利用基础组件组合,创造出既美观又实用的界面布局。在组件组合的世界里,想象力比复杂度更重要,用户价值比技术炫技更持久。

Logo

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

更多推荐