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)
|
|
}
|
|
}
|