在HarmonyOS应用开发中,Grid组件作为强大的网格布局容器,广泛应用于商品展示、菜单导航、图片墙等场景。然而,当需要在网格布局中实现类似"二级菜单"的展开收起功能时,开发者常面临布局动态调整的挑战。本文将深入解析如何利用GridLayoutOptions参数,实现Grid容器中二级子GridItem的智能展开与收起功能。

一、需求场景与核心挑战

1.1 典型应用场景

场景类型

具体需求

交互特点

电商分类

商品分类网格,点击主分类展开子分类

需要动态插入子项,不破坏原有布局

功能菜单

设置菜单网格,点击主项展开详细选项

展开内容需要与主项视觉关联

内容导航

知识库目录网格,展开章节查看子节

需要保持网格的整体对齐性

1.2 技术挑战分析

  1. 布局动态性:Grid的布局在初始化时确定,动态添加/移除子项会破坏原有布局结构

  2. 空间占用:展开的二级内容需要占用额外网格空间,可能影响其他项的布局

  3. 动画流畅性:展开收起过程需要平滑的过渡动画,避免视觉跳跃

  4. 状态管理:需要精确管理每个GridItem的展开状态和位置信息

二、核心原理:GridLayoutOptions参数解析

2.1 GridLayoutOptions结构

GridLayoutOptions是控制Grid布局行为的核心配置对象,主要包含以下关键参数:

interface GridLayoutOptions {
  // 规则大小:所有GridItem默认占用的行列数
  regularSize?: [number, number];
  
  // 不规则索引:指定哪些GridItem采用不规则大小
  irregularIndexes?: Array<number>;
  
  // 自定义不规则大小回调(可选)
  onGetIrregularSizeByIndex?: (index: number) => [number, number];
}

2.2 参数详细说明

2.2.1 regularSize - 规则大小配置
  • 作用:定义大多数GridItem的默认占用空间

  • 限制:当前版本只支持[1, 1],即每个GridItem默认占用1行1列

  • 意义:确保网格布局的基础对齐性和一致性

2.2.2 irregularIndexes - 不规则索引
  • 作用:指定哪些GridItem需要采用特殊大小

  • 默认行为:当不设置onGetIrregularSizeByIndex时,irregularIndexes中的GridItem默认:

    • 垂直滚动Grid:占用一整行

    • 水平滚动Grid:占用一整列

  • 应用场景:实现展开效果的关键,让特定GridItem可以跨越多行/列

2.2.3 onGetIrregularSizeByIndex - 自定义大小回调
  • 作用:为每个不规则GridItem动态计算占用大小

  • 参数:GridItem的索引

  • 返回值[行数, 列数]数组

  • 灵活性:允许根据业务逻辑动态调整每个项的大小

三、实现方案设计

3.1 整体架构思路

实现Grid二级展开收起功能的核心思路是:通过动态管理irregularIndexes和对应的数据源,控制特定GridItem的占用空间

// 架构示意图
class ExpandableGridManager {
  // 数据源:包含主项和可能的子项
  private dataSource: GridItemData[] = [];
  
  // 展开状态:记录哪些主项处于展开状态
  private expandedStates: Map<number, boolean> = new Map();
  
  // 不规则索引:动态计算哪些项需要特殊大小
  private irregularIndexes: number[] = [];
  
  // 布局选项:动态更新的GridLayoutOptions
  private layoutOptions: GridLayoutOptions = {
    regularSize: [1, 1],
    irregularIndexes: this.irregularIndexes
  };
}

3.2 关键实现步骤

步骤1:数据结构设计
// Grid项数据类型定义
interface GridItemData {
  id: string;           // 唯一标识
  type: 'primary' | 'secondary';  // 类型:主项或二级子项
  parentId?: string;    // 二级子项的父项ID
  content: any;         // 显示内容
  isExpanded?: boolean; // 是否展开(仅主项)
}

// 示例数据结构
const sampleData: GridItemData[] = [
  { id: '1', type: 'primary', content: '分类A', isExpanded: false },
  { id: '2', type: 'primary', content: '分类B', isExpanded: false },
  { id: '3', type: 'primary', content: '分类C', isExpanded: false },
  // 二级子项(初始不显示)
  { id: '1-1', type: 'secondary', parentId: '1', content: '子项A1' },
  { id: '1-2', type: 'secondary', parentId: '1', content: '子项A2' },
  { id: '2-1', type: 'secondary', parentId: '2', content: '子项B1' }
];
步骤2:展开收起逻辑
// 展开主项
expandPrimaryItem(itemId: string): void {
  // 1. 更新主项展开状态
  const primaryIndex = this.dataSource.findIndex(item => 
    item.id === itemId && item.type === 'primary'
  );
  
  if (primaryIndex !== -1) {
    this.dataSource[primaryIndex].isExpanded = true;
    
    // 2. 查找并插入二级子项
    const secondaryItems = this.getAllSecondaryItems().filter(
      item => item.parentId === itemId
    );
    
    // 3. 在主项后插入二级子项
    const insertIndex = primaryIndex + 1;
    this.dataSource.splice(insertIndex, 0, ...secondaryItems);
    
    // 4. 更新irregularIndexes
    this.updateIrregularIndexes();
  }
}

