问题现象

在HarmonyOS应用开发中,开发者经常需要实现可折叠和展开的列表功能,这种交互模式在多种场景中都有广泛应用:

典型应用场景

  • 设置界面中,分类设置项的展开与收起

  • 文件管理器中,目录树的展开与收起

  • 聊天应用中,消息记录的折叠展示

  • 电商应用中,商品分类的层级展示

  • 新闻应用中,长评论的展开与收起

交互需求

  • 用户点击"展开"按钮,显示更多内容

  • 用户点击"收起"按钮,隐藏详细内容

  • 需要平滑的展开/收起动画效果

  • 支持多级嵌套的折叠结构

  • 保持列表滚动时的性能流畅

背景知识

在深入解决方案之前,我们先了解HarmonyOS中相关的组件特性:

List组件基础特性

List组件是HarmonyOS中用于展示连续、多行同类数据的核心组件:

  • 支持垂直滚动,适合长列表展示

  • 支持ListItem、ListItemGroup和自定义组件作为子项

  • 内置虚拟化技术,优化大数据量性能

  • 支持滚动事件监听和定制

TreeView组件特性

TreeView是专门为层级数据设计的组件:

  • 专门用于显示嵌套结构数据

  • 支持父节点和子节点的展开/折叠

  • 适用于效率型应用,如文件管理器、侧边导航栏

  • 内置展开/收起动画效果

折叠展开的核心原理

无论使用哪种组件,折叠展开的核心原理都是:

  1. 状态管理:记录每个列表项的展开状态

  2. 条件渲染:根据状态决定是否渲染子内容

  3. 动画过渡:在状态切换时添加平滑动画

  4. 布局计算:动态计算容器高度变化

解决方案

基于HarmonyOS的特性,我们提供两种实现方案:

方案一:使用TreeView组件(推荐)

TreeView是HarmonyOS官方提供的树形结构组件,专门为层级数据设计,提供了开箱即用的折叠展开功能。

// TreeView实现示例
import { TreeView, TreeNodeData } from '@ohos.arkui.treeview';

@Component
struct TreeViewExample {
  // 树形结构数据
  private treeData: TreeNodeData[] = [
    {
      value: '父节点1',
      isExpanded: false,
      children: [
        { value: '子节点1-1' },
        { value: '子节点1-2' }
      ]
    },
    {
      value: '父节点2',
      isExpanded: true,
      children: [
        { value: '子节点2-1' },
        { 
          value: '子节点2-2',
          children: [
            { value: '孙节点2-2-1' }
          ]
        }
      ]
    }
  ];

  build() {
    Column() {
      // 使用TreeView组件
      TreeView({
        data: this.treeData,
        itemTemplate: (item: TreeNodeData) => {
          // 自定义节点渲染
          return this.renderTreeNode(item);
        }
      })
      .width('100%')
      .height('100%')
    }
  }

  // 自定义树节点渲染
  @Builder
  renderTreeNode(item: TreeNodeData) {
    Row() {
      // 展开/收起图标
      if (item.children && item.children.length > 0) {
        Image($r('app.media.ic_arrow'))
          .width(20)
          .height(20)
          .rotate({ angle: item.isExpanded ? 90 : 0 })
          .animation({ duration: 200, curve: Curve.Ease })
      }
      
      Text(item.value)
        .fontSize(16)
        .margin({ left: 10 })
    }
    .width('100%')
    .padding(10)
  }
}

TreeView方案的优势

  • ✅ 内置展开/收起动画效果

  • ✅ 支持多级嵌套结构

  • ✅ 性能优化,大数据量表现良好

  • ✅ 代码简洁,易于维护

适用场景

  • 文件浏览器

  • 组织架构图

  • 多级分类菜单

  • 复杂的层级数据展示

方案二:使用List组件自定义实现

当需要更灵活的定制或项目已大量使用List组件时,可以使用List结合条件渲染来实现折叠展开功能。

// 完整实现代码
@Entry
@Component
struct ExpandableListExample {
  // 列表数据
  private dataItems: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
  
  // 记录每个列表项的展开状态
  @State isExpanded: boolean[] = new Array(this.dataItems.length).fill(false);
  
  // 获取UI上下文
  private uiContext: UIContext = getUIContext();
  
  build() {
    Column() {
      // 列表标题
      Text('可折叠列表示例')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 40, bottom: 20 });
      
