// // CloudDriveTransferViewController.swift // suixinkan // import SnapKit import UIKit /// 云盘传输管理页,严格对齐 Android `CloudStorageTransitScreen`。 final class CloudDriveTransferViewController: BaseViewController { private enum Tab: Int { case upload case download } private enum Section { case main } private let manager: CloudTransferManager private let navigationDivider = UIView() private let tabContainer = UIView() private let uploadTabButton = UIButton(type: .system) private let downloadTabButton = UIButton(type: .system) private let tabIndicator = UIView() private let tableView = UITableView(frame: .zero, style: .plain) private var dataSource: UITableViewDiffableDataSource! private var selectedTab: Tab = .upload private var previousStandardAppearance: UINavigationBarAppearance? private var previousScrollEdgeAppearance: UINavigationBarAppearance? private var previousCompactAppearance: UINavigationBarAppearance? private var previousNavigationTintColor: UIColor? /// 初始化传输管理页。 init(manager: CloudTransferManager? = nil) { self.manager = manager ?? .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) } override func setupUI() { view.backgroundColor = AppColor.pageBackground navigationDivider.backgroundColor = UIColor(hex: 0xEEEEEE) tabContainer.backgroundColor = .white configureTabButton(uploadTabButton, title: "上传", action: #selector(uploadTabTapped)) configureTabButton(downloadTabButton, title: "下载", action: #selector(downloadTabTapped)) tabIndicator.backgroundColor = AppColor.primary tableView.backgroundColor = AppColor.pageBackground tableView.separatorStyle = .none tableView.showsVerticalScrollIndicator = false tableView.contentInsetAdjustmentBehavior = .never tableView.delegate = self tableView.register(CloudTransferCell.self, forCellReuseIdentifier: CloudTransferCell.reuseIdentifier) view.addSubview(navigationDivider) view.addSubview(tabContainer) tabContainer.addSubview(uploadTabButton) tabContainer.addSubview(downloadTabButton) tabContainer.addSubview(tabIndicator) view.addSubview(tableView) configureDataSource() } override func setupConstraints() { navigationDivider.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } tabContainer.snp.makeConstraints { make in make.top.equalTo(navigationDivider.snp.bottom) make.leading.trailing.equalToSuperview() make.height.equalTo(48) } uploadTabButton.snp.makeConstraints { make in make.top.leading.bottom.equalToSuperview() make.width.equalToSuperview().multipliedBy(0.5) } downloadTabButton.snp.makeConstraints { make in make.top.trailing.bottom.equalToSuperview() make.width.equalTo(uploadTabButton) } tabIndicator.snp.makeConstraints { make in make.bottom.equalToSuperview() make.height.equalTo(2) make.width.equalToSuperview().multipliedBy(0.5) make.leading.equalToSuperview() } tableView.snp.makeConstraints { make in make.top.equalTo(tabContainer.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } } override func bindActions() { manager.onTasksChange = { [weak self] in Task { @MainActor in self?.applySnapshot(animated: true) } } updateTabs(animated: false) applySnapshot(animated: false) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) applyCloudNavigationBarAppearance() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) restoreNavigationBarAppearance() } private var visibleTasks: [CloudTransferTask] { selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks } private func configureTabButton(_ button: UIButton, title: String, action: Selector) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14) button.addTarget(self, action: action, for: .touchUpInside) } 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 } private func configureDataSource() { dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, taskID in guard let self, let task = self.visibleTasks.first(where: { $0.id == taskID }), let cell = tableView.dequeueReusableCell( withIdentifier: CloudTransferCell.reuseIdentifier, for: indexPath ) as? CloudTransferCell else { return UITableViewCell() } cell.apply(task: task) cell.onPrimaryAction = { [weak self] in guard let self else { return } switch task.kind { case .upload: task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id) case .download: task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id) } } cell.onCancel = { [weak self] in guard let self else { return } task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id) } return cell } dataSource.defaultRowAnimation = .fade } private func applySnapshot(animated: Bool) { let previousIDs = Set(dataSource.snapshot().itemIdentifiers) let taskIDs = visibleTasks.map(\.id) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(taskIDs) snapshot.reconfigureItems(taskIDs.filter(previousIDs.contains)) dataSource.apply(snapshot, animatingDifferences: animated) } private func updateTabs(animated: Bool) { let uploadSelected = selectedTab == .upload uploadTabButton.setTitleColor(uploadSelected ? AppColor.primary : UIColor(hex: 0x333333), for: .normal) downloadTabButton.setTitleColor(uploadSelected ? UIColor(hex: 0x333333) : AppColor.primary, for: .normal) uploadTabButton.titleLabel?.font = .systemFont(ofSize: 14, weight: uploadSelected ? .medium : .regular) downloadTabButton.titleLabel?.font = .systemFont(ofSize: 14, weight: uploadSelected ? .regular : .medium) tabIndicator.snp.updateConstraints { make in make.leading.equalToSuperview().offset(uploadSelected ? 0 : view.bounds.width / 2) } guard animated else { tabContainer.layoutIfNeeded() return } UIView.animate(withDuration: 0.2) { self.tabContainer.layoutIfNeeded() } } private func selectTab(_ tab: Tab) { guard selectedTab != tab else { return } selectedTab = tab updateTabs(animated: true) applySnapshot(animated: true) tableView.setContentOffset(.zero, animated: false) } @objc private func backTapped() { navigationController?.popViewController(animated: true) } @objc private func uploadTabTapped() { selectTab(.upload) } @objc private func downloadTabTapped() { selectTab(.download) } } extension CloudDriveTransferViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 119 } } /// 云盘传输任务 Cell,严格对齐 Android 上传/下载任务卡片。 private final class CloudTransferCell: UITableViewCell { static let reuseIdentifier = "CloudTransferCell" var onPrimaryAction: (() -> Void)? var onCancel: (() -> Void)? private let cardView = UIView() private let iconView = UIImageView() private let titleLabel = UILabel() private let statusLabel = UILabel() private let primaryButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system) private let progressView = UIProgressView(progressViewStyle: .default) private let percentLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() onPrimaryAction = nil onCancel = nil } /// 应用传输任务状态。 func apply(task: CloudTransferTask) { iconView.image = CloudDriveAsset.image( named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill" ) titleLabel.text = task.fileName statusLabel.text = statusText(for: task) progressView.progress = Float(task.progress) / 100 percentLabel.text = "\(task.progress)%" let isRunning = task.status == .uploading || task.status == .downloading let canResume = task.status == .paused || task.status == .failed primaryButton.setImage( UIImage(systemName: isRunning ? "pause.fill" : "play.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20)), for: .normal ) primaryButton.isHidden = !(isRunning || canResume) } private func statusText(for task: CloudTransferTask) -> String { switch task.kind { case .upload: let status: String switch task.status { case .uploading: status = "上传中" case .paused: status = "已暂停" case .failed: status = "失败" default: status = "" } let size = CloudDriveFormatter.fileSize(task.fileSize) return status.isEmpty ? size : "\(status) \(size)" case .download: switch task.status { case .downloading: return "下载中" case .paused: return "已暂停" case .failed: return "失败" case .completed: return "已完成" default: return "" } } } private func setupUI() { backgroundColor = .clear selectionStyle = .none cardView.backgroundColor = .white cardView.layer.cornerRadius = 8 cardView.clipsToBounds = true iconView.contentMode = .scaleAspectFit titleLabel.font = .systemFont(ofSize: 15) titleLabel.textColor = UIColor(hex: 0x333333) titleLabel.lineBreakMode = .byTruncatingMiddle statusLabel.font = .systemFont(ofSize: 12) statusLabel.textColor = .gray progressView.progressTintColor = AppColor.primary progressView.trackTintColor = UIColor(hex: 0xE5E7EB) progressView.transform = CGAffineTransform(scaleX: 1, y: 2) percentLabel.font = .systemFont(ofSize: 11) percentLabel.textColor = UIColor(hex: 0x4B5563) percentLabel.textAlignment = .right primaryButton.tintColor = UIColor(hex: 0x333333) cancelButton.tintColor = UIColor(hex: 0x333333) cancelButton.setImage( UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20)), for: .normal ) primaryButton.accessibilityLabel = "暂停或继续" cancelButton.accessibilityLabel = "取消任务" primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) contentView.addSubview(cardView) [iconView, titleLabel, statusLabel, primaryButton, cancelButton, progressView, percentLabel] .forEach(cardView.addSubview) } private func setupConstraints() { cardView.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) make.leading.trailing.equalToSuperview().inset(12) make.bottom.equalToSuperview() } iconView.snp.makeConstraints { make in make.leading.top.equalToSuperview().offset(12) make.width.height.equalTo(48) } cancelButton.snp.makeConstraints { make in make.trailing.equalToSuperview() make.top.equalToSuperview().offset(8) make.width.height.equalTo(44) } primaryButton.snp.makeConstraints { make in make.trailing.equalTo(cancelButton.snp.leading) make.centerY.equalTo(cancelButton) make.width.height.equalTo(44) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(12) make.top.equalTo(iconView) make.trailing.equalTo(primaryButton.snp.leading).offset(-4) } statusLabel.snp.makeConstraints { make in make.leading.trailing.equalTo(titleLabel) make.top.equalTo(titleLabel.snp.bottom).offset(4) } progressView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.trailing.equalToSuperview().inset(12) make.top.equalTo(iconView.snp.bottom).offset(12) make.height.equalTo(4) } percentLabel.snp.makeConstraints { make in make.top.equalTo(progressView.snp.bottom).offset(4) make.trailing.equalTo(progressView) make.bottom.equalToSuperview().inset(12) } } @objc private func primaryTapped() { onPrimaryAction?() } @objc private func cancelTapped() { onCancel?() } }