// 收起主项
collapsePrimaryItem(itemId: string): void {
  // 1. 更新主项展开状态
  const primaryIndex = this.dataSource.findIndex(item => 
    item.id === itemId && item.type === 'primary'
  );
  
  if (primaryIndex !== -1) {
    this.dataSource[primaryIndex].isExpanded = false;
    
    // 2. 移除二级子项
    let itemsToRemove = 0;
    for (let i = primaryIndex + 1; i < this.dataSource.length; i++) {
      if (this.dataSource[i].type === 'secondary' && 
          this.dataSource[i].parentId === itemId) {
        itemsToRemove++;
      } else {
        break;
      }
    }
    
    this.dataSource.splice(primaryIndex + 1, itemsToRemove);
    
    // 3. 更新irregularIndexes
    this.updateIrregularIndexes();
  }
}
步骤3:不规则索引计算
// 更新不规则索引
updateIrregularIndexes(): void {
  this.irregularIndexes = [];
  
  // 遍历数据源,找出需要特殊处理的项
  this.dataSource.forEach((item, index) => {
    if (item.type === 'primary' && item.isExpanded) {
      // 展开的主项需要占用多行
      this.irregularIndexes.push(index);
    }
  });
  
  // 更新布局选项
  this.layoutOptions = {
    ...this.layoutOptions,
    irregularIndexes: this.irregularIndexes
  };
}

四、完整代码实现

4.1 CollapseMenu.ets - 可折叠菜单组件

// CollapseMenu.ets
@Component
export struct CollapseMenu {
  // 数据源
  @State private menuData: GridItemData[] = [];
  
  // 布局选项
  @State private layoutOptions: GridLayoutOptions = {
    regularSize: [1, 1],
    irregularIndexes: []
  };
  
  // 列数配置
  @Prop columnsTemplate: string = '1fr 1fr 1fr';
  
  // 初始化数据
  aboutToAppear(): void {
    this.initializeMenuData();
  }
  
  build() {
    Grid() {
      // 动态生成GridItem
      ForEach(this.menuData, (item: GridItemData, index: number) => {
        GridItem() {
          this.buildGridItemContent(item, index);
        }
        // 应用布局选项
        .layoutOptions(this.getLayoutOptionsForIndex(index))
      })
    }
    .columnsTemplate(this.columnsTemplate)
    .rowsTemplate('1fr')
    .columnsGap(12)
    .rowsGap(12)
    .layoutOptions(this.layoutOptions)
    .onClickIndex((index: number) => {
      this.handleItemClick(index);
    })
  }
  
