Refactor home to single collection view and add all-functions customization.

Replace scroll/stack layout with one UICollectionView, fix grid column math and Diffable item IDs so location report and quick actions render, and add the customizable all-functions page with persistence and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 09:57:41 +08:00
parent d8329d19fd
commit 715393e78c
14 changed files with 1222 additions and 209 deletions

View File

@ -0,0 +1,228 @@
//
// AllFunctionsViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class AllFunctionsViewController: BaseViewController {
var onDidCustomize: (() -> Void)?
private enum AllFunctionSection: Int, CaseIterable, Hashable {
case common
case more
var title: String {
switch self {
case .common: return "常用应用"
case .more: return "更多功能"
}
}
var actionStyle: AllFunctionMenuCell.ActionStyle {
switch self {
case .common: return .remove
case .more: return .add
}
}
}
private let viewModel = AllFunctionsViewModel()
private let collectionView: UICollectionView
private var dataSource: UICollectionViewDiffableDataSource<AllFunctionSection, HomeMenuItem>!
private var hasAppliedInitialSnapshot = false
init() {
collectionView = UICollectionView(frame: .zero, collectionViewLayout: Self.makeLayout())
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "全部功能"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.allowsSelection = false
collectionView.contentInset = UIEdgeInsets(
top: AppSpacing.xs,
left: 0,
bottom: AppSpacing.md,
right: 0
)
collectionView.register(
AllFunctionMenuCell.self,
forCellWithReuseIdentifier: AllFunctionMenuCell.reuseIdentifier
)
collectionView.register(
AllFunctionSectionHeaderView.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: AllFunctionSectionHeaderView.reuseIdentifier
)
dataSource = makeDataSource()
view.addSubview(collectionView)
}
override func setupConstraints() {
collectionView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyViewModel(animated: self?.hasAppliedInitialSnapshot == true)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.loadFunctions()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent, viewModel.didCustomize {
onDidCustomize?()
}
}
@MainActor
private func applyViewModel(animated: Bool) {
var snapshot = NSDiffableDataSourceSnapshot<AllFunctionSection, HomeMenuItem>()
snapshot.appendSections([.common, .more])
snapshot.appendItems(viewModel.commonMenus, toSection: .common)
snapshot.appendItems(viewModel.moreMenus, toSection: .more)
dataSource.apply(snapshot, animatingDifferences: animated)
hasAppliedInitialSnapshot = true
}
private func makeDataSource() -> UICollectionViewDiffableDataSource<AllFunctionSection, HomeMenuItem> {
let dataSource = UICollectionViewDiffableDataSource<AllFunctionSection, HomeMenuItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, menu in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: AllFunctionMenuCell.reuseIdentifier,
for: indexPath
) as! AllFunctionMenuCell
guard let section = AllFunctionSection(rawValue: indexPath.section) else { return cell }
cell.apply(menu: menu, actionStyle: section.actionStyle)
cell.onActionTap = {
switch section {
case .common:
self?.viewModel.removeFromCommon(menu)
case .more:
self?.viewModel.addToCommon(menu)
}
}
return cell
}
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
guard kind == UICollectionView.elementKindSectionHeader,
let section = AllFunctionSection(rawValue: indexPath.section) else {
return nil
}
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: AllFunctionSectionHeaderView.reuseIdentifier,
for: indexPath
) as! AllFunctionSectionHeaderView
header.apply(title: section.title)
return header
}
return dataSource
}
private static func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { sectionIndex, environment in
let spacing: CGFloat = 12
let columns = 3
let horizontalInset = AppSpacing.md
let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2
let totalSpacing = spacing * CGFloat(columns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemWidth)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemWidth)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
repeatingSubitem: item,
count: columns
)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(
top: 0,
leading: horizontalInset,
bottom: sectionIndex == AllFunctionSection.common.rawValue ? AppSpacing.lg : AppSpacing.md,
trailing: horizontalInset
)
let headerSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(36)
)
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerSize,
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
section.boundarySupplementaryItems = [header]
return section
}
}
}
///
private final class AllFunctionSectionHeaderView: UICollectionReusableView {
static let reuseIdentifier = "AllFunctionSectionHeaderView"
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .app(.title)
titleLabel.textColor = AppColor.textPrimary
addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.xs)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String) {
titleLabel.text = title
}
}