// // TravelAlbumCameraImportViewController.swift // suixinkan // import SnapKit import UIKit /// 旅拍相册相机历史照片选择导入页。 final class TravelAlbumCameraImportViewController: UIViewController { private enum Layout { static let columns: CGFloat = 3 static let spacing: CGFloat = 2 static let inset: CGFloat = 2 static let headerHeight: CGFloat = 44 } private let viewModel: TravelAlbumCameraImportViewModel var onImportFinished: ((Int, Int) -> Void)? var onImportCancelled: (() -> Void)? private var collectionView: UICollectionView! private var sections: [TravelAlbumCameraImportSection] = [] private var lastCollectionWidth: CGFloat = 0 private lazy var importBarButton = UIBarButtonItem( title: "导入 (0)", style: .done, target: self, action: #selector(importTapped) ) private lazy var helpBarButton = UIBarButtonItem( image: UIImage(systemName: "questionmark.circle"), style: .plain, target: self, action: #selector(helpTapped) ) private let activityIndicator = UIActivityIndicatorView(style: .large) private let messageLabel = UILabel() private let helpButton = UIButton(type: .system) /// 初始化旅拍相册相机历史照片选择导入页。 init(viewModel: TravelAlbumCameraImportViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() title = "选择照片" view.backgroundColor = .white navigationItem.leftBarButtonItem = UIBarButtonItem( title: "取消", style: .plain, target: self, action: #selector(cancelTapped) ) navigationItem.rightBarButtonItem = importBarButton importBarButton.isEnabled = false setupUI() setupConstraints() bindViewModel() viewModel.loadPhotos() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let width = collectionView.bounds.width guard width > 0, width != lastCollectionWidth else { return } lastCollectionWidth = width collectionView.collectionViewLayout.invalidateLayout() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) navigationController?.presentationController?.delegate = self } private func setupUI() { let layout = UICollectionViewFlowLayout() layout.minimumLineSpacing = Layout.spacing layout.minimumInteritemSpacing = Layout.spacing layout.sectionInset = UIEdgeInsets( top: Layout.inset, left: Layout.inset, bottom: Layout.inset, right: Layout.inset ) layout.headerReferenceSize = CGSize(width: 0, height: Layout.headerHeight) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.backgroundColor = .white collectionView.allowsMultipleSelection = true collectionView.dataSource = self collectionView.delegate = self collectionView.register( TravelAlbumCameraImportPhotoCell.self, forCellWithReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier ) collectionView.register( TravelAlbumCameraImportSectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier ) activityIndicator.hidesWhenStopped = true messageLabel.numberOfLines = 0 messageLabel.textAlignment = .center messageLabel.textColor = AppColor.textSecondary messageLabel.font = .systemFont(ofSize: 15) messageLabel.isHidden = true helpButton.setTitle("如何切换到 MTP 模式", for: .normal) helpButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) helpButton.backgroundColor = AppColor.primary.withAlphaComponent(0.10) helpButton.setTitleColor(AppColor.primary, for: .normal) helpButton.layer.cornerRadius = 9 helpButton.isHidden = true helpButton.addTarget(self, action: #selector(helpTapped), for: .touchUpInside) view.addSubview(collectionView) view.addSubview(activityIndicator) view.addSubview(messageLabel) view.addSubview(helpButton) } private func setupConstraints() { collectionView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) } activityIndicator.snp.makeConstraints { make in make.center.equalToSuperview() } messageLabel.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview().offset(-28) make.leading.greaterThanOrEqualToSuperview().offset(32) make.trailing.lessThanOrEqualToSuperview().offset(-32) } helpButton.snp.makeConstraints { make in make.top.equalTo(messageLabel.snp.bottom).offset(20) make.centerX.equalToSuperview() make.height.equalTo(38) make.width.greaterThanOrEqualTo(160) } } private func bindViewModel() { viewModel.onStateUpdated = { [weak self] state in self?.render(state) } viewModel.onSelectionUpdated = { [weak self] count in self?.updateImportButton(count: count) } viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in self?.updateHelpVisibility(show) } } private func render(_ state: TravelAlbumCameraImportState) { switch state { case .idle, .loading: isModalInPresentation = false collectionView.isHidden = true messageLabel.isHidden = true helpButton.isHidden = true importBarButton.isEnabled = false navigationItem.leftBarButtonItem?.isEnabled = true activityIndicator.startAnimating() case .loaded(let loadedSections): isModalInPresentation = false activityIndicator.stopAnimating() collectionView.isHidden = false messageLabel.isHidden = true helpButton.isHidden = true navigationItem.leftBarButtonItem?.isEnabled = true sections = loadedSections collectionView.reloadData() case .importing(let current, let total): isModalInPresentation = true collectionView.isHidden = true messageLabel.isHidden = false messageLabel.text = "正在导入 \(current)/\(total)..." helpButton.isHidden = true importBarButton.isEnabled = false navigationItem.leftBarButtonItem?.isEnabled = false activityIndicator.startAnimating() case .finished(let importedCount, let failedCount): isModalInPresentation = false activityIndicator.stopAnimating() dismiss(animated: true) { [weak self] in self?.onImportFinished?(importedCount, failedCount) } case .failed(let message): isModalInPresentation = false activityIndicator.stopAnimating() collectionView.isHidden = true messageLabel.isHidden = false messageLabel.text = message importBarButton.isEnabled = false navigationItem.leftBarButtonItem?.isEnabled = true helpButton.isHidden = !viewModel.shouldShowSonyMTPHelp } } private func updateHelpVisibility(_ show: Bool) { navigationItem.rightBarButtonItems = show ? [importBarButton, helpBarButton] : [importBarButton] if case .failed = viewModel.state { helpButton.isHidden = !show } } private func updateImportButton(count: Int) { importBarButton.title = "导入 (\(count))" importBarButton.isEnabled = count > 0 } private func thumbnailPixelSize() -> Int { guard let flow = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return 300 } return Int(itemSize(layout: flow).width * UIScreen.main.scale) } private func itemSize(layout: UICollectionViewFlowLayout) -> CGSize { let totalSpacing = layout.sectionInset.left + layout.sectionInset.right + layout.minimumInteritemSpacing * (Layout.columns - 1) let width = floor((collectionView.bounds.width - totalSpacing) / Layout.columns) return CGSize(width: width, height: width + 28) } private func applyHeader(_ header: TravelAlbumCameraImportSectionHeaderView, at sectionIndex: Int) { let section = sections[sectionIndex] header.apply( title: section.title, count: section.photos.count, importableCount: viewModel.importablePhotoCount(in: section), importedCount: viewModel.importedPhotoCount(in: section), hasImportablePhotos: viewModel.hasImportablePhotos(in: section), fullySelected: viewModel.isSectionFullySelected(section), partiallySelected: viewModel.isSectionPartiallySelected(section) ) header.onToggle = { [weak self] in self?.toggleSection(at: sectionIndex) } } private func refreshVisibleHeader(at sectionIndex: Int) { let indexPath = IndexPath(item: 0, section: sectionIndex) guard let header = collectionView.supplementaryView( forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath ) as? TravelAlbumCameraImportSectionHeaderView else { return } applyHeader(header, at: sectionIndex) } private func reloadItemsWithoutAnimation(_ indexPaths: [IndexPath]) { UIView.performWithoutAnimation { collectionView.reloadItems(at: indexPaths) collectionView.layoutIfNeeded() } } private func toggleSection(at sectionIndex: Int) { guard sections.indices.contains(sectionIndex) else { return } viewModel.toggleSection(sections[sectionIndex]) let indexPaths = sections[sectionIndex].photos.indices.map { IndexPath(item: $0, section: sectionIndex) } reloadItemsWithoutAnimation(indexPaths) refreshVisibleHeader(at: sectionIndex) } @objc private func cancelTapped() { onImportCancelled?() dismiss(animated: true) } @objc private func importTapped() { viewModel.importSelected() } @objc private func helpTapped() { guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return } UIApplication.shared.open(url) } } extension TravelAlbumCameraImportViewController: UICollectionViewDataSource { func numberOfSections(in collectionView: UICollectionView) -> Int { sections.count } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { sections[section].photos.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier, for: indexPath ) as! TravelAlbumCameraImportPhotoCell let photo = sections[indexPath.section].photos[indexPath.item] cell.apply( photo: photo, selected: viewModel.isPhotoSelected(id: photo.id), alreadyImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id), maxPixelSize: thumbnailPixelSize() ) { [weak self] photo, maxPixelSize in await self?.viewModel.thumbnailData(for: photo, maxPixelSize: maxPixelSize) } return cell } func collectionView( _ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath ) -> UICollectionReusableView { guard kind == UICollectionView.elementKindSectionHeader else { return UICollectionReusableView() } let header = collectionView.dequeueReusableSupplementaryView( ofKind: kind, withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier, for: indexPath ) as! TravelAlbumCameraImportSectionHeaderView applyHeader(header, at: indexPath.section) return header } } extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let photo = sections[indexPath.section].photos[indexPath.item] guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return } viewModel.togglePhoto(id: photo.id) reloadItemsWithoutAnimation([indexPath]) refreshVisibleHeader(at: indexPath.section) } func collectionView( _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath ) -> CGSize { guard let flow = collectionViewLayout as? UICollectionViewFlowLayout else { return CGSize(width: 120, height: 148) } return itemSize(layout: flow) } } extension TravelAlbumCameraImportViewController: UIAdaptivePresentationControllerDelegate { func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { onImportCancelled?() } } /// 相机历史照片导入 Cell。 private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell" private let imageView = UIImageView() private let filenameLabel = UILabel() private let checkmarkView = UIImageView() private let selectedOverlayView = UIView() private let importedOverlayView = UIView() private let importedBadgeLabel = UILabel() private var representedPhotoId: String? private var loadToken = UUID() override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() representedPhotoId = nil loadToken = UUID() imageView.image = nil filenameLabel.text = nil applySelection(false, alreadyImported: false) applyImported(false) } /// 绑定相机照片与选择状态。 func apply( photo: CameraObject, selected: Bool, alreadyImported: Bool, maxPixelSize: Int, thumbnailProvider: @escaping (CameraObject, Int) async -> Data? ) { representedPhotoId = photo.id filenameLabel.text = photo.filename setPlaceholderImage() applyImported(alreadyImported) applySelection(selected, alreadyImported: alreadyImported) let token = UUID() loadToken = token Task { @MainActor in guard let data = await thumbnailProvider(photo, maxPixelSize), self.loadToken == token, self.representedPhotoId == photo.id, let image = UIImage(data: data) else { return } self.imageView.image = image self.imageView.tintColor = nil } } private func setupUI() { contentView.backgroundColor = AppColor.pageBackground contentView.clipsToBounds = true contentView.layer.cornerRadius = 4 imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true selectedOverlayView.backgroundColor = AppColor.primary.withAlphaComponent(0.25) selectedOverlayView.isHidden = true importedOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35) importedOverlayView.isHidden = true importedBadgeLabel.text = "已在相册" importedBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold) importedBadgeLabel.textColor = .white importedBadgeLabel.textAlignment = .center importedBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55) importedBadgeLabel.layer.cornerRadius = 4 importedBadgeLabel.clipsToBounds = true importedBadgeLabel.isHidden = true checkmarkView.image = UIImage(systemName: "checkmark.circle.fill") checkmarkView.tintColor = AppColor.primary checkmarkView.isHidden = true filenameLabel.font = .systemFont(ofSize: 9, weight: .medium) filenameLabel.textColor = AppColor.textSecondary filenameLabel.textAlignment = .center filenameLabel.numberOfLines = 2 [imageView, selectedOverlayView, importedOverlayView, importedBadgeLabel, checkmarkView, filenameLabel].forEach { contentView.addSubview($0) } imageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(contentView.snp.width) } selectedOverlayView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(imageView) } importedOverlayView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(imageView) } importedBadgeLabel.snp.makeConstraints { make in make.center.equalTo(imageView) make.height.equalTo(18) make.width.greaterThanOrEqualTo(52) } checkmarkView.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4) make.size.equalTo(22) } filenameLabel.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(2) make.leading.trailing.equalToSuperview().inset(2) make.bottom.lessThanOrEqualToSuperview().offset(-2) } } private func setPlaceholderImage() { let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .light) imageView.image = UIImage(systemName: "photo", withConfiguration: config) imageView.tintColor = AppColor.textTertiary } private func applySelection(_ selected: Bool, alreadyImported: Bool) { let showSelection = selected && !alreadyImported selectedOverlayView.isHidden = !showSelection checkmarkView.isHidden = !showSelection contentView.layer.borderWidth = showSelection ? 2 : 0 contentView.layer.borderColor = showSelection ? AppColor.primary.cgColor : nil } private func applyImported(_ alreadyImported: Bool) { importedOverlayView.isHidden = !alreadyImported importedBadgeLabel.isHidden = !alreadyImported contentView.alpha = alreadyImported ? 0.55 : 1 } } /// 相机历史照片导入分组 Header。 private final class TravelAlbumCameraImportSectionHeaderView: UICollectionReusableView { static let reuseIdentifier = "TravelAlbumCameraImportSectionHeaderView" var onToggle: (() -> Void)? private let checkButton = UIButton(type: .system) private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 绑定分组信息。 func apply( title: String, count: Int, importableCount: Int, importedCount: Int, hasImportablePhotos: Bool, fullySelected: Bool, partiallySelected: Bool ) { titleLabel.text = importedCount > 0 ? "\(title) · 可导入 \(importableCount) / 共 \(count) 张" : "\(title) · \(count) 张" checkButton.isEnabled = hasImportablePhotos let symbolName: String if !hasImportablePhotos { symbolName = "circle" } else if fullySelected { symbolName = "checkmark.circle.fill" } else if partiallySelected { symbolName = "minus.circle.fill" } else { symbolName = "circle" } checkButton.setImage(UIImage(systemName: symbolName), for: .normal) checkButton.tintColor = hasImportablePhotos && (fullySelected || partiallySelected) ? AppColor.primary : AppColor.textTertiary } private func setupUI() { backgroundColor = .white checkButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) titleLabel.textColor = AppColor.textPrimary addSubview(checkButton) addSubview(titleLabel) checkButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) make.centerY.equalToSuperview() make.size.equalTo(28) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(checkButton.snp.trailing).offset(8) make.trailing.equalToSuperview().offset(-12) make.centerY.equalToSuperview() } } @objc private func toggleTapped() { onToggle?() } }