You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何设置UICollectionViewCompositionalLayout标题与.insetGrouped布局子列表分离并实现Mail应用式可折叠分组效果

实现苹果Mail风格的可折叠侧边栏(带InsetGrouped子列表)

你遇到的核心问题是把组标题和子项放在了同一个insetGrouped风格的section里,导致标题也被加上了inset。要复刻Mail的效果,必须把可折叠的组标题做成独立的无inset section,再搭配一个insetGrouped风格的子项section,然后通过数据源快照来控制子项section的显示/隐藏。

下面是完整的实现方案:

1. 更新数据模型

首先给你的分组模型添加折叠状态属性,方便跟踪每个组的展开/折叠状态:

import UIKit

// 子项模型
struct SidebarItem: Hashable {
    let id = UUID()
    let title: String
    let image: UIImage?
    let subtitle: String?
}

// 分组模型:包含标题、折叠状态和子项数组
struct SidebarGroup: Hashable {
    let id = UUID()
    let title: String
    var isCollapsed: Bool
    let items: [SidebarItem]
}

// 用于区分数据源中的section类型(标题组/子项组)
enum DataSourceSection: Hashable {
    case header(group: SidebarGroup)
    case items(group: SidebarGroup)
}

2. 构建组合布局

在布局闭包中,根据section的类型返回对应的list配置:标题组用sidebarPlain(无inset,占满宽度),子项组用insetGrouped:

class ViewController: UIViewController, UICollectionViewDelegate {
    
    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<DataSourceSection, SidebarItem>!
    // 存储所有分组数据
    private var groups: [SidebarGroup] = [
        SidebarGroup(title: "邮箱", isCollapsed: false, items: [
            SidebarItem(title: "收件箱", image: UIImage(systemName: "tray"), subtitle: "12"),
            SidebarItem(title: "已发送", image: UIImage(systemName: "paperplane"), subtitle: nil)
        ]),
        SidebarGroup(title: "文件夹", isCollapsed: true, items: [
            SidebarItem(title: "工作", image: UIImage(systemName: "folder"), subtitle: "5"),
            SidebarItem(title: "个人", image: UIImage(systemName: "folder.fill"), subtitle: "3")
        ])
    ]

    override func viewDidLoad() {
        super.viewDidLoad()
        setupCollectionView()
        setupDataSource()
        applyInitialSnapshot()
    }
    
    private func setupCollectionView() {
        let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, env -> NSCollectionLayoutSection? in
            guard let self = self,
                  let section = self.dataSource.snapshot().sectionIdentifiers[safe: sectionIndex] else {
                return nil
            }
            
            switch section {
            case .header:
                // 标题组:无inset,占满宽度
                let config = UICollectionLayoutListConfiguration(appearance: .sidebarPlain)
                return NSCollectionLayoutSection.list(using: config, layoutEnvironment: env)
            case .items:
                // 子项组:使用insetGrouped样式
                let config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
                return NSCollectionLayoutSection.list(using: config, layoutEnvironment: env)
            }
        }
        
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.delegate = self
        view.addSubview(collectionView)
    }

3. 配置数据源和Cell注册

分别注册标题cell和子项cell,并绑定数据源:

private func setupDataSource() {
        // 标题cell注册:带折叠指示器
        let headerCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, SidebarGroup> { cell, _, group in
            var config = UIListContentConfiguration.sidebarHeader()
            config.text = group.title
            cell.contentConfiguration = config
            
            // 设置折叠指示器为header风格
            let disclosureOptions = UICellAccessory.OutlineDisclosureOptions(style: .header)
            cell.accessories = [.outlineDisclosure(displayed: .always, options: disclosureOptions)]
        }
        
        // 子项cell注册
        let itemCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, SidebarItem> { cell, _, item in
            var config = UIListContentConfiguration.sidebarCell()
            config.text = item.title
            config.image = item.image
            cell.contentConfiguration = config
            
            if let subtitle = item.subtitle {
                cell.accessories = [.label(text: subtitle), .disclosureIndicator()]
            } else {
                cell.accessories = [.disclosureIndicator()]
            }
        }
        
        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in
            guard let section = self?.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
                return nil
            }
            
            switch section {
            case .header(let group):
                return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: group)
            case .items:
                return collectionView.dequeueConfiguredReusableCell(using: itemCellRegistration, for: indexPath, item: item)
            }
        }
    }

4. 处理快照更新和折叠交互

通过快照控制section的显示/隐藏,点击标题时切换折叠状态:

private func applyInitialSnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<DataSourceSection, SidebarItem>()
        
        for group in groups {
            // 添加标题section
            snapshot.appendSections([.header(group: group)])
            // 如果组未折叠,添加子项section和子项
            if !group.isCollapsed {
                snapshot.appendSections([.items(group: group)])
                snapshot.appendItems(group.items, toSection: .items(group: group))
            }
        }
        
        dataSource.apply(snapshot, animatingDifferences: false)
    }
    
    // 点击标题时切换折叠状态
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let section = dataSource.snapshot().sectionIdentifiers[indexPath.section],
              case .header(let group) = section,
              let groupIndex = groups.firstIndex(where: { $0.id == group.id }) else {
            return
        }
        
        // 切换折叠状态
        groups[groupIndex].isCollapsed.toggle()
        // 重新生成快照
        applyInitialSnapshot()
        // 取消选中状态
        collectionView.deselectItem(at: indexPath, animated: true)
    }
}

// 扩展:安全访问数组元素
extension Collection {
    subscript(safe index: Index) -> Element? {
        return indices.contains(index) ? self[index] : nil
    }
}

关键说明

  • 把标题和子项拆成独立的section,避免了标题被insetGrouped样式影响,完美复刻Mail的视觉效果。
  • 使用DiffableDataSource的快照来管理section的显示/隐藏,实现流畅的折叠动画。
  • 标题cell使用.sidebarHeader配置和.header风格的折叠指示器,和Mail的交互逻辑一致。

内容的提问来源于stack exchange,提问作者Leu

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.30 18:22:28