// // CloudStoragePickForTaskViewController.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 任务云盘多选页,对齐 Android `CloudStorageListForTaskScreen`。 final class CloudStoragePickForTaskViewController: BaseViewController { var onConfirmed: (([CloudFile]) -> Void)? private let viewModel: CloudStoragePickForTaskViewModel private let taskAPI = NetworkServices.shared.taskAPI private let searchField = UITextField() private let pathScrollView = UIScrollView() private let pathStack = UIStackView() private let filterBar = UIStackView() private let sortButton = UIButton(type: .system) private let typeButton = UIButton(type: .system) private let displayModeButton = UIButton(type: .system) private let refreshControl = UIRefreshControl() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let confirmButton = AppButton(title: "确定") private let bottomBar = UIView() private var isGridMode = true /// 初始化云盘多选页,可按云盘文件类型限制选择范围。 init(importedFileIDs: Set = [], allowedFileType: Int? = nil) { viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs, allowedFileType: allowedFileType) 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() { searchField.placeholder = "搜索文件" searchField.borderStyle = .roundedRect searchField.font = .app(.body) searchField.returnKeyType = .search searchField.delegate = self pathStack.axis = .horizontal pathStack.spacing = AppSpacing.xs pathScrollView.showsHorizontalScrollIndicator = false pathScrollView.addSubview(pathStack) pathStack.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalToSuperview() } configureMenuButton(sortButton, imageName: "arrow.up.arrow.down") configureMenuButton(typeButton, imageName: "line.3.horizontal.decrease") displayModeButton.tintColor = AppColor.textSecondary displayModeButton.backgroundColor = AppColor.inputBackground displayModeButton.layer.cornerRadius = AppRadius.xs displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside) filterBar.axis = .horizontal filterBar.spacing = AppSpacing.sm filterBar.distribution = .fill filterBar.addArrangedSubview(sortButton) filterBar.addArrangedSubview(typeButton) filterBar.addArrangedSubview(displayModeButton) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = AppColor.pageBackground collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier) collectionView.delegate = self collectionView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, fileID in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: CloudPickCell.reuseIdentifier, for: indexPath ) as! CloudPickCell if let file = self?.viewModel.files.first(where: { $0.id == fileID }) { cell.apply( file: file, isSelected: self?.viewModel.isSelected(fileID) == true, isImported: self?.viewModel.isImported(fileID) == true, isGridMode: self?.isGridMode == true ) } return cell } bottomBar.backgroundColor = .white view.addSubview(searchField) view.addSubview(pathScrollView) view.addSubview(filterBar) view.addSubview(collectionView) view.addSubview(bottomBar) bottomBar.addSubview(confirmButton) } override func setupConstraints() { searchField.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(40) } pathScrollView.snp.makeConstraints { make in make.top.equalTo(searchField.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(28) } filterBar.snp.makeConstraints { make in make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(36) } sortButton.snp.makeConstraints { make in make.width.equalTo(typeButton) } displayModeButton.snp.makeConstraints { make in make.width.equalTo(36) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } confirmButton.snp.makeConstraints { make in make.edges.equalToSuperview().inset(AppSpacing.md) } collectionView.snp.makeConstraints { make in make.top.equalTo(filterBar.snp.bottom).offset(AppSpacing.md) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.refresh(api: taskAPI) } } @MainActor private func applyViewModel() { rebuildPathButtons() rebuildFilterMenus() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.files.map(\.id)) let existingItems = Set(dataSource.snapshot().itemIdentifiers) let itemsToReconfigure = snapshot.itemIdentifiers.filter(existingItems.contains) snapshot.reconfigureItems(itemsToReconfigure) dataSource.apply(snapshot, animatingDifferences: false) let selectedCount = viewModel.selectedFileList.count confirmButton.setTitle(selectedCount > 0 ? "确认选择(\(selectedCount))" : "确认选择", for: .normal) if viewModel.isLoading && viewModel.files.isEmpty { showLoading() } else { hideLoading() } if !viewModel.isRefreshing { refreshControl.endRefreshing() } } private func rebuildPathButtons() { pathStack.arrangedSubviews.forEach { $0.removeFromSuperview() } for (index, item) in viewModel.pathStack.enumerated() { let button = UIButton(type: .system) button.setTitle(item.name, for: .normal) button.titleLabel?.font = .app(.caption) button.tag = index button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside) pathStack.addArrangedSubview(button) if index < viewModel.pathStack.count - 1 { let arrow = UILabel() arrow.text = ">" arrow.font = .app(.caption) arrow.textColor = AppColor.textTertiary pathStack.addArrangedSubview(arrow) } } } @objc private func pathTapped(_ sender: UIButton) { Task { await viewModel.navigateToPathIndex(sender.tag, api: taskAPI) } } @objc private func confirmTapped() { guard !viewModel.selectedFileList.isEmpty else { showToast("请至少选择一个文件") return } onConfirmed?(viewModel.selectedFileList) navigationController?.popViewController(animated: true) } @objc private func refreshTriggered() { Task { await viewModel.refresh(api: taskAPI) } } @objc private func displayModeTapped() { isGridMode.toggle() collectionView.setCollectionViewLayout(makeLayout(), animated: true) applyViewModel() } private func configureMenuButton(_ button: UIButton, imageName: String) { button.tintColor = AppColor.textSecondary button.backgroundColor = AppColor.inputBackground button.layer.cornerRadius = AppRadius.xs button.contentHorizontalAlignment = .leading button.titleLabel?.font = .app(.body) button.setTitleColor(AppColor.textPrimary, for: .normal) button.setImage(UIImage(systemName: imageName), for: .normal) button.semanticContentAttribute = .forceRightToLeft button.showsMenuAsPrimaryAction = true } private func rebuildFilterMenus() { let sortItems = [(1, "创建时间顺序"), (2, "创建时间倒序")] sortButton.setTitle(sortItems.first(where: { $0.0 == viewModel.sortType })?.1 ?? "排序", for: .normal) sortButton.menu = UIMenu(children: sortItems.map { value, title in UIAction(title: title, state: value == viewModel.sortType ? .on : .off) { [weak self] _ in guard let self else { return } self.viewModel.updateSortType(value) Task { await self.viewModel.refresh(api: self.taskAPI) } } }) let typeItems = [(0, "全部"), (2, "图片"), (1, "视频"), (99, "文件夹")] typeButton.setTitle(typeItems.first(where: { $0.0 == viewModel.filterType })?.1 ?? "全部", for: .normal) typeButton.menu = UIMenu(children: typeItems.map { value, title in UIAction(title: title, state: value == viewModel.filterType ? .on : .off) { [weak self] _ in guard let self else { return } self.viewModel.updateFilterType(value) Task { await self.viewModel.refresh(api: self.taskAPI) } } }) displayModeButton.setImage( UIImage(systemName: isGridMode ? "list.bullet" : "square.grid.2x2"), for: .normal ) displayModeButton.accessibilityLabel = isGridMode ? "列表显示" : "宫格显示" } private func makeLayout() -> UICollectionViewCompositionalLayout { let isGridMode = isGridMode return UICollectionViewCompositionalLayout { _, environment in let columns = isGridMode ? 2 : 1 let spacing: CGFloat = 8 let inset = AppSpacing.md * 2 let availableWidth = environment.container.effectiveContentSize.width - inset let totalSpacing = spacing * CGFloat(columns - 1) let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns)) let itemHeight = isGridMode ? itemWidth + 38 : 72 let itemSize = NSCollectionLayoutSize( widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemHeight) ) 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: AppSpacing.md, bottom: AppSpacing.md, trailing: AppSpacing.md ) return section } } } extension CloudStoragePickForTaskViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let fileID = dataSource.itemIdentifier(for: indexPath), let file = viewModel.files.first(where: { $0.id == fileID }) else { return } Task { await viewModel.handleItemTap(file, api: taskAPI) } } func scrollViewDidScroll(_ scrollView: UIScrollView) { let offsetY = scrollView.contentOffset.y let contentHeight = scrollView.contentSize.height let frameHeight = scrollView.frame.size.height if offsetY > contentHeight - frameHeight - 120 { Task { await viewModel.loadMore(api: taskAPI) } } } } extension CloudStoragePickForTaskViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { viewModel.updateSearchText(textField.text ?? "") textField.resignFirstResponder() Task { await viewModel.refresh(api: taskAPI) } return true } } /// 云盘选择 cell。 private final class CloudPickCell: UICollectionViewCell { static let reuseIdentifier = "CloudPickCell" private let imageView = UIImageView() private let nameLabel = UILabel() private let selectionIconView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill")) private let folderIconView = UIImageView() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = AppRadius.sm contentView.clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true folderIconView.image = UIImage(systemName: "folder.fill") folderIconView.tintColor = AppColor.primary folderIconView.contentMode = .scaleAspectFit nameLabel.font = .app(.caption) nameLabel.textColor = AppColor.textPrimary nameLabel.numberOfLines = 2 nameLabel.textAlignment = .center selectionIconView.tintColor = AppColor.primary selectionIconView.contentMode = .scaleAspectFit selectionIconView.isHidden = true contentView.addSubview(imageView) contentView.addSubview(folderIconView) contentView.addSubview(nameLabel) contentView.addSubview(selectionIconView) imageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(contentView.snp.width) } folderIconView.snp.makeConstraints { make in make.center.equalTo(imageView) make.width.height.equalTo(36) } nameLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs) make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs) } selectionIconView.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) make.width.height.equalTo(20) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() imageView.kf.cancelDownloadTask() imageView.image = nil folderIconView.isHidden = true selectionIconView.isHidden = true contentView.alpha = 1 } func apply(file: CloudFile, isSelected: Bool, isImported: Bool, isGridMode: Bool) { nameLabel.text = file.name contentView.layer.borderWidth = isSelected ? 2 : 0 contentView.layer.borderColor = AppColor.primary.cgColor selectionIconView.isHidden = !isSelected if isGridMode { imageView.snp.remakeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(imageView.snp.width) } nameLabel.textAlignment = .center nameLabel.numberOfLines = 2 nameLabel.snp.remakeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs) make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs) } } else { imageView.snp.remakeConstraints { make in make.top.bottom.leading.equalToSuperview() make.width.equalTo(imageView.snp.height) } nameLabel.textAlignment = .left nameLabel.numberOfLines = 2 nameLabel.snp.remakeConstraints { make in make.leading.equalTo(imageView.snp.trailing).offset(AppSpacing.sm) make.trailing.equalToSuperview().inset(AppSpacing.sm) make.centerY.equalToSuperview() } } if file.isFolder { contentView.alpha = 1 imageView.image = nil folderIconView.isHidden = false return } folderIconView.isHidden = true let preview = file.coverUrl.isEmpty ? file.fileUrl : file.coverUrl if let url = URL(string: preview), !preview.isEmpty { imageView.kf.setImage(with: url) } else { imageView.image = UIImage(systemName: file.isVideo ? "video" : "photo") imageView.tintColor = AppColor.textTertiary } if isImported { contentView.alpha = 0.5 } else { contentView.alpha = 1 } } }