同步相册云盘界面并完善相关交互

This commit is contained in:
2026-07-10 17:15:15 +08:00
parent ab5220e460
commit ceca780ab3
13 changed files with 2269 additions and 532 deletions

View File

@ -6,8 +6,14 @@
import SnapKit
import UIKit
/// Android
/// Android
final class CloudDriveActionSheetViewController: UIViewController {
///
enum Layout {
case upload
case controls
}
///
struct Item {
///
@ -20,19 +26,20 @@ final class CloudDriveActionSheetViewController: UIViewController {
let handler: () -> Void
}
private let sheetTitle: String?
private let sheetTitle: String
private let layout: Layout
private let items: [Item]
private let dimView = UIView()
private let panelView = UIView()
private let stackView = UIStackView()
private let contentStack = UIStackView()
///
init(title: String? = nil, items: [Item]) {
self.sheetTitle = title
/// Android
init(title: String, layout: Layout, items: [Item]) {
sheetTitle = title
self.layout = layout
self.items = items
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
@ -46,92 +53,607 @@ final class CloudDriveActionSheetViewController: UIViewController {
setupConstraints()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
dimView.alpha = 0
panelView.transform = CGAffineTransform(translationX: 0, y: 420)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 0.24, delay: 0, options: [.curveEaseOut]) {
self.dimView.alpha = 1
self.panelView.transform = .identity
}
}
private func setupUI() {
view.backgroundColor = .clear
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
panelView.backgroundColor = .white
panelView.layer.cornerRadius = 18
panelView.layer.cornerRadius = 16
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
stackView.axis = .vertical
stackView.spacing = 0
contentStack.axis = .vertical
contentStack.spacing = 0
view.addSubview(dimView)
view.addSubview(panelView)
panelView.addSubview(stackView)
panelView.addSubview(contentStack)
if let sheetTitle {
let titleLabel = UILabel()
titleLabel.text = sheetTitle
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
titleLabel.snp.makeConstraints { make in
make.height.equalTo(54)
}
stackView.addArrangedSubview(titleLabel)
let titleContainer = UIView()
let titleLabel = UILabel()
titleLabel.text = sheetTitle
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = .black
titleLabel.textAlignment = .left
titleContainer.addSubview(titleLabel)
titleContainer.snp.makeConstraints { make in make.height.equalTo(52) }
titleLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
}
contentStack.addArrangedSubview(titleContainer)
let titleDivider = UIView()
titleDivider.backgroundColor = UIColor(hex: 0xEEEEEE)
titleDivider.snp.makeConstraints { make in make.height.equalTo(0.5) }
contentStack.addArrangedSubview(titleDivider)
switch layout {
case .upload:
contentStack.addArrangedSubview(makeUploadContent())
case .controls:
contentStack.addArrangedSubview(makeControlsContent())
}
for item in items {
stackView.addArrangedSubview(makeItemButton(item))
}
let spacer = UIView()
spacer.backgroundColor = AppColor.pageBackground
spacer.snp.makeConstraints { make in
make.height.equalTo(8)
}
stackView.addArrangedSubview(spacer)
let cancelButton = UIButton(type: .system)
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textPrimary, for: .normal)
cancelButton.titleLabel?.font = .app(.body)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancelButton.snp.makeConstraints { make in
make.height.equalTo(54)
}
stackView.addArrangedSubview(cancelButton)
let divider = UIView()
divider.backgroundColor = UIColor(hex: 0xEEEEEE)
divider.snp.makeConstraints { make in make.height.equalTo(0.5) }
contentStack.addArrangedSubview(divider)
contentStack.addArrangedSubview(makeCancelArea())
let bottomSpacer = UIView()
bottomSpacer.snp.makeConstraints { make in make.height.equalTo(12) }
contentStack.addArrangedSubview(bottomSpacer)
}
private func setupConstraints() {
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
panelView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.greaterThanOrEqualTo(view.safeAreaLayoutGuide).offset(20)
}
stackView.snp.makeConstraints { make in
contentStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
private func makeItemButton(_ item: Item) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(item.title, for: .normal)
button.setImage(item.image, for: .normal)
button.tintColor = item.isDestructive ? AppColor.danger : AppColor.textPrimary
button.setTitleColor(item.isDestructive ? AppColor.danger : AppColor.textPrimary, for: .normal)
button.titleLabel?.font = .app(.body)
button.contentHorizontalAlignment = .left
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.xl, bottom: 0, right: AppSpacing.xl)
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.md)
button.addAction(UIAction { [weak self] _ in
self?.dismiss(animated: true) {
item.handler()
private func makeUploadContent() -> UIView {
let container = UIView()
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 12
container.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
items.enumerated().forEach { index, item in
let row = UIView()
row.layer.cornerRadius = 8
row.layer.borderWidth = 1
row.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
let iconView = UIImageView(image: item.image?.withRenderingMode(.alwaysOriginal))
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = item.title
label.font = .systemFont(ofSize: 14, weight: .bold)
label.textColor = .black
let button = UIButton(type: .custom)
button.tag = index
button.accessibilityLabel = item.title
button.addTarget(self, action: #selector(itemTapped(_:)), for: .touchUpInside)
row.addSubview(iconView)
row.addSubview(label)
row.addSubview(button)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.width.height.equalTo(32)
}
}, for: .touchUpInside)
button.snp.makeConstraints { make in
make.height.equalTo(54)
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview().inset(16)
}
button.snp.makeConstraints { make in make.edges.equalToSuperview() }
row.snp.makeConstraints { make in make.height.equalTo(56) }
stack.addArrangedSubview(row)
}
return container
}
private func makeControlsContent() -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.distribution = .fillEqually
items.enumerated().forEach { index, item in
let container = UIView()
let iconView = UIImageView(image: item.image?.withRenderingMode(.alwaysOriginal))
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = item.title
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x4B5563)
label.textAlignment = .center
let button = UIButton(type: .custom)
button.tag = index
button.accessibilityLabel = item.title
button.addTarget(self, action: #selector(itemTapped(_:)), for: .touchUpInside)
container.addSubview(iconView)
container.addSubview(label)
container.addSubview(button)
iconView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(28)
make.centerX.equalToSuperview()
make.width.height.equalTo(24)
}
label.snp.makeConstraints { make in
make.top.equalTo(iconView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(4)
}
button.snp.makeConstraints { make in make.edges.equalToSuperview() }
container.snp.makeConstraints { make in make.height.equalTo(105) }
row.addArrangedSubview(container)
}
return row
}
private func makeCancelArea() -> UIView {
let container = UIView()
let button = UIButton(type: .system)
button.setTitle("取消", for: .normal)
button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = UIColor(hex: 0xF4F4F4)
button.layer.cornerRadius = 12
button.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
container.addSubview(button)
button.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.height.equalTo(48)
}
return container
}
private func close(completion: (() -> Void)? = nil) {
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) {
self.dimView.alpha = 0
self.panelView.transform = CGAffineTransform(translationX: 0, y: self.panelView.bounds.height + 40)
} completion: { _ in
self.dismiss(animated: false, completion: completion)
}
}
@objc private func itemTapped(_ sender: UIButton) {
guard items.indices.contains(sender.tag) else { return }
let handler = items[sender.tag].handler
close(completion: handler)
}
@objc private func cancelTapped() {
close()
}
}
/// Android
class CloudDriveOverlayDialogViewController: UIViewController {
let dimView = UIView()
let cardView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
modalPresentationStyle = .overFullScreen
view.backgroundColor = .clear
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
view.addSubview(dimView)
view.addSubview(cardView)
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelOverlay)))
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
dimView.alpha = 0
cardView.alpha = 0
cardView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 0.2) {
self.dimView.alpha = 1
self.cardView.alpha = 1
self.cardView.transform = .identity
}
}
func dismissOverlay(completion: (() -> Void)? = nil) {
UIView.animate(withDuration: 0.16) {
self.dimView.alpha = 0
self.cardView.alpha = 0
self.cardView.transform = CGAffineTransform(scaleX: 0.96, y: 0.96)
} completion: { _ in
self.dismiss(animated: false, completion: completion)
}
}
@objc private func cancelOverlay() {
dismissOverlay()
}
}
/// Android `WeDialog`
final class CloudDriveConfirmationDialogViewController: CloudDriveOverlayDialogViewController {
private let dialogTitle: String
private let message: String
private let onConfirm: () -> Void
///
init(title: String, message: String, onConfirm: @escaping () -> Void) {
dialogTitle = title
self.message = message
self.onConfirm = onConfirm
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()
let titleLabel = UILabel()
titleLabel.text = dialogTitle
titleLabel.font = .systemFont(ofSize: 17, weight: .bold)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = message
messageLabel.font = .systemFont(ofSize: 17)
messageLabel.textColor = .black
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
let divider = UIView()
divider.backgroundColor = UIColor(hex: 0xD1D5DB)
let verticalDivider = UIView()
verticalDivider.backgroundColor = UIColor(hex: 0xD1D5DB)
let cancelButton = makeDialogButton(title: "取消", color: .black, action: #selector(cancelTapped))
let confirmButton = makeDialogButton(title: "确定", color: AppColor.primary, action: #selector(confirmTapped))
[titleLabel, messageLabel, divider, cancelButton, verticalDivider, confirmButton].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalToSuperview().multipliedBy(0.8)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(32)
make.leading.trailing.equalToSuperview().inset(24)
}
messageLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(24)
}
divider.snp.makeConstraints { make in
make.top.equalTo(messageLabel.snp.bottom).offset(32)
make.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom)
make.leading.bottom.equalToSuperview()
make.height.equalTo(56)
}
confirmButton.snp.makeConstraints { make in
make.top.bottom.width.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing)
make.trailing.equalToSuperview()
}
verticalDivider.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom)
make.bottom.equalToSuperview()
make.centerX.equalToSuperview()
make.width.equalTo(0.5)
}
}
private func makeDialogButton(title: String, color: UIColor, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(color, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 17, weight: .bold)
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
@objc private func cancelTapped() {
dismiss(animated: true)
dismissOverlay()
}
@objc private func confirmTapped() {
dismissOverlay(completion: onConfirm)
}
}
/// Android `AddFolderDialog`
final class CloudDriveTextInputDialogViewController: CloudDriveOverlayDialogViewController {
private let dialogTitle: String
private let placeholder: String
private let onConfirm: (String) -> Void
private let textField = UITextField()
private let confirmButton = UIButton(type: .system)
///
init(title: String, placeholder: String, onConfirm: @escaping (String) -> Void) {
dialogTitle = title
self.placeholder = placeholder
self.onConfirm = onConfirm
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()
cardView.layer.cornerRadius = 16
let titleLabel = UILabel()
titleLabel.text = dialogTitle
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = .black
let divider = UIView()
divider.backgroundColor = UIColor(hex: 0xEEEEEE)
textField.placeholder = placeholder
textField.font = .systemFont(ofSize: 14)
textField.textColor = UIColor(hex: 0x333333)
textField.backgroundColor = UIColor(hex: 0xF4F4F4)
textField.layer.cornerRadius = 8
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
textField.leftViewMode = .always
textField.clearButtonMode = .whileEditing
textField.returnKeyType = .done
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
textField.addTarget(self, action: #selector(confirmTapped), for: .editingDidEndOnExit)
let cancelButton = UIButton(type: .system)
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.primary, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 14)
cancelButton.backgroundColor = UIColor(hex: 0xEFF6FF)
cancelButton.layer.cornerRadius = 8
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.setTitle("确定", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.titleLabel?.font = .systemFont(ofSize: 14)
confirmButton.layer.cornerRadius = 8
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
confirmButton.isEnabled = false
updateConfirmAppearance()
[titleLabel, divider, textField, cancelButton, confirmButton].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(28)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(52)
}
divider.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom)
make.leading.trailing.equalToSuperview()
make.height.equalTo(1)
}
textField.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(48)
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(textField.snp.bottom).offset(20)
make.trailing.bottom.equalToSuperview().inset(16)
make.width.equalTo(72)
make.height.equalTo(38)
}
cancelButton.snp.makeConstraints { make in
make.trailing.equalTo(confirmButton.snp.leading).offset(-8)
make.centerY.width.height.equalTo(confirmButton)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardFrameChanged(_:)),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func updateConfirmAppearance() {
confirmButton.backgroundColor = confirmButton.isEnabled ? AppColor.primary : AppColor.buttonDisabled
}
@objc private func textChanged() {
confirmButton.isEnabled = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
updateConfirmAppearance()
}
@objc private func cancelTapped() {
view.endEditing(true)
dismissOverlay()
}
@objc private func confirmTapped() {
let value = (textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
view.endEditing(true)
dismissOverlay { [onConfirm] in onConfirm(value) }
}
@objc private func keyboardFrameChanged(_ notification: Notification) {
guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let frameInView = view.convert(frame, from: nil)
let overlap = max(0, cardView.frame.maxY + 16 - frameInView.minY)
UIView.animate(withDuration: 0.2) {
self.cardView.transform = CGAffineTransform(translationX: 0, y: -overlap)
}
}
}
/// Android `MoveFileDialog`
final class CloudDriveMoveDialogViewController: CloudDriveOverlayDialogViewController, UITableViewDataSource, UITableViewDelegate {
private let folders: [CloudFile]
private let onConfirm: (Int) -> Void
private let tableView = UITableView(frame: .zero, style: .plain)
private var selectedID: Int
///
init(folders: [CloudFile], onConfirm: @escaping (Int) -> Void) {
self.folders = folders
self.onConfirm = onConfirm
selectedID = folders.first?.id ?? 0
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()
cardView.layer.cornerRadius = 28
let titleLabel = UILabel()
titleLabel.text = "移动到"
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = UIColor(hex: 0x333333)
let messageLabel = UILabel()
messageLabel.text = "请选择目标文件夹"
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textColor = UIColor(hex: 0x666666)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = 44
tableView.dataSource = self
tableView.delegate = self
let cancelButton = makeTextButton(title: "取消", color: UIColor(hex: 0x666666), action: #selector(cancelTapped))
let confirmButton = makeTextButton(title: "确定", color: AppColor.primary, action: #selector(confirmTapped))
[titleLabel, messageLabel, tableView, cancelButton, confirmButton].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(28)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(24)
make.leading.trailing.equalToSuperview().inset(24)
}
messageLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.equalTo(titleLabel)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(messageLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(min(CGFloat(max(folders.count, 1)) * 44, UIScreen.main.bounds.height * 0.6))
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(tableView.snp.bottom).offset(8)
make.trailing.bottom.equalToSuperview().inset(12)
make.width.equalTo(64)
make.height.equalTo(44)
}
cancelButton.snp.makeConstraints { make in
make.trailing.equalTo(confirmButton.snp.leading)
make.centerY.width.height.equalTo(confirmButton)
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
folders.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let reuseIdentifier = "CloudDriveMoveFolderCell"
let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier)
?? UITableViewCell(style: .default, reuseIdentifier: reuseIdentifier)
configure(cell, folder: folders[indexPath.row])
return cell
}
private func configure(_ cell: UITableViewCell, folder: CloudFile) {
cell.textLabel?.text = folder.name
cell.textLabel?.font = .systemFont(ofSize: 14)
cell.textLabel?.textColor = UIColor(hex: 0x333333)
cell.imageView?.image = UIImage(
systemName: selectedID == folder.id ? "circle.inset.filled" : "circle",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20)
)
cell.imageView?.tintColor = AppColor.primary
cell.selectionStyle = .none
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let previousID = selectedID
selectedID = folders[indexPath.row].id
if let previousIndex = folders.firstIndex(where: { $0.id == previousID }),
let previousCell = tableView.cellForRow(at: IndexPath(row: previousIndex, section: 0)) {
configure(previousCell, folder: folders[previousIndex])
}
if let selectedCell = tableView.cellForRow(at: indexPath) {
configure(selectedCell, folder: folders[indexPath.row])
}
}
private func makeTextButton(title: String, color: UIColor, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(color, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
@objc private func cancelTapped() {
dismissOverlay()
}
@objc private func confirmTapped() {
let selectedID = selectedID
dismissOverlay { [onConfirm] in onConfirm(selectedID) }
}
}

View File

@ -6,18 +6,15 @@
import SnapKit
import UIKit
///
/// Android `CloudStorageFileDetailBottomModal`
final class CloudDriveDetailSheetViewController: UIViewController {
private let file: CloudFile
private let onSave: (String) -> Void
private let dimView = UIView()
private let panelView = UIView()
private let titleLabel = UILabel()
private let nameTitleLabel = UILabel()
private let scrollView = UIScrollView()
private let contentView = UIView()
private let nameField = UITextField()
private let infoLabel = UILabel()
private let cancelButton = UIButton(type: .system)
private let saveButton = UIButton(type: .system)
///
init(file: CloudFile, onSave: @escaping (String) -> Void) {
@ -25,7 +22,6 @@ final class CloudDriveDetailSheetViewController: UIViewController {
self.onSave = onSave
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
@ -37,6 +33,25 @@ final class CloudDriveDetailSheetViewController: UIViewController {
super.viewDidLoad()
setupUI()
setupConstraints()
registerKeyboardNotifications()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
dimView.alpha = 0
panelView.transform = CGAffineTransform(translationX: 0, y: 620)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 0.24, delay: 0, options: [.curveEaseOut]) {
self.dimView.alpha = 1
self.panelView.transform = .identity
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func setupUI() {
@ -45,99 +60,241 @@ final class CloudDriveDetailSheetViewController: UIViewController {
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
panelView.backgroundColor = .white
panelView.layer.cornerRadius = 18
panelView.layer.cornerRadius = 28
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
titleLabel.text = "详情"
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
nameTitleLabel.text = "文件名称"
nameTitleLabel.font = .app(.caption)
nameTitleLabel.textColor = AppColor.textSecondary
nameField.text = file.name
nameField.font = .app(.body)
nameField.textColor = AppColor.textPrimary
nameField.clearButtonMode = .whileEditing
nameField.returnKeyType = .done
nameField.delegate = self
nameField.backgroundColor = AppColor.inputBackground
nameField.layer.cornerRadius = AppRadius.xs
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1))
nameField.leftViewMode = .always
infoLabel.text = "\(file.createdAt) | \(CloudDriveFormatter.fileSize(file.fileSize))\n类型:\(CloudDriveFormatter.typeName(file.type))"
infoLabel.font = .app(.caption)
infoLabel.textColor = AppColor.textTertiary
infoLabel.numberOfLines = 0
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.titleLabel?.font = .app(.body)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = .app(.bodyMedium)
saveButton.backgroundColor = AppColor.primary
saveButton.layer.cornerRadius = AppRadius.xs
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
scrollView.alwaysBounceVertical = false
scrollView.keyboardDismissMode = .interactive
view.addSubview(dimView)
view.addSubview(panelView)
[titleLabel, nameTitleLabel, nameField, infoLabel, cancelButton, saveButton].forEach(panelView.addSubview)
panelView.addSubview(scrollView)
scrollView.addSubview(contentView)
let dragHandle = UIView()
dragHandle.backgroundColor = UIColor(hex: 0x79747E)
dragHandle.layer.cornerRadius = 2
let typeIcon = UIImageView(image: fileTypeIcon())
typeIcon.contentMode = .scaleAspectFit
let fileNameLabel = UILabel()
fileNameLabel.text = file.name
fileNameLabel.font = .systemFont(ofSize: 16)
fileNameLabel.textColor = UIColor(hex: 0x333333)
fileNameLabel.numberOfLines = 1
fileNameLabel.lineBreakMode = .byTruncatingTail
let metaLabel = UILabel()
metaLabel.text = "\(file.createdAt) | \(CloudDriveFormatter.fileSize(file.fileSize))"
metaLabel.font = .systemFont(ofSize: 12)
metaLabel.textColor = UIColor(hex: 0x999999)
metaLabel.numberOfLines = 1
let closeButton = UIButton(type: .system)
closeButton.setImage(
UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)),
for: .normal
)
closeButton.tintColor = UIColor(hex: 0x333333)
closeButton.accessibilityLabel = "关闭"
closeButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let nameTitleLabel = makeFieldTitle("文件名称")
nameField.text = file.name
nameField.placeholder = file.name
nameField.font = .systemFont(ofSize: 16)
nameField.textColor = UIColor(hex: 0x333333)
nameField.clearButtonMode = .whileEditing
nameField.returnKeyType = .done
nameField.delegate = self
nameField.backgroundColor = .white
nameField.layer.cornerRadius = 4
nameField.layer.borderWidth = 1
nameField.layer.borderColor = UIColor(hex: 0x9CA3AF).cgColor
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
nameField.leftViewMode = .always
let infoStack = UIStackView()
infoStack.axis = .vertical
infoStack.spacing = 0
infoStack.addArrangedSubview(makeInfoRow(label: "文件类型", value: detailTypeText()))
if file.isFolder {
infoStack.addArrangedSubview(makeInfoRow(label: "项目数量", value: String(file.childNum)))
} else {
infoStack.addArrangedSubview(makeInfoRow(label: "文件大小", value: CloudDriveFormatter.fileSize(file.fileSize)))
}
infoStack.addArrangedSubview(makeInfoRow(label: "创建时间", value: file.createdAt))
infoStack.addArrangedSubview(makeInfoRow(label: "修改时间", value: file.updatedAt))
let saveButton = UIButton(type: .system)
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
saveButton.backgroundColor = AppColor.primary
saveButton.layer.cornerRadius = 10
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
[dragHandle, typeIcon, fileNameLabel, metaLabel, closeButton, nameTitleLabel, nameField, infoStack, saveButton]
.forEach(contentView.addSubview)
dragHandle.snp.makeConstraints { make in
make.top.equalToSuperview().offset(15)
make.centerX.equalToSuperview()
make.width.equalTo(40)
make.height.equalTo(4)
}
typeIcon.snp.makeConstraints { make in
make.top.equalTo(dragHandle.snp.bottom).offset(24)
make.leading.equalToSuperview().offset(16)
make.width.height.equalTo(40)
}
closeButton.snp.makeConstraints { make in
make.centerY.equalTo(typeIcon)
make.trailing.equalToSuperview().inset(4)
make.width.height.equalTo(44)
}
fileNameLabel.snp.makeConstraints { make in
make.top.equalTo(typeIcon)
make.leading.equalTo(typeIcon.snp.trailing).offset(12)
make.trailing.equalTo(closeButton.snp.leading).offset(-8)
}
metaLabel.snp.makeConstraints { make in
make.top.equalTo(fileNameLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(fileNameLabel)
}
nameTitleLabel.snp.makeConstraints { make in
make.top.equalTo(typeIcon.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
nameField.snp.makeConstraints { make in
make.top.equalTo(nameTitleLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(nameTitleLabel)
make.height.equalTo(52)
}
infoStack.snp.makeConstraints { make in
make.top.equalTo(nameField.snp.bottom).offset(10)
make.leading.trailing.equalTo(nameField)
}
saveButton.snp.makeConstraints { make in
make.top.equalTo(infoStack.snp.bottom).offset(20)
make.leading.trailing.equalTo(nameField)
make.height.equalTo(44)
make.bottom.equalToSuperview().inset(12)
}
}
private func setupConstraints() {
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
panelView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview()
make.top.greaterThanOrEqualTo(view.safeAreaLayoutGuide).offset(16)
make.height.equalTo(590).priority(.high)
}
titleLabel.snp.makeConstraints { make in
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(54)
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
nameTitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
contentView.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide)
make.width.equalTo(scrollView.frameLayoutGuide)
}
nameField.snp.makeConstraints { make in
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalTo(nameTitleLabel)
make.height.equalTo(42)
}
private func makeFieldTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 13)
label.textColor = UIColor(hex: 0x7B8EAA)
return label
}
private func makeInfoRow(label: String, value: String) -> UIView {
let container = UIView()
let labelView = makeFieldTitle(label)
let valueContainer = UIView()
valueContainer.backgroundColor = UIColor(hex: 0xF4F4F4)
valueContainer.layer.cornerRadius = 6
let valueLabel = UILabel()
valueLabel.text = value
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = UIColor(hex: 0x333333)
valueLabel.numberOfLines = 1
valueLabel.lineBreakMode = .byTruncatingTail
container.addSubview(labelView)
container.addSubview(valueContainer)
valueContainer.addSubview(valueLabel)
labelView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(nameField.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalTo(nameTitleLabel)
valueContainer.snp.makeConstraints { make in
make.top.equalTo(labelView.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview()
make.height.equalTo(36)
make.bottom.equalToSuperview().inset(6)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(infoLabel.snp.bottom).offset(AppSpacing.lg)
make.leading.equalToSuperview().offset(AppSpacing.md)
make.height.equalTo(44)
make.width.equalTo(saveButton)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
valueLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
saveButton.snp.makeConstraints { make in
make.top.bottom.width.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing).offset(AppSpacing.md)
make.trailing.equalToSuperview().inset(AppSpacing.md)
return container
}
private func fileTypeIcon() -> UIImage? {
if file.isFolder {
return CloudDriveAsset.image(named: "icon_cloud_storage_file_type_folder", fallbackSystemName: "folder.fill")
}
if file.isVideo {
return CloudDriveAsset.image(named: "icon_cloud_storage_file_type_video", fallbackSystemName: "video.fill")
}
return CloudDriveAsset.image(named: "icon_cloud_storage_file_type_photo", fallbackSystemName: "photo.fill")
}
private func detailTypeText() -> String {
if file.isFolder { return "文件夹" }
let suffix = (file.name as NSString).pathExtension.uppercased()
return suffix.isEmpty ? "文件" : suffix
}
private func registerKeyboardNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardFrameChanged(_:)),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
}
private func close(completion: (() -> Void)? = nil) {
view.endEditing(true)
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) {
self.dimView.alpha = 0
self.panelView.transform = CGAffineTransform(translationX: 0, y: self.panelView.bounds.height + 40)
} completion: { _ in
self.dismiss(animated: false, completion: completion)
}
}
@objc private func cancelTapped() {
dismiss(animated: true)
close()
}
@objc private func saveTapped() {
let name = nameField.text ?? ""
dismiss(animated: true) { [onSave] in
onSave(name)
}
let name = (nameField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
close { [onSave] in onSave(name) }
}
@objc private func keyboardFrameChanged(_ notification: Notification) {
guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
let frameInView = view.convert(frame, from: nil)
let overlap = max(0, view.bounds.maxY - frameInView.minY)
scrollView.contentInset.bottom = overlap
scrollView.verticalScrollIndicatorInsets.bottom = overlap
guard overlap > 0 else { return }
let fieldFrame = nameField.convert(nameField.bounds, to: scrollView)
scrollView.scrollRectToVisible(fieldFrame.insetBy(dx: 0, dy: -16), animated: true)
}
}

View File

@ -7,15 +7,20 @@ import Kingfisher
import SnapKit
import UIKit
/// Cell Android
/// Cell Android
final class CloudDriveFileCell: UICollectionViewCell {
static let reuseIdentifier = "CloudDriveFileCell"
///
var onDetails: (() -> Void)?
private let thumbnailView = UIImageView()
private let folderIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill"))
private let playIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill"))
private let folderIconView = UIImageView(
image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill")
)
private let playIconView = UIImageView(
image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill")
)
private let extensionLabel = CloudDrivePaddingLabel()
private let nameLabel = UILabel()
private let detailLabel = UILabel()
@ -47,126 +52,151 @@ final class CloudDriveFileCell: UICollectionViewCell {
///
func apply(file: CloudFile, mode: CloudDriveDisplayMode) {
let isGrid = mode == .grid
gridConstraints.forEach { isGrid ? $0.activate() : $0.deactivate() }
listConstraints.forEach { isGrid ? $0.deactivate() : $0.activate() }
nameLabel.text = file.name
detailLabel.text = mode == .grid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file)
configureThumbnail(file)
gridConstraints.forEach { mode == .grid ? $0.activate() : $0.deactivate() }
listConstraints.forEach { mode == .list ? $0.activate() : $0.deactivate() }
contentView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
contentView.layer.borderWidth = mode == .grid ? 1 : 0
contentView.backgroundColor = mode == .grid ? .white : .clear
thumbnailView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0
nameLabel.textAlignment = mode == .grid ? .left : .left
nameLabel.font = .systemFont(ofSize: 15, weight: isGrid ? .semibold : .regular)
nameLabel.lineBreakMode = isGrid ? .byTruncatingTail : .byTruncatingMiddle
detailLabel.text = isGrid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file)
detailLabel.textColor = isGrid ? UIColor(hex: 0x4B5563) : UIColor(hex: 0x7B8EAA)
contentView.layer.cornerRadius = isGrid ? 12 : 0
contentView.layer.borderWidth = isGrid ? 1 : 0
contentView.backgroundColor = isGrid ? .white : .clear
thumbnailView.layer.cornerRadius = isGrid ? 12 : 0
thumbnailView.clipsToBounds = isGrid
configureThumbnail(file, mode: mode)
}
private func setupUI() {
backgroundColor = .clear
contentView.clipsToBounds = true
contentView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
thumbnailView.contentMode = .scaleAspectFill
thumbnailView.clipsToBounds = true
thumbnailView.backgroundColor = AppColor.warningBackground
folderIconView.tintColor = AppColor.warning
folderIconView.contentMode = .scaleAspectFit
folderIconView.isHidden = true
playIconView.tintColor = .white
playIconView.contentMode = .scaleAspectFit
playIconView.isHidden = true
extensionLabel.font = .app(.caption)
extensionLabel.font = .systemFont(ofSize: 12, weight: .bold)
extensionLabel.textColor = .white
extensionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
extensionLabel.layer.cornerRadius = AppRadius.xs
extensionLabel.layer.cornerRadius = 4
extensionLabel.clipsToBounds = true
extensionLabel.isHidden = true
nameLabel.font = .app(.body)
nameLabel.textColor = AppColor.textPrimary
nameLabel.textColor = UIColor(hex: 0x333333)
nameLabel.numberOfLines = 1
detailLabel.font = .app(.caption)
detailLabel.textColor = UIColor(hex: 0x4B5563)
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.numberOfLines = 1
moreButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "ellipsis"), for: .normal)
moreButton.tintColor = AppColor.textSecondary
let detailsImage = CloudDriveAsset.resizedImage(
named: "icon_cloud_storage_file_item_details",
fallbackSystemName: "ellipsis",
size: CGSize(width: 16, height: 16)
)
moreButton.setImage(detailsImage, for: .normal)
moreButton.imageView?.contentMode = .scaleAspectFit
moreButton.addTarget(self, action: #selector(moreTapped), for: .touchUpInside)
moreButton.accessibilityLabel = "文件操作"
contentView.addSubview(thumbnailView)
contentView.addSubview(folderIconView)
contentView.addSubview(playIconView)
contentView.addSubview(extensionLabel)
contentView.addSubview(nameLabel)
contentView.addSubview(detailLabel)
contentView.addSubview(moreButton)
[thumbnailView, folderIconView, playIconView, extensionLabel, nameLabel, detailLabel, moreButton]
.forEach(contentView.addSubview)
}
private func setupConstraints() {
thumbnailView.snp.makeConstraints { make in
gridConstraints.append(make.top.leading.trailing.equalToSuperview().constraint)
gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).multipliedBy(0.56).constraint)
gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).dividedBy(1.77).constraint)
listConstraints.append(make.leading.centerY.equalToSuperview().constraint)
listConstraints.append(make.width.height.equalTo(48).constraint)
}
folderIconView.snp.makeConstraints { make in
make.center.equalTo(thumbnailView)
make.width.height.equalTo(42)
make.width.height.equalTo(48)
}
playIconView.snp.makeConstraints { make in
make.center.equalTo(thumbnailView)
make.width.height.equalTo(28)
make.width.height.equalTo(24)
}
extensionLabel.snp.makeConstraints { make in
make.top.leading.equalTo(thumbnailView).offset(AppSpacing.xs)
make.top.leading.equalTo(thumbnailView).offset(8)
}
nameLabel.snp.makeConstraints { make in
gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(AppSpacing.xs).constraint)
gridConstraints.append(make.leading.equalToSuperview().offset(AppSpacing.sm).constraint)
listConstraints.append(make.top.equalToSuperview().offset(AppSpacing.sm).constraint)
listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(AppSpacing.sm).constraint)
make.trailing.equalTo(moreButton.snp.leading).offset(-AppSpacing.xs)
gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(8).constraint)
gridConstraints.append(make.leading.equalToSuperview().offset(12).constraint)
listConstraints.append(make.top.equalToSuperview().offset(12).constraint)
listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(12).constraint)
make.trailing.equalTo(moreButton.snp.leading).offset(-4)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(AppSpacing.xxs)
make.leading.equalTo(nameLabel)
make.trailing.equalTo(nameLabel)
make.top.equalTo(nameLabel.snp.bottom).offset(2)
make.leading.trailing.equalTo(nameLabel)
}
moreButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.xs)
gridConstraints.append(make.trailing.equalToSuperview().inset(10).constraint)
listConstraints.append(make.trailing.equalToSuperview().constraint)
make.centerY.equalTo(nameLabel.snp.bottom).offset(4)
make.width.height.equalTo(32)
make.width.height.equalTo(28)
}
listConstraints.forEach { $0.deactivate() }
}
private func configureThumbnail(_ file: CloudFile) {
if file.isFolder {
thumbnailView.image = nil
thumbnailView.backgroundColor = AppColor.warningBackground
folderIconView.isHidden = false
extensionLabel.isHidden = true
private func configureThumbnail(_ file: CloudFile, mode: CloudDriveDisplayMode) {
thumbnailView.kf.cancelDownloadTask()
folderIconView.isHidden = true
playIconView.isHidden = true
extensionLabel.isHidden = true
if mode == .list {
thumbnailView.backgroundColor = .clear
thumbnailView.contentMode = .scaleAspectFit
let assetName: String
let fallbackName: String
if file.isFolder {
assetName = "icon_cloud_storage_file_type_folder"
fallbackName = "folder.fill"
} else if file.isVideo {
assetName = "icon_cloud_storage_file_type_video"
fallbackName = "video.fill"
} else {
assetName = "icon_cloud_storage_file_type_photo"
fallbackName = "photo.fill"
}
thumbnailView.image = CloudDriveAsset.image(named: assetName, fallbackSystemName: fallbackName)
return
}
folderIconView.isHidden = true
thumbnailView.backgroundColor = AppColor.inputBackground
let preview = file.isVideo ? file.coverUrl : file.fileUrl
if let url = URL(string: preview), !preview.isEmpty {
let placeholder = CloudDriveAsset.image(
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
fallbackSystemName: file.isVideo ? "video" : "photo"
)
thumbnailView.contentMode = .scaleAspectFill
if file.isFolder {
thumbnailView.image = nil
thumbnailView.backgroundColor = UIColor(hex: 0xFFF0E2)
folderIconView.isHidden = false
return
}
thumbnailView.backgroundColor = UIColor(hex: 0xF4F4F4)
let previewURL = file.isVideo ? file.coverUrl : file.fileUrl
let placeholder = CloudDriveAsset.image(
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
fallbackSystemName: file.isVideo ? "video" : "photo"
)
if let url = URL(string: previewURL), !previewURL.isEmpty {
thumbnailView.kf.setImage(with: url, placeholder: placeholder)
} else {
thumbnailView.image = CloudDriveAsset.image(
named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
fallbackSystemName: file.isVideo ? "video" : "photo"
)
thumbnailView.tintColor = AppColor.textTertiary
thumbnailView.image = placeholder
}
playIconView.isHidden = !file.isVideo
extensionLabel.text = CloudDriveFormatter.fileExtension(file.fileUrl.isEmpty ? file.name : file.fileUrl)
extensionLabel.isHidden = false
extensionLabel.isHidden = extensionLabel.text?.isEmpty ?? true
}
@objc private func moreTapped() {
@ -174,16 +204,19 @@ final class CloudDriveFileCell: UICollectionViewCell {
}
}
///
/// Android
private final class CloudDrivePaddingLabel: UILabel {
var insets = UIEdgeInsets(top: 3, left: 5, bottom: 3, right: 5)
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom)
}
private let insets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: insets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + insets.left + insets.right,
height: size.height + insets.top + insets.bottom
)
}
}