  // 构建GridItem内容
  @Builder
  buildGridItemContent(item: GridItemData, index: number) {
    Column({ space: 8 }) {
      // 主项内容
      if (item.type === 'primary') {
        this.buildPrimaryItem(item, index);
      } 
      // 二级子项内容
      else if (item.type === 'secondary') {
        this.buildSecondaryItem(item);
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor(this.getItemBackgroundColor(item.type))
    .borderRadius(8)
    .shadow({ radius: 4, color: Color.Black, offsetX: 0, offsetY: 2 })
  }
  
  // 构建主项
  @Builder
  buildPrimaryItem(item: GridItemData, index: number) {
    Row({ space: 8 }) {
      // 图标
      Image($r('app.media.category_icon'))
        .width(24)
        .height(24)
      
      // 标题
      Text(item.content)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#1890FF')
        .layoutWeight(1)
      
      // 展开/收起指示器
      Image($r(item.isExpanded ? 
        'app.media.arrow_up' : 'app.media.arrow_down'))
        .width(16)
        .height(16)
    }
    .width('100%')
  }
  
  // 构建二级子项
  @Builder
  buildSecondaryItem(item: GridItemData) {
    Row({ space: 8 }) {
      // 缩进标识
      Blank()
        .width(24)
        .height(24)
      
      // 子项内容
      Text(item.content)
        .fontSize(14)
        .fontColor('#666666')
        .layoutWeight(1)
    }
    .width('100%')
  }
  
  // 获取项背景色
  private getItemBackgroundColor(type: string): ResourceColor {
    switch (type) {
      case 'primary': return '#FFFFFF';
      case 'secondary': return '#F5F5F5';
      default: return '#FFFFFF';
    }
  }
  
  // 获取特定索引的布局选项
  private getLayoutOptionsForIndex(index: number): GridLayoutOptions {
    const item = this.menuData[index];
    
    if (item.type === 'primary' && item.isExpanded) {
      // 展开的主项:根据子项数量计算需要占用的行数
      const childCount = this.getChildCount(item.id);
      const rowsNeeded = Math.ceil((childCount + 1) / 3); // +1 包含主项自身
      
      return {
        regularSize: [1, 1],
        irregularIndexes: [index],
        onGetIrregularSizeByIndex: (idx: number) => {
          return idx === index ? [rowsNeeded, 1] : [1, 1];
        }
      };
    }
    
    // 默认布局
    return { regularSize: [1, 1] };
  }
  
  // 处理项点击
  private handleItemClick(index: number): void {
    const item = this.menuData[index];
    
    if (item.type === 'primary') {
      if (item.isExpanded) {
        this.collapseItem(item.id);
      } else {
        this.expandItem(item.id);
      }
    }
  }
  
  // 展开项
  private expandItem(itemId: string): void {
    // 查找主项索引
    const primaryIndex = this.menuData.findIndex(
      item => item.id === itemId && item.type === 'primary'
    );
    
    if (primaryIndex === -1) return;
    
    // 更新展开状态
    this.menuData[primaryIndex].isExpanded = true;
    
    // 获取二级子项
    const secondaryItems = this.getSecondaryItemsForParent(itemId);
    
    // 插入二级子项
    const insertIndex = primaryIndex + 1;
    const newItems = [...this.menuData];
    newItems.splice(insertIndex, 0, ...secondaryItems);
    
    // 更新数据源
    this.menuData = newItems;
    
    // 更新布局选项
    this.updateLayoutOptions();
  }
  
  // 收起项
  private collapseItem(itemId: string): void {
    // 查找主项索引
    const primaryIndex = this.menuData.findIndex(
      item => item.id === itemId && item.type === 'primary'
    );
    
    if (primaryIndex === -1) return;
    
    // 更新展开状态
    this.menuData[primaryIndex].isExpanded = false;
    
    // 移除二级子项
    const newItems = [...this.menuData];
    let itemsToRemove = 0;
    
    for (let i = primaryIndex + 1; i < newItems.length; i++) {
      if (newItems[i].type === 'secondary' && 
          newItems[i].parentId === itemId) {
        itemsToRemove++;
      } else {
        break;
      }
    }
    
    newItems.splice(primaryIndex + 1, itemsToRemove);
    
    // 更新数据源
    this.menuData = newItems;
    
    // 更新布局选项
    this.updateLayoutOptions();
  }
  
  // 获取父项的子项数量
  private getChildCount(parentId: string): number {
    return this.menuData.filter(
      item => item.type === 'secondary' && item.parentId === parentId
    ).length;
  }
  
  // 获取父项的二级子项
  private getSecondaryItemsForParent(parentId: string): GridItemData[] {
    // 这里应该从数据源或API获取
    // 示例数据
    const sampleSecondaryItems: Record<string, GridItemData[]> = {
      '1': [
        { id: '1-1', type: 'secondary', parentId: '1', content: '子分类A1' },
        { id: '1-2', type: 'secondary', parentId: '1', content: '子分类A2' },
        { id: '1-3', type: 'secondary', parentId: '1', content: '子分类A3' }
      ],
      '2': [
        { id: '2-1', type: 'secondary', parentId: '2', content: '子分类B1' },
        { id: '2-2', type: 'secondary', parentId: '2', content: '子分类B2' }
      ]
    };
    
    return sampleSecondaryItems[parentId] || [];
  }
  
  // 更新布局选项
  private updateLayoutOptions(): void {
    // 收集所有展开的主项索引
    const expandedIndexes: number[] = [];
    
    this.menuData.forEach((item, index) => {
      if (item.type === 'primary' && item.isExpanded) {
        expandedIndexes.push(index);
      }
    });
    
    // 更新布局选项
    this.layoutOptions = {
      regularSize: [1, 1],
      irregularIndexes: expandedIndexes,
      onGetIrregularSizeByIndex: (index: number) => {
        if (expandedIndexes.includes(index)) {
          const item = this.menuData[index];
          const childCount = this.getChildCount(item.id);
          // 计算需要的行数(考虑列数)
          const rowsNeeded = Math.ceil((childCount + 1) / 3);
          return [rowsNeeded, 1];
        }
        return [1, 1];
      }
    };
  }
  
  // 初始化菜单数据
  private initializeMenuData(): void {
    this.menuData = [
      { id: '1', type: 'primary', content: '电子产品', isExpanded: false },
      { id: '2', type: 'primary', content: '家居用品', isExpanded: false },
      { id: '3', type: 'primary', content: '服装服饰', isExpanded: false },
      { id: '4', type: 'primary', content: '食品饮料', isExpanded: false },
      { id: '5', type: 'primary', content: '图书音像', isExpanded: false },
      { id: '6', type: 'primary', content: '运动户外', isExpanded: false }
    ];
  }
}

4.2 Index.ets - 主页面集成

// Index.ets
@Entry
@Component
struct Index {
  @State currentTab: string = 'categories';
  
  build() {
    Column({ space: 0 }) {
      // 顶部导航栏
      this.buildTopNavigation()
      
      // 内容区域
      Column({ space: 0 }) {
        // 标题
        Text('商品分类')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 20, bottom: 20 })
          .width('100%')
          .textAlign(TextAlign.Center)
        
        // 可折叠菜单
        CollapseMenu({
          columnsTemplate: '1fr 1fr 1fr'
        })
        .height('80%')
        .margin({ left: 16, right: 16 })
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F0F2F5')
    }
    .width('100%')
    .height('100%')
  }
  
  @Builder
  buildTopNavigation() {
    Row({ space: 0 }) {
      // 返回按钮
      Image($r('app.media.back_icon'))
        .width(24)
        .height(24)
        .margin({ left: 16 })
        .onClick(() => {
          // 返回逻辑
        })
      
      // 标题
      Text('网格布局演示')
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      
      // 设置按钮
      Image($r('app.media.settings_icon'))
        .width(24)
        .height(24)
        .margin({ right: 16 })
        .onClick(() => {
          // 设置逻辑
        })
    }
    .width('100%')
    .height(56)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 2, color: Color.Black, offsetX: 0, offsetY: 1 })
  }
}

