// // CloudDriveListViewController.swift // suixinkan // import PhotosUI import SnapKit import UIKit import UniformTypeIdentifiers /// 相册云盘列表页,严格对齐 Android `CloudStorageListScreen`。 final class CloudDriveListViewController: BaseViewController { private let viewModel = CloudDriveListViewModel() private let api: any CloudDriveServing private let transferManager: CloudTransferManager private let navigationDivider = UIView() private let pathScrollView = UIScrollView() private let pathStackView = UIStackView() private let searchContainer = UIView() private let searchIconView = UIImageView( image: CloudDriveAsset.image(named: "icon_cloud_storage_file_search", fallbackSystemName: "magnifyingglass") ) private let searchField = UITextField() private let sortButton = CloudDriveMenuButton() private let filterButton = CloudDriveMenuButton() private let displayModeButton = UIButton(type: .system) private let contentDivider = UIView() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private let refreshControl = UIRefreshControl() private let loadingMoreIndicator = UIActivityIndicatorView(style: .medium) private let uploadButton = UIButton(type: .system) private let bottomBar = UIView() private var appliedDisplayMode: CloudDriveDisplayMode? private var lastInteractionTimes: [String: TimeInterval] = [:] private var previousStandardAppearance: UINavigationBarAppearance? private var previousScrollEdgeAppearance: UINavigationBarAppearance? private var previousCompactAppearance: UINavigationBarAppearance? private var previousNavigationTintColor: UIColor? /// 初始化相册云盘列表页。 init( api: (any CloudDriveServing)? = nil, transferManager: CloudTransferManager? = nil ) { self.api = api ?? NetworkServices.shared.cloudDriveAPI self.transferManager = transferManager ?? .shared super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { let titleLabel = UILabel() titleLabel.text = "相册云盘" titleLabel.textColor = UIColor(hex: 0x333333) titleLabel.font = .systemFont(ofSize: 18) navigationItem.titleView = titleLabel let backButton = UIButton(type: .system) backButton.setImage(UIImage(systemName: "chevron.left")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal) backButton.tintColor = .black backButton.accessibilityLabel = "返回" backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) backButton.snp.makeConstraints { make in make.width.height.equalTo(44) } navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton) let transferButton = UIButton(type: .system) transferButton.setImage( CloudDriveAsset.resizedImage( named: "icon_cloud_storage_transit", fallbackSystemName: "arrow.up.arrow.down.circle", size: CGSize(width: 20, height: 20) ), for: .normal ) transferButton.imageView?.contentMode = .scaleAspectFit transferButton.accessibilityLabel = "传输管理" transferButton.addTarget(self, action: #selector(openTransferManager), for: .touchUpInside) transferButton.snp.makeConstraints { make in make.width.height.equalTo(44) } navigationItem.rightBarButtonItem = UIBarButtonItem(customView: transferButton) } override func setupUI() { view.backgroundColor = .white navigationDivider.backgroundColor = UIColor(hex: 0xEEEEEE) pathScrollView.showsHorizontalScrollIndicator = false pathScrollView.alwaysBounceHorizontal = false pathStackView.axis = .horizontal pathStackView.alignment = .center pathStackView.spacing = 0 pathScrollView.addSubview(pathStackView) searchContainer.backgroundColor = UIColor(hex: 0xF4F4F4) searchContainer.layer.cornerRadius = 4 searchIconView.contentMode = .scaleAspectFit searchField.attributedPlaceholder = NSAttributedString( string: "搜索文件名称", attributes: [ .font: UIFont.systemFont(ofSize: 14), .foregroundColor: UIColor(hex: 0xB6BECA), ] ) searchField.font = .systemFont(ofSize: 14) searchField.textColor = UIColor(hex: 0x333333) searchField.returnKeyType = .search searchField.clearButtonMode = .never searchField.delegate = self searchContainer.addSubview(searchIconView) searchContainer.addSubview(searchField) sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside) filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) displayModeButton.backgroundColor = .white displayModeButton.imageView?.contentMode = .scaleAspectFit displayModeButton.accessibilityLabel = "切换展示方式" contentDivider.backgroundColor = UIColor(hex: 0xEEEEEE) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = .white collectionView.alwaysBounceVertical = true collectionView.delegate = self collectionView.refreshControl = refreshControl collectionView.register(CloudDriveFileCell.self, forCellWithReuseIdentifier: CloudDriveFileCell.reuseIdentifier) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, file in guard let self, let cell = collectionView.dequeueReusableCell( withReuseIdentifier: CloudDriveFileCell.reuseIdentifier, for: indexPath ) as? CloudDriveFileCell else { return UICollectionViewCell() } cell.apply(file: file, mode: self.viewModel.displayMode) cell.onDetails = { [weak self] in guard let self, self.shouldHandleInteraction("details_\(file.id)") else { return } self.showControlSheet(for: file) } return cell } loadingMoreIndicator.color = AppColor.primary collectionView.addSubview(loadingMoreIndicator) bottomBar.backgroundColor = .white bottomBar.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor bottomBar.layer.borderWidth = 0.5 uploadButton.setTitle("上传", for: .normal) uploadButton.setTitleColor(.white, for: .normal) uploadButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) uploadButton.backgroundColor = AppColor.primary uploadButton.layer.cornerRadius = 10 uploadButton.accessibilityLabel = "上传" [navigationDivider, pathScrollView, searchContainer, sortButton, filterButton, displayModeButton, contentDivider, collectionView, bottomBar] .forEach(view.addSubview) bottomBar.addSubview(uploadButton) } override func setupConstraints() { navigationDivider.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } pathScrollView.snp.makeConstraints { make in make.top.equalTo(navigationDivider.snp.bottom) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(52) } pathStackView.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalToSuperview() } searchContainer.snp.makeConstraints { make in make.top.equalTo(pathScrollView.snp.bottom).offset(2) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(36) } searchIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.width.height.equalTo(16) } searchField.snp.makeConstraints { make in make.leading.equalTo(searchIconView.snp.trailing).offset(12) make.top.bottom.equalToSuperview() make.trailing.equalToSuperview().inset(8) } sortButton.snp.makeConstraints { make in make.top.equalTo(searchContainer.snp.bottom).offset(16) make.leading.equalToSuperview().offset(16) make.height.equalTo(36) } filterButton.snp.makeConstraints { make in make.top.width.height.equalTo(sortButton) make.leading.equalTo(sortButton.snp.trailing).offset(12) } displayModeButton.snp.makeConstraints { make in make.centerY.equalTo(sortButton) make.leading.equalTo(filterButton.snp.trailing).offset(12) make.trailing.equalToSuperview().inset(6) make.width.height.equalTo(35) } sortButton.snp.makeConstraints { make in make.width.equalTo(filterButton) } contentDivider.snp.makeConstraints { make in make.top.equalTo(sortButton.snp.bottom).offset(16) make.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } uploadButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } collectionView.snp.makeConstraints { make in make.top.equalTo(contentDivider.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } loadingMoreIndicator.snp.makeConstraints { make in make.centerX.equalToSuperview() make.bottom.equalTo(collectionView.frameLayoutGuide).inset(12) } } override func bindActions() { uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside) refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } transferManager.onUploadCompleted = { [weak self] folderId in guard let self else { return } Task { await self.viewModel.handleUploadCompleted(parentFolderId: folderId, api: self.api) } } transferManager.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.refresh(api: api, showLoading: true) } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) applyCloudNavigationBarAppearance() updateInteractivePopGesture() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) restoreNavigationBarAppearance() navigationController?.interactivePopGestureRecognizer?.isEnabled = true } private func applyCloudNavigationBarAppearance() { guard let navigationBar = navigationController?.navigationBar else { return } previousStandardAppearance = navigationBar.standardAppearance previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance previousCompactAppearance = navigationBar.compactAppearance previousNavigationTintColor = navigationBar.tintColor let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = .white appearance.shadowColor = .clear navigationBar.standardAppearance = appearance navigationBar.scrollEdgeAppearance = appearance navigationBar.compactAppearance = appearance navigationBar.tintColor = .black } private func restoreNavigationBarAppearance() { guard let navigationBar = navigationController?.navigationBar else { return } if let previousStandardAppearance { navigationBar.standardAppearance = previousStandardAppearance } navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance navigationBar.compactAppearance = previousCompactAppearance navigationBar.tintColor = previousNavigationTintColor } @MainActor private func applyViewModel() { rebuildBreadcrumb() searchField.text = viewModel.searchText sortButton.apply( title: viewModel.sortType.title, image: CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down") ) filterButton.apply( title: viewModel.filterType.displayTitle, image: CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle") ) let isGrid = viewModel.displayMode == .grid let displayIcon = CloudDriveAsset.resizedImage( named: isGrid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table", fallbackSystemName: isGrid ? "list.bullet" : "square.grid.2x2", size: CGSize(width: 16, height: 16) ) displayModeButton.setImage(displayIcon, for: .normal) let shouldReconfigureCells = appliedDisplayMode != nil && appliedDisplayMode != viewModel.displayMode if appliedDisplayMode != viewModel.displayMode { appliedDisplayMode = viewModel.displayMode collectionView.setCollectionViewLayout(makeLayout(), animated: false) } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(viewModel.files) if shouldReconfigureCells { snapshot.reconfigureItems(viewModel.files) } dataSource.apply(snapshot, animatingDifferences: true) if !viewModel.isRefreshing { refreshControl.endRefreshing() } if viewModel.isLoading, !viewModel.files.isEmpty { loadingMoreIndicator.startAnimating() } else { loadingMoreIndicator.stopAnimating() } if viewModel.isLoading, viewModel.files.isEmpty, !refreshControl.isRefreshing { showLoading() } else { hideLoading() } updateInteractivePopGesture() } private func rebuildBreadcrumb() { pathStackView.arrangedSubviews.forEach { pathStackView.removeArrangedSubview($0) $0.removeFromSuperview() } let homeButton = UIButton(type: .system) homeButton.setImage( CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill")?.withRenderingMode(.alwaysOriginal), for: .normal ) homeButton.imageView?.contentMode = .scaleAspectFit homeButton.accessibilityLabel = "返回云盘首页" homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) homeButton.snp.makeConstraints { make in make.width.height.equalTo(16) } pathStackView.addArrangedSubview(homeButton) pathStackView.addArrangedSubview(makeBreadcrumbSpacer(width: 8)) for (index, item) in viewModel.pathStack.enumerated() { if index > 0 { let container = UIView() let arrow = UIImageView( image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right") ) arrow.contentMode = .scaleAspectFit container.addSubview(arrow) container.snp.makeConstraints { make in make.width.equalTo(32) } arrow.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.height.equalTo(16) } pathStackView.addArrangedSubview(container) } let isLast = index == viewModel.pathStack.count - 1 let button = UIButton(type: .system) button.setTitle(item.name, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: isLast ? .semibold : .regular) button.setTitleColor(isLast ? .black : UIColor(hex: 0x4B5563), for: .normal) button.titleLabel?.lineBreakMode = .byTruncatingTail button.tag = index button.isUserInteractionEnabled = !isLast if !isLast { button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside) } pathStackView.addArrangedSubview(button) } pathScrollView.layoutIfNeeded() let rightOffset = max(0, pathScrollView.contentSize.width - pathScrollView.bounds.width) pathScrollView.setContentOffset(CGPoint(x: rightOffset, y: 0), animated: false) } private func makeBreadcrumbSpacer(width: CGFloat) -> UIView { let spacer = UIView() spacer.snp.makeConstraints { make in make.width.equalTo(width) } return spacer } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { [weak self] _, environment in switch self?.viewModel.displayMode ?? .grid { case .grid: let columns = 2 let spacing: CGFloat = 15 let horizontalInset: CGFloat = 12 let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 let itemWidth = (availableWidth - spacing) / CGFloat(columns) let itemHeight = itemWidth / 1.77 + 52 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.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) section.interGroupSpacing = spacing return section case .list: let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72)) let item = NSCollectionLayoutItem(layoutSize: size) let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) return section } } } private func showControlSheet(for file: CloudFile) { viewModel.setControlFile(file) let sheet = CloudDriveActionSheetViewController( title: "请选择", layout: .controls, items: [ .init( title: "删除", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"), isDestructive: false, handler: { [weak self] in self?.confirmDelete() } ), .init( title: "下载", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"), isDestructive: false, handler: { [weak self] in self?.download(file) } ), .init( title: "移动", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"), isDestructive: false, handler: { [weak self] in self?.showMoveDialog() } ), .init( title: "详情", image: CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "info.circle"), isDestructive: false, handler: { [weak self] in self?.showDetailDialog(file) } ), ] ) present(sheet, animated: false) } private func confirmDelete() { let dialog = CloudDriveConfirmationDialogViewController( title: "删除确认", message: "删除后无法恢复,确认删除吗?", onConfirm: { [weak self] in guard let self else { return } Task { await self.viewModel.deleteCurrentFile(api: self.api) } } ) present(dialog, animated: false) } private func download(_ file: CloudFile) { guard file.isSelectableMedia else { showToast("当前类型不支持下载") return } guard !file.fileUrl.isEmpty else { showToast("文件地址不存在") return } transferManager.addDownloadTask( fileURL: file.fileUrl, fileName: file.name, fileType: file.type, fileSize: file.fileSize ) } private func showMoveDialog() { let dialog = CloudDriveMoveDialogViewController(folders: viewModel.moveTargetFolders()) { [weak self] folderID in guard let self else { return } Task { await self.viewModel.moveCurrentFile(targetFolderId: folderID, api: self.api) } } present(dialog, animated: false) } private func showDetailDialog(_ file: CloudFile) { let sheet = CloudDriveDetailSheetViewController(file: file) { [weak self] name in guard let self else { return } Task { await self.viewModel.saveCurrentFileName(name, api: self.api) } } present(sheet, animated: false) } private func beginPick(fileType: Int) { Task { guard await viewModel.checkCanUpload(api: api) else { return } await MainActor.run { var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = 1 configuration.filter = fileType == 2 ? .images : .videos let picker = PHPickerViewController(configuration: configuration) picker.delegate = self picker.view.tag = fileType self.present(picker, animated: true) } } } private func showCreateFolderDialog() { guard viewModel.pathStack.count < 5 else { showToast("不能创建更深的文件夹") return } let dialog = CloudDriveTextInputDialogViewController( title: "新建文件夹", placeholder: "请输入文件夹名称" ) { [weak self] name in guard let self else { return } Task { await self.viewModel.addFolder(name: name, api: self.api) } } present(dialog, animated: false) } private func showDropdown( from sourceView: UIView, items: [CloudDriveDropdownViewController.Item] ) { present(CloudDriveDropdownViewController(sourceView: sourceView, items: items), animated: false) } private func updateInteractivePopGesture() { navigationController?.interactivePopGestureRecognizer?.isEnabled = viewModel.pathStack.count == 1 } private func shouldHandleInteraction(_ key: String, interval: TimeInterval = 0.5) -> Bool { let now = ProcessInfo.processInfo.systemUptime if let previous = lastInteractionTimes[key], now - previous < interval { return false } lastInteractionTimes[key] = now return true } @objc private func backTapped() { guard shouldHandleInteraction("back", interval: 1) else { return } Task { let handled = await viewModel.navigateBack(api: api) if !handled { _ = await MainActor.run { self.navigationController?.popViewController(animated: true) } } } } @objc private func refreshTriggered() { Task { await viewModel.refresh(api: api, showLoading: false) } } @objc private func sortTapped() { guard shouldHandleInteraction("sort") else { return } showDropdown( from: sortButton, items: CloudDriveSortType.allCases.map { type in .init(title: type.title, isSelected: type == viewModel.sortType) { [weak self] in guard let self else { return } Task { await self.viewModel.chooseSortType(type, api: self.api) } } } ) } @objc private func filterTapped() { guard shouldHandleInteraction("filter") else { return } showDropdown( from: filterButton, items: CloudDriveFilterType.androidMenuOrder.map { type in .init(title: type.menuTitle, isSelected: type == viewModel.filterType) { [weak self] in guard let self else { return } Task { await self.viewModel.chooseFilterType(type, api: self.api) } } } ) } @objc private func uploadTapped() { guard shouldHandleInteraction("upload") else { return } let sheet = CloudDriveActionSheetViewController( title: "选择上传类型", layout: .upload, items: [ .init( title: "上传图片", image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"), isDestructive: false, handler: { [weak self] in self?.beginPick(fileType: 2) } ), .init( title: "上传视频", image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"), isDestructive: false, handler: { [weak self] in self?.beginPick(fileType: 1) } ), .init( title: "新建文件夹", image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"), isDestructive: false, handler: { [weak self] in self?.showCreateFolderDialog() } ), ] ) present(sheet, animated: false) } @objc private func displayModeTapped() { guard shouldHandleInteraction("display") else { return } viewModel.toggleDisplayMode() } @objc private func openTransferManager() { guard shouldHandleInteraction("transfer", interval: 1) else { return } navigationController?.pushViewController(CloudDriveTransferViewController(manager: transferManager), animated: true) } @objc private func homeTapped() { guard shouldHandleInteraction("home") else { return } Task { await viewModel.goHome(api: api) } } @objc private func pathTapped(_ sender: UIButton) { guard shouldHandleInteraction("path_\(sender.tag)") else { return } Task { await viewModel.navigateToPathIndex(sender.tag, api: api) } } private func openPreview(file: CloudFile) { navigationController?.pushViewController(CloudDrivePreviewViewController(file: file), animated: true) } private func stagePickedFile(result: PHPickerResult, fileType: Int) { let provider = result.itemProvider let typeIdentifier = fileType == 2 ? UTType.image.identifier : UTType.movie.identifier provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in guard let self else { return } if let error { Task { @MainActor in self.showToast(error.localizedDescription) } return } guard let url else { return } do { let stagedURL = try Self.copyPickedFileToStage( url: url, suggestedName: provider.suggestedName, fileType: fileType ) let attributes = try? FileManager.default.attributesOfItem(atPath: stagedURL.path) let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 Task { @MainActor in self.transferManager.addUploadTask( localFileURL: stagedURL, fileName: stagedURL.lastPathComponent, fileType: fileType, fileSize: size, parentFolderId: self.viewModel.currentFolderID ) } } catch { Task { @MainActor in self.showToast(error.localizedDescription) } } } } private static func copyPickedFileToStage(url: URL, suggestedName: String?, fileType: Int) throws -> URL { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cloud_drive_uploads", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let ext = url.pathExtension.isEmpty ? (fileType == 2 ? "jpg" : "mp4") : url.pathExtension let baseName = CloudTransferFileName.sanitized(suggestedName ?? "cloud_upload") let fileName = baseName.lowercased().hasSuffix(".\(ext.lowercased())") ? baseName : "\(baseName).\(ext)" let destination = directory.appendingPathComponent("\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))_\(fileName)") try FileManager.default.copyItem(at: url, to: destination) return destination } } extension CloudDriveListViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { viewModel.updateSearchText(textField.text ?? "") textField.resignFirstResponder() Task { await viewModel.refresh(api: api, showLoading: true) } return true } } extension CloudDriveListViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let file = dataSource.itemIdentifier(for: indexPath), shouldHandleInteraction("file_\(file.id)") else { return } Task { if let preview = await viewModel.handleFileTap(file, api: api) { await MainActor.run { self.openPreview(file: preview) } } } } func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.contentSize.height > scrollView.bounds.height else { return } let threshold = scrollView.contentSize.height - scrollView.bounds.height - 120 guard scrollView.contentOffset.y >= threshold else { return } let lastIndex = max(0, viewModel.files.count - 1) Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: lastIndex, api: api) } } } extension CloudDriveListViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { let fileType = picker.view.tag picker.dismiss(animated: true) guard let result = results.first else { return } stagePickedFile(result: result, fileType: fileType) } } /// Android 云盘筛选条按钮,保证文案与图标分居两端。 private final class CloudDriveMenuButton: UIButton { private let valueLabel = UILabel() private let iconView = UIImageView() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor(hex: 0xF4F4F4) layer.cornerRadius = 4 valueLabel.font = .systemFont(ofSize: 14) valueLabel.textColor = UIColor(hex: 0x333333) valueLabel.isUserInteractionEnabled = false iconView.contentMode = .scaleAspectFit iconView.isUserInteractionEnabled = false addSubview(valueLabel) addSubview(iconView) valueLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualTo(iconView.snp.leading).offset(-8) } iconView.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() make.width.height.equalTo(16) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 更新按钮文案与图标。 func apply(title: String, image: UIImage?) { valueLabel.text = title iconView.image = image?.withRenderingMode(.alwaysOriginal) accessibilityLabel = title } } /// Android 风格的轻量下拉菜单。 private final class CloudDriveDropdownViewController: UIViewController { /// 下拉菜单项。 struct Item { let title: String let isSelected: Bool let handler: () -> Void } private weak var sourceView: UIView? private let items: [Item] private let dismissButton = UIButton(type: .custom) private let menuView = UIView() private let stackView = UIStackView() /// 创建锚定到指定按钮的下拉菜单。 init(sourceView: UIView, items: [Item]) { self.sourceView = sourceView self.items = items super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) menuView.backgroundColor = .white menuView.layer.cornerRadius = 8 menuView.layer.shadowColor = UIColor.black.cgColor menuView.layer.shadowOpacity = 0.16 menuView.layer.shadowRadius = 10 menuView.layer.shadowOffset = CGSize(width: 0, height: 4) stackView.axis = .vertical view.addSubview(dismissButton) view.addSubview(menuView) menuView.addSubview(stackView) dismissButton.snp.makeConstraints { make in make.edges.equalToSuperview() } stackView.snp.makeConstraints { make in make.edges.equalToSuperview() } items.enumerated().forEach { index, item in let button = UIButton(type: .system) var configuration = UIButton.Configuration.plain() configuration.title = item.title configuration.baseForegroundColor = item.isSelected ? AppColor.primary : UIColor(hex: 0x333333) configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) configuration.titleAlignment = .leading configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in var attributes = attributes attributes.font = .systemFont(ofSize: 14) return attributes } button.configuration = configuration button.tag = index button.addTarget(self, action: #selector(itemTapped(_:)), for: .touchUpInside) button.snp.makeConstraints { make in make.height.equalTo(48) } stackView.addArrangedSubview(button) } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() guard let sourceView else { return } let sourceFrame = sourceView.convert(sourceView.bounds, to: view) let width: CGFloat = 165 let height = CGFloat(items.count) * 48 let x = min(max(8, sourceFrame.minX), view.bounds.width - width - 8) var y = sourceFrame.maxY if y + height > view.bounds.height - 8 { y = sourceFrame.minY - height } menuView.frame = CGRect(x: x, y: y, width: width, height: height) } @objc private func dismissTapped() { dismiss(animated: false) } @objc private func itemTapped(_ sender: UIButton) { guard items.indices.contains(sender.tag) else { return } let handler = items[sender.tag].handler dismiss(animated: false, completion: handler) } }