Files
suixinkan_uikit/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift
T

888 lines
37 KiB
Swift

//
// 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<Int, CloudFile>!
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<Int, CloudFile>(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<Int, CloudFile>()
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)
}
}