View File

@ -24,15 +24,16 @@ enum CloudDriveFormatter {
///
static func fileDescription(_ file: CloudFile) -> String {
let createdDate = dateOnly(file.createdAt)
if file.isFolder {
return "\(file.childNum) 个项目"
return "\(createdDate) | \(file.childNum)个项目"
}
return "\(typeName(file.type)) \(fileSize(file.fileSize))"
return "\(createdDate) | \(fileSize(file.fileSize))"
}
///
static func fileSize(_ bytes: Int64) -> String {
guard bytes > 0 else { return "0B" }
guard bytes > 0 else { return "0 B" }
let units = ["B", "KB", "MB", "GB", "TB"]
var value = Double(bytes)
var index = 0
@ -40,10 +41,17 @@ enum CloudDriveFormatter {
value /= 1024
index += 1
}
if index == 0 {
return "\(Int(value))\(units[index])"
return String(format: "%.2f %@", value, units[index])
}
/// Android 使 yyyy-MM-dd
static func dateOnly(_ value: String) -> String {
guard !value.isEmpty else { return "" }
if value.count >= 10 {
let endIndex = value.index(value.startIndex, offsetBy: 10)
return String(value[..<endIndex])
}
return String(format: "%.1f%@", value, units[index])
return value
}
/// URL
@ -59,4 +67,13 @@ enum CloudDriveAsset {
static func image(named name: String, fallbackSystemName: String) -> UIImage? {
UIImage(named: name)?.withRenderingMode(.alwaysOriginal) ?? UIImage(systemName: fallbackSystemName)
}
///
static func resizedImage(named name: String, fallbackSystemName: String, size: CGSize) -> UIImage? {
guard let source = image(named: name, fallbackSystemName: fallbackSystemName) else { return nil }
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
source.draw(in: CGRect(origin: .zero, size: size))
}.withRenderingMode(.alwaysOriginal)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -8,13 +8,17 @@ import Kingfisher
import SnapKit
import UIKit
///
/// Android iOS
final class CloudDrivePreviewViewController: BaseViewController {
private let file: CloudFile
private let imageView = UIImageView()
private var playerViewController: AVPlayerViewController?
private var previousStandardAppearance: UINavigationBarAppearance?
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
private var previousCompactAppearance: UINavigationBarAppearance?
private var previousTintColor: UIColor?
///
///
init(file: CloudFile) {
self.file = file
super.init(nibName: nil, bundle: nil)
@ -25,9 +29,23 @@ final class CloudDrivePreviewViewController: BaseViewController {
fatalError("init(coder:) has not been implemented")
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
override func setupNavigationBar() {
title = ""
navigationController?.navigationBar.tintColor = .white
navigationItem.titleView = UIView()
navigationItem.hidesBackButton = true
let backButton = UIButton(type: .system)
backButton.setImage(
UIImage(systemName: "chevron.left")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)),
for: .normal
)
backButton.tintColor = .white
backButton.accessibilityLabel = "返回"
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.snp.makeConstraints { make in make.width.height.equalTo(44) }
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
}
override func setupUI() {
@ -39,37 +57,71 @@ final class CloudDrivePreviewViewController: BaseViewController {
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyTransparentNavigationBar()
setNeedsStatusBarAppearanceUpdate()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.navigationBar.tintColor = AppColor.primary
restoreNavigationBar()
playerViewController?.player?.pause()
}
private func setupImage() {
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
imageView.accessibilityLabel = file.name
view.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
if let url = URL(string: file.fileUrl) {
imageView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) }
if let url = URL(string: file.fileUrl), !file.fileUrl.isEmpty {
imageView.kf.setImage(with: url)
}
}
private func setupVideo() {
guard let url = URL(string: file.fileUrl) else { return }
guard let url = URL(string: file.fileUrl), !file.fileUrl.isEmpty else { return }
let player = AVPlayer(url: url)
let controller = AVPlayerViewController()
controller.player = player
controller.view.backgroundColor = .black
addChild(controller)
view.addSubview(controller.view)
controller.view.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
controller.view.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) }
controller.didMove(toParent: self)
playerViewController = controller
player.play()
}
private func applyTransparentNavigationBar() {
guard let navigationBar = navigationController?.navigationBar else { return }
previousStandardAppearance = navigationBar.standardAppearance
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
previousCompactAppearance = navigationBar.compactAppearance
previousTintColor = navigationBar.tintColor
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
appearance.backgroundColor = .clear
appearance.shadowColor = .clear
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = .white
}
private func restoreNavigationBar() {
guard let navigationBar = navigationController?.navigationBar else { return }
if let previousStandardAppearance {
navigationBar.standardAppearance = previousStandardAppearance
}
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
navigationBar.compactAppearance = previousCompactAppearance
navigationBar.tintColor = previousTintColor
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
}

View File

@ -6,21 +6,34 @@
import SnapKit
import UIKit
/// Android `CloudStorageTransitScreen`
/// Android `CloudStorageTransitScreen`
final class CloudDriveTransferViewController: BaseViewController {
private enum Tab: Int {
case upload
case download
}
private enum Section {
case main
}
private let manager: CloudTransferManager
private let segmentedControl = UISegmentedControl(items: ["上传", "下载"])
private let navigationDivider = UIView()
private let tabContainer = UIView()
private let uploadTabButton = UIButton(type: .system)
private let downloadTabButton = UIButton(type: .system)
private let tabIndicator = UIView()
private let tableView = UITableView(frame: .zero, style: .plain)
private var dataSource: UITableViewDiffableDataSource<Section, String>!
private var selectedTab: Tab = .upload
private var previousStandardAppearance: UINavigationBarAppearance?
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
private var previousCompactAppearance: UINavigationBarAppearance?
private var previousNavigationTintColor: UIColor?
///
init(manager: CloudTransferManager = .shared) {
self.manager = manager
init(manager: CloudTransferManager? = nil) {
self.manager = manager ?? .shared
super.init(nibName: nil, bundle: nil)
}
@ -30,81 +43,217 @@ final class CloudDriveTransferViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "传输管理"
let titleLabel = UILabel()
titleLabel.text = "传输管理"
titleLabel.textColor = UIColor(hex: 0x333333)
titleLabel.font = .systemFont(ofSize: 18)
navigationItem.titleView = titleLabel
let backButton = UIButton(type: .system)
backButton.setImage(
UIImage(systemName: "chevron.left")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)),
for: .normal
)
backButton.tintColor = .black
backButton.accessibilityLabel = "返回"
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.snp.makeConstraints { make in make.width.height.equalTo(44) }
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
segmentedControl.selectedSegmentIndex = selectedTab.rawValue
navigationDivider.backgroundColor = UIColor(hex: 0xEEEEEE)
tabContainer.backgroundColor = .white
configureTabButton(uploadTabButton, title: "上传", action: #selector(uploadTabTapped))
configureTabButton(downloadTabButton, title: "下载", action: #selector(downloadTabTapped))
tabIndicator.backgroundColor = AppColor.primary
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.dataSource = self
tableView.showsVerticalScrollIndicator = false
tableView.contentInsetAdjustmentBehavior = .never
tableView.delegate = self
tableView.register(CloudTransferCell.self, forCellReuseIdentifier: CloudTransferCell.reuseIdentifier)
view.addSubview(segmentedControl)
view.addSubview(navigationDivider)
view.addSubview(tabContainer)
tabContainer.addSubview(uploadTabButton)
tabContainer.addSubview(downloadTabButton)
tabContainer.addSubview(tabIndicator)
view.addSubview(tableView)
configureDataSource()
}
override func setupConstraints() {
segmentedControl.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
navigationDivider.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
tabContainer.snp.makeConstraints { make in
make.top.equalTo(navigationDivider.snp.bottom)
make.leading.trailing.equalToSuperview()
make.height.equalTo(48)
}
uploadTabButton.snp.makeConstraints { make in
make.top.leading.bottom.equalToSuperview()
make.width.equalToSuperview().multipliedBy(0.5)
}
downloadTabButton.snp.makeConstraints { make in
make.top.trailing.bottom.equalToSuperview()
make.width.equalTo(uploadTabButton)
}
tabIndicator.snp.makeConstraints { make in
make.bottom.equalToSuperview()
make.height.equalTo(2)
make.width.equalToSuperview().multipliedBy(0.5)
make.leading.equalToSuperview()
}
tableView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.sm)
make.top.equalTo(tabContainer.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
manager.onTasksChange = { [weak self] in
Task { @MainActor in self?.tableView.reloadData() }
Task { @MainActor in self?.applySnapshot(animated: true) }
}
updateTabs(animated: false)
applySnapshot(animated: false)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyCloudNavigationBarAppearance()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
restoreNavigationBarAppearance()
}
private var visibleTasks: [CloudTransferTask] {
selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks
}
@objc private func tabChanged() {
selectedTab = Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .upload
tableView.reloadData()
}
}
extension CloudDriveTransferViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
visibleTasks.count
private func configureTabButton(_ button: UIButton, title: String, action: Selector) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14)
button.addTarget(self, action: action, for: .touchUpInside)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
112
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
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: CloudTransferCell.reuseIdentifier, for: indexPath) as! CloudTransferCell
let task = visibleTasks[indexPath.row]
cell.apply(task: task)
cell.onPrimaryAction = { [weak self] in
guard let self else { return }
if task.kind == .upload {
task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id)
} else {
task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id)
private func restoreNavigationBarAppearance() {
guard let navigationBar = navigationController?.navigationBar else { return }
if let previousStandardAppearance {
navigationBar.standardAppearance = previousStandardAppearance
}
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
navigationBar.compactAppearance = previousCompactAppearance
navigationBar.tintColor = previousNavigationTintColor
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, String>(tableView: tableView) { [weak self] tableView, indexPath, taskID in
guard let self,
let task = self.visibleTasks.first(where: { $0.id == taskID }),
let cell = tableView.dequeueReusableCell(
withIdentifier: CloudTransferCell.reuseIdentifier,
for: indexPath
) as? CloudTransferCell else {
return UITableViewCell()
}
cell.apply(task: task)
cell.onPrimaryAction = { [weak self] in
guard let self else { return }
switch task.kind {
case .upload:
task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id)
case .download:
task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id)
}
}
cell.onCancel = { [weak self] in
guard let self else { return }
task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id)
}
return cell
}
cell.onCancel = { [weak self] in
guard let self else { return }
task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id)
dataSource.defaultRowAnimation = .fade
}
private func applySnapshot(animated: Bool) {
let previousIDs = Set(dataSource.snapshot().itemIdentifiers)
let taskIDs = visibleTasks.map(\.id)
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
snapshot.appendSections([.main])
snapshot.appendItems(taskIDs)
snapshot.reconfigureItems(taskIDs.filter(previousIDs.contains))
dataSource.apply(snapshot, animatingDifferences: animated)
}
private func updateTabs(animated: Bool) {
let uploadSelected = selectedTab == .upload
uploadTabButton.setTitleColor(uploadSelected ? AppColor.primary : UIColor(hex: 0x333333), for: .normal)
downloadTabButton.setTitleColor(uploadSelected ? UIColor(hex: 0x333333) : AppColor.primary, for: .normal)
uploadTabButton.titleLabel?.font = .systemFont(ofSize: 14, weight: uploadSelected ? .medium : .regular)
downloadTabButton.titleLabel?.font = .systemFont(ofSize: 14, weight: uploadSelected ? .regular : .medium)
tabIndicator.snp.updateConstraints { make in
make.leading.equalToSuperview().offset(uploadSelected ? 0 : view.bounds.width / 2)
}
return cell
guard animated else {
tabContainer.layoutIfNeeded()
return
}
UIView.animate(withDuration: 0.2) { self.tabContainer.layoutIfNeeded() }
}
private func selectTab(_ tab: Tab) {
guard selectedTab != tab else { return }
selectedTab = tab
updateTabs(animated: true)
applySnapshot(animated: true)
tableView.setContentOffset(.zero, animated: false)
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func uploadTabTapped() {
selectTab(.upload)
}
@objc private func downloadTabTapped() {
selectTab(.download)
}
}
/// Cell
extension CloudDriveTransferViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
119
}
}
/// Cell Android /
private final class CloudTransferCell: UITableViewCell {
static let reuseIdentifier = "CloudTransferCell"
@ -137,90 +286,124 @@ private final class CloudTransferCell: UITableViewCell {
onCancel = nil
}
///
func apply(task: CloudTransferTask) {
iconView.image = CloudDriveAsset.image(
named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo",
fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill"
)
titleLabel.text = task.fileName
statusLabel.text = "\(task.status.title) \(CloudDriveFormatter.fileSize(task.fileSize))"
statusLabel.text = statusText(for: task)
progressView.progress = Float(task.progress) / 100
percentLabel.text = "\(task.progress)%"
let isRunning = task.status == .uploading || task.status == .downloading
primaryButton.setImage(UIImage(systemName: isRunning ? "pause.fill" : "play.fill"), for: .normal)
primaryButton.isHidden = task.status == .completed
let canResume = task.status == .paused || task.status == .failed
primaryButton.setImage(
UIImage(systemName: isRunning ? "pause.fill" : "play.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20)),
for: .normal
)
primaryButton.isHidden = !(isRunning || canResume)
}
private func statusText(for task: CloudTransferTask) -> String {
switch task.kind {
case .upload:
let status: String
switch task.status {
case .uploading: status = "上传中"
case .paused: status = "已暂停"
case .failed: status = "失败"
default: status = ""
}
let size = CloudDriveFormatter.fileSize(task.fileSize)
return status.isEmpty ? size : "\(status) \(size)"
case .download:
switch task.status {
case .downloading: return "下载中"
case .paused: return "已暂停"
case .failed: return "失败"
case .completed: return "已完成"
default: return ""
}
}
}
private func setupUI() {
backgroundColor = .clear
selectionStyle = .none
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.sm
cardView.layer.cornerRadius = 8
cardView.clipsToBounds = true
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.font = .app(.body)
titleLabel.textColor = AppColor.textPrimary
titleLabel.font = .systemFont(ofSize: 15)
titleLabel.textColor = UIColor(hex: 0x333333)
titleLabel.lineBreakMode = .byTruncatingMiddle
statusLabel.font = .app(.caption)
statusLabel.textColor = AppColor.textTertiary
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = .gray
progressView.progressTintColor = AppColor.primary
percentLabel.font = .app(.caption)
percentLabel.textColor = AppColor.textSecondary
progressView.trackTintColor = UIColor(hex: 0xE5E7EB)
progressView.transform = CGAffineTransform(scaleX: 1, y: 2)
percentLabel.font = .systemFont(ofSize: 11)
percentLabel.textColor = UIColor(hex: 0x4B5563)
percentLabel.textAlignment = .right
primaryButton.tintColor = AppColor.primary
cancelButton.tintColor = AppColor.textSecondary
cancelButton.setImage(UIImage(systemName: "xmark"), for: .normal)
primaryButton.tintColor = UIColor(hex: 0x333333)
cancelButton.tintColor = UIColor(hex: 0x333333)
cancelButton.setImage(
UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20)),
for: .normal
)
primaryButton.accessibilityLabel = "暂停或继续"
cancelButton.accessibilityLabel = "取消任务"
primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
contentView.addSubview(cardView)
cardView.addSubview(iconView)
cardView.addSubview(titleLabel)
cardView.addSubview(statusLabel)
cardView.addSubview(primaryButton)
cardView.addSubview(cancelButton)
cardView.addSubview(progressView)
cardView.addSubview(percentLabel)
[iconView, titleLabel, statusLabel, primaryButton, cancelButton, progressView, percentLabel]
.forEach(cardView.addSubview)
}
private func setupConstraints() {
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(12)
make.bottom.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.leading.top.equalToSuperview().offset(AppSpacing.sm)
make.leading.top.equalToSuperview().offset(12)
make.width.height.equalTo(48)
}
cancelButton.snp.makeConstraints { make in
make.trailing.top.equalToSuperview().inset(AppSpacing.xs)
make.width.height.equalTo(36)
make.trailing.equalToSuperview()
make.top.equalToSuperview().offset(8)
make.width.height.equalTo(44)
}
primaryButton.snp.makeConstraints { make in
make.trailing.equalTo(cancelButton.snp.leading)
make.centerY.equalTo(cancelButton)
make.width.height.equalTo(36)
make.width.height.equalTo(44)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm)
make.leading.equalTo(iconView.snp.trailing).offset(12)
make.top.equalTo(iconView)
make.trailing.equalTo(primaryButton.snp.leading).offset(-AppSpacing.xs)
make.trailing.equalTo(primaryButton.snp.leading).offset(-4)
}
statusLabel.snp.makeConstraints { make in
make.leading.trailing.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xxs)
make.top.equalTo(titleLabel.snp.bottom).offset(4)
}
progressView.snp.makeConstraints { make in
make.leading.equalTo(iconView)
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm)
make.leading.equalToSuperview().offset(12)
make.trailing.equalToSuperview().inset(12)
make.top.equalTo(iconView.snp.bottom).offset(12)
make.height.equalTo(4)
}
percentLabel.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(AppSpacing.xxs)
make.top.equalTo(progressView.snp.bottom).offset(4)
make.trailing.equalTo(progressView)
make.bottom.equalToSuperview().inset(12)
}
}

