888 lines
37 KiB
Swift
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)
|
||
}
|
||
}
|