// // 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 var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let confirmButton = AppButton(title: "确定") private let bottomBar = UIView() init(importedFileIDs: Set = []) { viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs) 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() } collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = AppColor.pageBackground collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier) collectionView.delegate = self 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 ) } return cell } bottomBar.backgroundColor = .white view.addSubview(searchField) view.addSubview(pathScrollView) 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) } 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(pathScrollView.snp.bottom).offset(AppSpacing.sm) 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() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.files.map(\.id)) dataSource.apply(snapshot, animatingDifferences: false) if viewModel.isLoading && viewModel.files.isEmpty { showLoading() } else { hideLoading() } } 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() { onConfirmed?(viewModel.selectedFileList) navigationController?.popViewController(animated: true) } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { _, environment in let columns = 3 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 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: 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 badgeView = UIView() 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 badgeView.backgroundColor = AppColor.primary badgeView.layer.cornerRadius = 10 badgeView.isHidden = true contentView.addSubview(imageView) contentView.addSubview(folderIconView) contentView.addSubview(nameLabel) contentView.addSubview(badgeView) 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) } badgeView.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 badgeView.isHidden = true } func apply(file: CloudFile, isSelected: Bool, isImported: Bool) { nameLabel.text = file.name contentView.layer.borderWidth = isSelected ? 2 : 0 contentView.layer.borderColor = AppColor.primary.cgColor badgeView.isHidden = !isSelected if file.isFolder { 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 } } }