View File

@ -10,6 +10,8 @@ import UIKit
/// 线
final class WildReportRiskMapViewController: BaseViewController {
private static let cachedNavigationAppKey = "wildReportRiskMap.cachedNavigationApp"
private let viewModel: WildReportRiskMapViewModel
private let api: any WildPhotographerReportServing
private let locationProvider: any LocationProviding
@ -907,6 +909,10 @@ final class WildReportRiskMapViewController: BaseViewController {
return
}
guard let target = viewModel.navigateToSelectedMarker() else { return }
if let cachedOption = cachedNavigationOption(for: target) {
openNavigation(cachedOption, cacheSelection: false)
return
}
presentNavigationOptions(for: target)
}
@ -930,7 +936,7 @@ final class WildReportRiskMapViewController: BaseViewController {
let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet)
options.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
self?.openNavigation(option)
self?.openNavigation(option, cacheSelection: true)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -942,43 +948,71 @@ final class WildReportRiskMapViewController: BaseViewController {
}
private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] {
[
WildReportNavigationOption(title: "Apple 地图", url: navigationURL(for: .apple, target: target)),
WildReportNavigationOption(title: "高德地图", url: navigationURL(for: .amap, target: target)),
WildReportNavigationOption(title: "百度地图", url: navigationURL(for: .baidu, target: target)),
WildReportNavigationOption(title: "腾讯地图", url: navigationURL(for: .tencent, target: target)),
WildReportNavigationOption(title: "Google Maps", url: navigationURL(for: .google, target: target))
].compactMap { option in
guard option.url != nil else { return nil }
WildReportNavigationApp.allCases.compactMap { app in
guard let option = navigationOption(for: app, target: target) else { return nil }
guard isNavigationOptionAvailable(option) else { return nil }
return option
}
}
private func openNavigation(_ option: WildReportNavigationOption) {
private func cachedNavigationOption(for target: WildReportMapMarker) -> WildReportNavigationOption? {
guard let rawValue = UserDefaults.standard.string(forKey: Self.cachedNavigationAppKey),
let app = WildReportNavigationApp(rawValue: rawValue),
let option = navigationOption(for: app, target: target),
isNavigationOptionAvailable(option)
else {
UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey)
return nil
}
return option
}
private func navigationOption(for app: WildReportNavigationApp, target: WildReportMapMarker) -> WildReportNavigationOption? {
WildReportNavigationOption(
app: app,
title: app.title,
url: navigationURL(for: app, target: target),
requiresAvailabilityCheck: app.requiresAvailabilityCheck
)
}
private func isNavigationOptionAvailable(_ option: WildReportNavigationOption) -> Bool {
guard let url = option.url else { return false }
return !option.requiresAvailabilityCheck || UIApplication.shared.canOpenURL(url)
}
private func openNavigation(_ option: WildReportNavigationOption, cacheSelection: Bool) {
guard let url = option.url else { return }
UIApplication.shared.open(url) { [weak self] success in
guard !success else { return }
guard success else {
UserDefaults.standard.removeObject(forKey: Self.cachedNavigationAppKey)
Task { @MainActor in
self?.showToast("无法打开\(option.title)")
}
return
}
guard cacheSelection else { return }
Task { @MainActor in
self?.showToast("未安装\(option.title)或无法打开")
UserDefaults.standard.set(option.app.rawValue, forKey: Self.cachedNavigationAppKey)
}
}
}
private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? {
let latitude = target.coordinate.latitude
let longitude = target.coordinate.longitude
let destLat = target.coordinate.latitude
let destLon = target.coordinate.longitude
let name = encodedNavigationText(target.title)
switch app {
case .apple:
return URL(string: "http://maps.apple.com/?daddr=\(latitude),\(longitude)&dirflg=d&q=\(name)")
return URL(string: "http://maps.apple.com/?daddr=\(destLat),\(destLon)&dirflg=d")
case .amap:
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(latitude)&dlon=\(longitude)&dname=\(name)&dev=0&t=0")
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(destLat)&dlon=\(destLon)&dname=\(name)&dev=0&t=0")
case .baidu:
return URL(string: "baidumap://map/direction?destination=latlng:\(latitude),\(longitude)|name:\(name)&mode=driving&coord_type=wgs84")
return URL(string: "baidumap://map/direction?destination=latlng:\(destLat),\(destLon)|name:\(name)&mode=driving&coord_type=wgs84")
case .tencent:
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(latitude),\(longitude)&to=\(name)&referer=suixinkan")
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(destLat),\(destLon)&to=\(name)&referer=suixinkan")
case .google:
return URL(string: "comgooglemaps://?daddr=\(latitude),\(longitude)&directionsmode=driving")
return URL(string: "comgooglemaps://?daddr=\(destLat),\(destLon)&directionsmode=driving")
}
}
@ -1022,17 +1056,40 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
///
private struct WildReportNavigationOption {
let app: WildReportNavigationApp
let title: String
let url: URL?
let requiresAvailabilityCheck: Bool
}
private enum WildReportNavigationApp {
///
private enum WildReportNavigationApp: String, CaseIterable {
case apple
case amap
case baidu
case tencent
case google
var title: String {
switch self {
case .apple:
return "Apple 地图"
case .amap:
return "高德地图"
case .baidu:
return "百度地图"
case .tencent:
return "腾讯地图"
case .google:
return "Google Maps"
}
}
var requiresAvailabilityCheck: Bool {
self != .apple
}
}
extension WildReportRiskMapViewController: MKMapViewDelegate {