      // 主列表容器
      List({ space: 10, initialIndex: 0 }) {
        ForEach(this.dataItems, (item: number, index: number) => {
          ListItem() {
            this.renderListItem(item, index)
          }
        }, (item: number) => item.toString())
      }
      .width('100%')
      .height('100%')
      .scrollBar(BarState.Auto)
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#F8F9FA')
  }
  
  // 构建列表项
  @Builder
  renderListItem(item: number, index: number) {
    Column({ space: 0 }) {
      // 顶部控制区域
      Row() {
        // 项目序号
        Text(`项目 ${item}`)
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')
        
        // 右侧占位,用于对齐按钮
        Blank()
        
        // 展开/收起按钮
        Button(this.isExpanded[index] ? '收起 ▲' : '展开 ▼')
          .fontSize(14)
          .fontColor('#0066CC')
          .backgroundColor('#FFFFFF')
          .borderRadius(20)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .border({ width: 1, color: '#0066CC' })
          .onClick(() => {
            this.toggleExpand(index);
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .justifyContent(FlexAlign.SpaceBetween)
      
      // 分隔线
      Divider()
        .color('#E0E0E0')
        .strokeWidth(1)
        .margin({ left: 16, right: 16 })
      
      // 可展开的内容区域
      if (this.isExpanded[index]) {
        this.renderExpandableContent(item, index)
      }
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 4, color: '#1A1A1A', offsetX: 0, offsetY: 2 })
  }
  
  // 构建可展开的内容
  @Builder
  renderExpandableContent(item: number, index: number) {
    Column({ space: 8 }) {
      // 内容标题
      Text('详细内容')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#333333')
        .margin({ top: 12, left: 16, right: 16 })
      
      // 内容正文
      Text('这是项目' + item + '的详细内容区域,可以放置任何需要展开显示的内容。这里可以包含文本、图片、按钮等任何组件。折叠展开功能让界面更加简洁,同时不丢失信息的完整性。')
        .fontSize(14)
        .fontColor('#666666')
        .lineHeight(20)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .maxLines(3)
        .margin({ left: 16, right: 16, bottom: 8 })
      
      // 内容操作按钮区域
      Row({ space: 12 }) {
        Button('操作1')
          .fontSize(12)
          .backgroundColor('#F0F7FF')
          .fontColor('#0066CC')
          .borderRadius(6)
        
        Button('操作2')
          .fontSize(12)
          .backgroundColor('#F0F7FF')
          .fontColor('#0066CC')
          .borderRadius(6)
      }
      .margin({ left: 16, right: 16, bottom: 16 })
      .justifyContent(FlexAlign.Start)
    }
    .width('100%')
  }
  
  // 切换展开状态(带动画)
  private toggleExpand(index: number): void {
    this.uiContext.animateTo({
      duration: 300,  // 动画持续时间300ms
      curve: Curve.EaseInOut,  // 缓动曲线
      onFinish: () => {
        // 动画完成回调
        console.info(`第${index}项的${this.isExpanded[index] ? '收起' : '展开'}动画完成`);
      }
    }, () => {
      // 在动画上下文中更新状态
      this.isExpanded[index] = !this.isExpanded[index];
    });
  }
}

关键点分析

1. 状态管理的实现

折叠展开功能的核心是状态管理。我们使用@State装饰器来管理每个列表项的展开状态:

// 创建与数据项相同长度的布尔数组
@State isExpanded: boolean[] = new Array(dataItems.length).fill(false);

// 初始化所有项为收起状态
// true表示展开,false表示收起

状态管理的最佳实践

  • 使用数组存储每个列表项的状态,便于通过索引访问

  • 初始状态设为false(默认收起)

  • 状态变化触发UI自动刷新

2. 条件渲染的实现

HarmonyOS使用条件渲染语句来控制组件的显示与隐藏:

// 条件渲染语法
if (this.isExpanded[index]) {
  // 当条件为true时渲染的内容
  this.renderExpandableContent(item, index)
}

条件渲染的特点

  • 基于布尔值的动态渲染

  • 支持复杂的条件表达式

  • 可以与动画效果结合使用

  • 性能优化:不渲染隐藏的组件

3. 动画效果的实现

平滑的动画效果能显著提升用户体验。我们使用animateTo方法实现:

// 创建动画上下文
this.uiContext.animateTo({
  duration: 300,  // 动画持续时间
  curve: Curve.EaseInOut,  // 缓动曲线
  onFinish: () => {
    // 动画完成后的回调
  }
}, () => {
  // 在动画上下文中更新状态
  this.isExpanded[index] = !this.isExpanded[index];
});

动画参数详解

  • duration: 动画持续时间,单位毫秒

  • curve: 动画曲线,控制动画速度变化

    • Curve.Linear: 线性

    • Curve.Ease: 缓入缓出

    • Curve.EaseIn: 缓入

    • Curve.EaseOut: 缓出

    • Curve.EaseInOut: 缓入缓出

  • onFinish: 动画完成回调函数

4. 性能优化考虑

在处理大量列表项时,性能优化至关重要:

// 使用LazyForEach优化大数据量
LazyForEach(this.dataSource, (item: DataItem) => {
  ListItem() {
    this.renderListItem(item)
  }
}, (item: DataItem) => item.id.toString())

// 虚拟化技术
List({ scroller: this.listScroller })
  .cachedCount(5)  // 预加载数量
  .edgeEffect(EdgeEffect.None)  // 禁用边缘效果提升性能

扩展应用

场景一:多级嵌套折叠

在实际应用中,我们经常需要多级嵌套的折叠结构:

@Component
struct MultiLevelExpandableList {
  @State expandedLevel1: boolean[] = [false, false, false];
  @State expandedLevel2: boolean[][];
  
  aboutToAppear() {
    // 初始化二级展开状态
    this.expandedLevel2 = [
      [false, false],
      [false, false, false],
      [false]
    ];
  }
  
  build() {
    List() {
      ForEach(this.expandedLevel1, (_, level1Index) => {
        ListItem() {
          Column() {
            // 一级项目
            this.renderLevel1Item(level1Index)
            
            // 二级项目(条件渲染)
            if (this.expandedLevel1[level1Index]) {
              ForEach(this.expandedLevel2[level1Index], (_, level2Index) => {
                this.renderLevel2Item(level1Index, level2Index)
              })
            }
          }
        }
      })
    }
  }
}

场景二:手风琴效果(同时只展开一项)

某些场景需要手风琴效果,即同时只能展开一个项目:

@Component
struct AccordionList {
  @State expandedIndex: number = -1;  // -1表示没有展开项
  
  toggleExpand(index: number): void {
    this.uiContext.animateTo({
      duration: 300,
      curve: Curve.EaseInOut
    }, () => {
      if (this.expandedIndex === index) {
        // 点击已展开的项,收起
        this.expandedIndex = -1;
      } else {
        // 点击其他项,展开新项
        this.expandedIndex = index;
      }
    });
  }
  
  build() {
    List() {
      ForEach(this.dataItems, (item, index) => {
        ListItem() {
          Column() {
            // 列表项标题
            this.renderItemHeader(item, index)
            
            // 内容区域
            if (index === this.expandedIndex) {
              this.renderItemContent(item)
            }
          }
        }
      })
    }
  }
}

场景三:记住展开状态

在应用重启后保持用户的展开状态:

import { PersistentStorage } from '@ohos.data.preferences';

@Component
struct RememberExpandedState {
  @State isExpanded: boolean[] = [];
  private preferences: PersistentStorage.Preferences | null = null;
  
  async aboutToAppear() {
    // 初始化Preferences
    this.preferences = await PersistentStorage.getPreferences(this.context, 'expandState');
    
    // 从持久化存储加载状态
    const savedState = await this.preferences.get('expandedStates', '[]');
    this.isExpanded = JSON.parse(savedState);
  }
  
  async toggleExpand(index: number) {
    this.uiContext.animateTo({
      duration: 300
    }, () => {
      this.isExpanded[index] = !this.isExpanded[index];
      
      // 保存状态到持久化存储
      this.saveExpandedState();
    });
  }
  
  async saveExpandedState() {
    if (this.preferences) {
      await this.preferences.put('expandedStates', JSON.stringify(this.isExpanded));
      await this.preferences.flush();
    }
  }
}

常见问题与解决方案

Q1: 展开内容时列表跳动或闪烁?

问题原因

  • 高度计算不准确

  • 动画与布局更新不同步

  • 条件渲染触发时机问题

解决方案

// 1. 使用固定高度或最大高度
if (this.isExpanded[index]) {
  Column() {
    // 展开内容
  }
  .height(200)  // 固定高度
  .clip(false)  // 允许内容溢出
}

// 2. 使用布局过渡动画
.animation({
  duration: 300,
  curve: Curve.EaseInOut,
  delay: 0,
  iterations: 1,
  playMode: PlayMode.Normal
})

// 3. 确保状态更新在动画上下文中
this.uiContext.animateTo({
  duration: 300
}, () => {
  this.isExpanded[index] = !this.isExpanded[index];
});

Q2: 大数据量时性能下降?

优化策略

// 1. 使用LazyForEach懒加载
LazyForEach(this.dataSource, (item) => {
  ListItem() {
    // 列表项内容
  }
})

// 2. 设置合理的缓存数量
List({ initialIndex: 0 })
  .cachedCount(10)  // 预渲染前后10个项目

// 3. 避免复杂计算在渲染过程中
aboutToAppear() {
  // 在组件出现前预处理数据
  this.preprocessData();
}

// 4. 使用shouldComponentUpdate优化
shouldComponentUpdate(params: Record<string, unknown>): boolean {
  // 只在自己相关的状态变化时更新
  return params.index === this.index;
}

Q3: 嵌套滚动冲突?

问题场景

展开内容内部有可滚动区域,与外部List滚动冲突。

解决方案

// 内部滚动区域禁用滚动,由外部List统一控制
Scroll() {
  // 内部内容
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.onScrollEdge((side: ScrollEdge) => {
  // 滚动到边缘时传递给外部
  if (side === ScrollEdge.Top || side === ScrollEdge.Bottom) {
    return; // 允许外部List继续滚动
  }
})

Q4: 展开动画不流畅?

优化方法

// 1. 使用硬件加速
.transform({
  translate: { x: 0, y: 0, z: 0 }
})
.backdropBlur(0)  // 启用硬件加速

// 2. 优化动画曲线
animateTo({
  duration: 300,
  curve: Curve.Friction  // 物理曲线,更自然
})

// 3. 减少重绘区域
.clip(true)  // 裁剪超出部分
.opacity(1)  // 避免透明度变化

总结与最佳实践

方案选择建议

场景特点

推荐方案

理由

简单的展开收起

List + 条件渲染

灵活控制,代码简单

多级嵌套结构

TreeView

内置支持,开发效率高

大数据量

List + LazyForEach

性能优化,内存占用低

需要复杂动画

List + animateTo

动画控制更精细

需要记住状态

任意方案 + PersistentStorage

状态持久化

性能优化清单

  1. 数据层面

    • 使用懒加载(LazyForEach)

    • 分页加载大数据集

    • 避免在渲染过程中进行复杂计算

  2. 渲染层面

    • 设置合理的cachedCount

    • 使用shouldComponentUpdate避免不必要渲染

    • 简化组件结构,减少嵌套

  3. 动画层面

    • 使用硬件加速

    • 优化动画曲线

    • 避免同时执行多个复杂动画

  4. 内存层面

    • 及时清理不需要的状态

    • 使用轻量级数据格式

    • 监控内存使用情况

代码规范建议

// ✅ 推荐写法
@Component
struct ExpandableList {
  // 明确的命名
  @State private expandedStates: boolean[] = [];
  
  // 单一职责的方法
  private toggleItem(index: number): void {
    this.uiContext.animateTo({
      duration: 300,
      curve: Curve.EaseInOut
    }, () => {
      this.expandedStates[index] = !this.expandedStates[index];
    });
  }
  
  // 清晰的组件结构
  @Builder
  renderListItem(item: DataItem, index: number) {
    Column() {
      this.renderHeader(item, index)
      this.renderContent(item, index)
    }
  }
}

// ❌ 不推荐写法
@Component
struct BadExample {
  @State flag: boolean[] = [];  // 命名不清晰
  // 方法职责不单一
  // 组件结构混乱
}

测试要点

在实现折叠展开功能后,需要进行全面测试:

功能测试

  • 点击展开/收起按钮功能正常

  • 动画流畅,无卡顿

  • 多级嵌套展开正确

  • 手风琴模式工作正常

性能测试

  • 100+项目流畅滚动

  • 展开收起响应迅速

  • 内存占用合理

  • 动画帧率稳定

兼容性测试

  • 不同设备尺寸适配

  • 横竖屏切换正常

  • 深色/浅色主题适配

  • 不同HarmonyOS版本兼容

用户体验测试

  • 动画自然流畅

  • 交互反馈及时

  • 状态记忆正确

  • 无障碍功能支持

通过本文的详细介绍,相信你已经掌握了在HarmonyOS 6中实现列表折叠展开功能的多种方法。无论选择TreeView还是自定义List方案,关键是根据实际业务需求选择最合适的方案,并结合性能优化和用户体验考虑,打造出既美观又实用的列表交互体验。

在实际开发中,建议先使用TreeView快速实现基础功能,如果需要更复杂的定制再考虑使用List自定义实现。同时,不要忘记性能优化和用户体验的细节,这些都是打造高质量应用的关键。

Logo

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

更多推荐