如何设置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
相关产品推荐
相关产品推荐