五、高级优化技巧

5.1 动画效果增强

// 添加展开收起动画
private animateExpansion(itemId: string, expand: boolean): void {
  // 使用animateTo实现平滑过渡
  animateTo({
    duration: 300,
    curve: expand ? Curve.EaseOut : Curve.EaseIn
  }, () => {
    if (expand) {
      this.expandItem(itemId);
    } else {
      this.collapseItem(itemId);
    }
  });
}

5.2 性能优化策略

  1. 虚拟滚动支持:对于大量数据,实现虚拟滚动避免渲染所有GridItem

  2. 缓存机制:缓存已加载的二级数据,避免重复请求

  3. 懒加载:二级内容在需要时再加载,减少初始渲染压力

5.3 响应式布局适配

// 根据屏幕宽度动态调整列数
@Watch('windowSize')
onWindowSizeChange() {
  const screenWidth = window.getWindowWidth();
  
  if (screenWidth >= 1200) {
    this.columnsTemplate = '1fr 1fr 1fr 1fr';
  } else if (screenWidth >= 768) {
    this.columnsTemplate = '1fr 1fr 1fr';
  } else {
    this.columnsTemplate = '1fr 1fr';
  }
}

六、常见问题与解决方案

6.1 布局错乱问题

问题现象:展开二级内容后,其他GridItem位置错乱

解决方案

  1. 确保irregularIndexes只包含展开的主项索引

  2. onGetIrregularSizeByIndex中准确计算所需行数

  3. 使用GridLayoutOptionsregularSize保持基础对齐

6.2 动画卡顿问题

问题现象:展开收起过程中动画不流畅

解决方案

  1. 减少同时展开的项数量

  2. 使用animateTo替代直接状态更新

  3. 优化二级内容的渲染复杂度

6.3 数据同步问题

问题现象:展开状态与数据源不同步

解决方案

  1. 使用单一数据源管理所有状态

  2. 实现状态回滚机制

  3. 添加数据验证和错误处理

七、总结与最佳实践

通过本文的深入解析,我们掌握了利用GridLayoutOptions实现Grid容器二级子项展开收起功能的核心技术。关键要点总结如下:

  1. 理解参数机制regularSize定义基础布局,irregularIndexes控制特殊项

  2. 动态数据管理:通过数据源的动态增删实现展开收起效果

  3. 精确布局计算:根据子项数量准确计算所需网格空间

  4. 用户体验优化:添加平滑动画和响应式布局支持

在实际开发中,建议遵循以下最佳实践:

  • 渐进式展开:避免同时展开过多项,影响性能

  • 状态持久化:保存用户的展开状态,提升体验

  • 边界情况处理:考虑空状态、网络错误等场景

  • 可访问性支持:确保键盘导航和屏幕阅读器兼容

随着HarmonyOS技术的不断发展,Grid组件的布局能力将持续增强。掌握本文介绍的技术方案,开发者可以灵活应对各种复杂的网格布局需求,打造出既美观又实用的HarmonyOS应用界面。

Logo

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

更多推荐