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

530 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// CloudDriveListViewController.swift
// suixinkan
//
import Kingfisher
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 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 = UIButton(type: .system)
private let filterButton = UIButton(type: .system)
private let displayModeButton = UIButton(type: .system)
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, CloudFile>!
private let uploadButton = AppButton(title: "上传")
private let bottomBar = UIView()
///
init(
api: (any CloudDriveServing)? = nil,
transferManager: CloudTransferManager = .shared
) {
self.api = api ?? NetworkServices.shared.cloudDriveAPI
self.transferManager = transferManager
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "相册云盘"
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: CloudDriveAsset.image(named: "icon_cloud_storage_transit", fallbackSystemName: "arrow.up.arrow.down.circle"),
style: .plain,
target: self,
action: #selector(openTransferManager)
)
}
override func setupUI() {
view.backgroundColor = .white
pathScrollView.showsHorizontalScrollIndicator = false
pathStackView.axis = .horizontal
pathStackView.alignment = .center
pathStackView.spacing = AppSpacing.xs
pathScrollView.addSubview(pathStackView)
searchContainer.backgroundColor = AppColor.inputBackground
searchContainer.layer.cornerRadius = AppRadius.xs
searchIconView.tintColor = AppColor.textTertiary
searchField.placeholder = "搜索文件名称"
searchField.font = .app(.body)
searchField.textColor = AppColor.textPrimary
searchField.returnKeyType = .search
searchField.delegate = self
searchContainer.addSubview(searchIconView)
searchContainer.addSubview(searchField)
configureFilterButton(sortButton)
configureFilterButton(filterButton)
displayModeButton.tintColor = AppColor.textPrimary
displayModeButton.backgroundColor = .white
displayModeButton.layer.cornerRadius = AppRadius.xs
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.register(CloudDriveFileCell.self, forCellWithReuseIdentifier: CloudDriveFileCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, CloudFile>(collectionView: collectionView) { [weak self] collectionView, indexPath, file in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CloudDriveFileCell.reuseIdentifier,
for: indexPath
) as! CloudDriveFileCell
cell.apply(file: file, mode: self?.viewModel.displayMode ?? .grid)
cell.onDetails = { [weak self] in self?.showControlSheet(for: file) }
return cell
}
bottomBar.backgroundColor = .white
bottomBar.layer.borderColor = AppColor.border.cgColor
bottomBar.layer.borderWidth = 0.5
view.addSubview(pathScrollView)
view.addSubview(searchContainer)
view.addSubview(sortButton)
view.addSubview(filterButton)
view.addSubview(displayModeButton)
view.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(uploadButton)
}
override func setupConstraints() {
pathScrollView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
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(AppSpacing.md)
make.height.equalTo(36)
}
searchIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.sm)
make.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
searchField.snp.makeConstraints { make in
make.leading.equalTo(searchIconView.snp.trailing).offset(AppSpacing.sm)
make.top.bottom.trailing.equalToSuperview().inset(AppSpacing.xs)
}
sortButton.snp.makeConstraints { make in
make.top.equalTo(searchContainer.snp.bottom).offset(AppSpacing.md)
make.leading.equalToSuperview().offset(AppSpacing.md)
make.height.equalTo(36)
}
filterButton.snp.makeConstraints { make in
make.top.width.height.equalTo(sortButton)
make.leading.equalTo(sortButton.snp.trailing).offset(AppSpacing.sm)
}
displayModeButton.snp.makeConstraints { make in
make.centerY.equalTo(sortButton)
make.leading.equalTo(filterButton.snp.trailing).offset(AppSpacing.sm)
make.trailing.equalToSuperview().inset(6)
make.width.height.equalTo(36)
}
sortButton.snp.makeConstraints { make in
make.width.equalTo(filterButton)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
uploadButton.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(sortButton.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
}
override func bindActions() {
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside)
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) }
}
configureMenus()
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: api, showLoading: true) }
}
@MainActor
private func applyViewModel() {
rebuildBreadcrumb()
searchField.text = viewModel.searchText
sortButton.setTitle(viewModel.sortType.title, for: .normal)
filterButton.setTitle(viewModel.filterType.title, for: .normal)
let displayIconName = viewModel.displayMode == .grid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table"
let displayFallback = viewModel.displayMode == .grid ? "list.bullet" : "square.grid.2x2"
displayModeButton.setImage(CloudDriveAsset.image(named: displayIconName, fallbackSystemName: displayFallback), for: .normal)
sortButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down"), for: .normal)
filterButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle"), for: .normal)
collectionView.setCollectionViewLayout(makeLayout(), animated: false)
var snapshot = NSDiffableDataSourceSnapshot<Int, CloudFile>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files)
dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.files.isEmpty {
showLoading()
} else {
hideLoading()
}
}
private func rebuildBreadcrumb() {
pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
let homeButton = UIButton(type: .system)
homeButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill"), for: .normal)
homeButton.tintColor = AppColor.textPrimary
homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside)
homeButton.snp.makeConstraints { make in
make.width.height.equalTo(24)
}
pathStackView.addArrangedSubview(homeButton)
for (index, item) in viewModel.pathStack.enumerated() {
if index > 0 {
let arrow = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right"))
arrow.tintColor = AppColor.textTertiary
arrow.snp.makeConstraints { make in
make.width.equalTo(8)
}
pathStackView.addArrangedSubview(arrow)
}
let button = UIButton(type: .system)
button.setTitle(item.name, for: .normal)
button.titleLabel?.font = .app(index == viewModel.pathStack.count - 1 ? .body : .caption)
button.setTitleColor(index == viewModel.pathStack.count - 1 ? .black : AppColor.textSecondary, for: .normal)
button.tag = index
button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside)
pathStackView.addArrangedSubview(button)
}
}
private func configureMenus() {
sortButton.menu = UIMenu(children: CloudDriveSortType.allCases.map { type in
UIAction(title: type.title) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.chooseSortType(type, api: self.api) }
}
})
sortButton.showsMenuAsPrimaryAction = true
filterButton.menu = UIMenu(children: CloudDriveFilterType.allCases.map { type in
UIAction(title: type.title) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.chooseFilterType(type, api: self.api) }
}
})
filterButton.showsMenuAsPrimaryAction = true
}
private func configureFilterButton(_ button: UIButton) {
button.backgroundColor = AppColor.inputBackground
button.layer.cornerRadius = AppRadius.xs
button.titleLabel?.font = .app(.caption)
button.setTitleColor(AppColor.textPrimary, for: .normal)
button.contentHorizontalAlignment = .left
button.imageView?.contentMode = .scaleAspectFit
button.tintColor = AppColor.textPrimary
button.semanticContentAttribute = .forceLeftToRight
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.xs)
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.sm, bottom: 0, right: AppSpacing.sm)
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] _, environment in
let mode = self?.viewModel.displayMode ?? .grid
switch mode {
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 itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemWidth * 0.82))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemWidth * 0.82))
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: horizontalInset, bottom: 12, trailing: horizontalInset)
section.interGroupSpacing = spacing
return section
case .list:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: AppSpacing.md, bottom: AppSpacing.md, trailing: AppSpacing.md)
return section
}
}
}
private func showControlSheet(for file: CloudFile) {
viewModel.setControlFile(file)
let sheet = CloudDriveActionSheetViewController(title: "请选择", items: [
CloudDriveActionSheetViewController.Item(
title: "删除",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"),
isDestructive: true
) { [weak self] in self?.confirmDelete() },
CloudDriveActionSheetViewController.Item(
title: "下载",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"),
isDestructive: false
) { [weak self] in self?.download(file) },
CloudDriveActionSheetViewController.Item(
title: "移动",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"),
isDestructive: false
) { [weak self] in self?.showMoveDialog() },
CloudDriveActionSheetViewController.Item(
title: "详情",
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_item_details", fallbackSystemName: "info.circle"),
isDestructive: false
) { [weak self] in self?.showDetailDialog(file) }
])
present(sheet, animated: true)
}
private func confirmDelete() {
showAlert(title: "删除确认", message: "删除后无法恢复,确认删除吗?") { [weak self] in
guard let self else { return }
Task { await self.viewModel.deleteCurrentFile(api: self.api) }
}
}
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 items = viewModel.moveTargetFolders().map { folder in
CloudDriveActionSheetViewController.Item(
title: folder.name,
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_type_folder", fallbackSystemName: "folder"),
isDestructive: false
) { [weak self] in
guard let self else { return }
Task { await self.viewModel.moveCurrentFile(targetFolderId: folder.id, api: self.api) }
}
}
present(CloudDriveActionSheetViewController(title: "移动到", items: items), animated: true)
}
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: true)
}
@objc private func uploadTapped() {
let sheet = CloudDriveActionSheetViewController(title: "选择上传类型", items: [
CloudDriveActionSheetViewController.Item(
title: "上传图片",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"),
isDestructive: false
) { [weak self] in self?.beginPick(type: 1) },
CloudDriveActionSheetViewController.Item(
title: "上传视频",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"),
isDestructive: false
) { [weak self] in self?.beginPick(type: 2) },
CloudDriveActionSheetViewController.Item(
title: "新建文件夹",
image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"),
isDestructive: false
) { [weak self] in self?.showCreateFolderDialog() }
])
present(sheet, animated: true)
}
private func beginPick(type: Int) {
Task {
guard await viewModel.checkCanUpload(api: api) else { return }
await MainActor.run {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
configuration.filter = type == 1 ? .images : .videos
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.tag = type
present(picker, animated: true)
}
}
}
private func showCreateFolderDialog() {
guard viewModel.pathStack.count < 5 else {
showToast("不能创建更深的文件夹")
return
}
let alert = UIAlertController(title: "新建文件夹", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "请输入文件夹名称"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self, weak alert] _ in
guard let self else { return }
let name = alert?.textFields?.first?.text ?? ""
Task { await self.viewModel.addFolder(name: name, api: self.api) }
})
present(alert, animated: true)
}
@objc private func displayModeTapped() {
viewModel.toggleDisplayMode()
}
@objc private func openTransferManager() {
navigationController?.pushViewController(CloudDriveTransferViewController(manager: transferManager), animated: true)
}
@objc private func homeTapped() {
Task { await viewModel.goHome(api: api) }
}
@objc private func pathTapped(_ sender: UIButton) {
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 size = (try? FileManager.default.attributesOfItem(atPath: stagedURL.path)[.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)")
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
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) else { return }
Task {
if let preview = await viewModel.handleFileTap(file, api: api) {
await MainActor.run { self.openPreview(file: preview) }
}
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let frameHeight = scrollView.frame.size.height
if offsetY > contentHeight - frameHeight - 120 {
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 type = picker.view.tag
picker.dismiss(animated: true)
guard let result = results.first else { return }
stagePickedFile(result: result, fileType: type == 2 ? 1 : 2)
}
}