// // WiredCameraTransferViewController.swift // suixinkan // import Kingfisher import PhotosUI import SnapKit import UIKit import UniformTypeIdentifiers private enum WiredTransferMetrics { static let sidebarWidth: CGFloat = 76 static let rowHeight: CGFloat = 66 static let sectionHeaderHeight: CGFloat = 28 static let selectAllBarHeight: CGFloat = 44 } private struct WiredTransferListSection: Hashable { let id: String let title: String init(_ section: TravelAlbumOTGPhotoSection) { id = section.slotId title = section.headerTitle } } /// 有线相机传输页,对齐 Android OTG 页面并接入真实 ImageCaptureCore 传输。 final class WiredCameraTransferViewController: BaseViewController { private let viewModel: WiredCameraTransferViewModel private let headerView = UIView() private let backButton = UIButton(type: .system) private let titleLabel = UILabel() private let phoneLabel = UILabel() private let statusCard = UIView() private let storagePanel = UIView() private let storageTitleLabel = UILabel() private let storageValueLabel = UILabel() private let statusLabel = UILabel() private let refreshButton = UIButton(type: .system) private let helpLabel = UILabel() private let chipsStack = UIStackView() private let retouchButton = UIButton(type: .system) private let formatButton = UIButton(type: .system) private let modeButton = UIButton(type: .system) private let modeChevronView = UIImageView() private let settingsStatsDivider = UIView() private let statsCard = UIStackView() private let firstStatsDivider = UIView() private let secondStatsDivider = UIView() private let photoPanelView = UIView() private let selectAllBar = UIControl() private let selectAllIconView = UIImageView() private let selectAllTitleLabel = UILabel() private let selectAllCountLabel = UILabel() private let photoContentView = UIView() private let sidebarView = WiredTransferTimeSidebarView() private let sidebarDivider = UIView() private let listContainerView = UIView() private let expandSidebarButton = UIButton(type: .system) private let emptyLabel = UILabel() private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var currentSections: [WiredTransferListSection] = [] private var pendingScrollSlotId: String? private var selectAllBarHeightConstraint: Constraint? private let bottomBar = UIView() private let bottomButtonsStack = UIStackView() private let batchButton = UIButton(type: .system) private let historyImportButton = UIButton(type: .system) private let albumImportButton = UIButton(type: .system) private let specifyButton = UIButton(type: .system) private var isHistoryImportButtonVisible = false init(viewModel: WiredCameraTransferViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { navigationController?.setNavigationBarHidden(true, animated: false) } override func viewDidLoad() { super.viewDidLoad() applyViewModel() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.start() navigationController?.setNavigationBarHidden(true, animated: animated) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) viewModel.stop() navigationController?.setNavigationBarHidden(false, animated: animated) } override func setupUI() { view.backgroundColor = AppColor.pageBackground headerView.backgroundColor = AppColor.primary backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) backButton.tintColor = .white titleLabel.text = viewModel.albumTitle titleLabel.font = .systemFont(ofSize: 20, weight: .bold) titleLabel.textColor = .white phoneLabel.text = viewModel.headerPhone phoneLabel.font = .systemFont(ofSize: 13) phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92) statusCard.backgroundColor = .white statusCard.layer.cornerRadius = 12 statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor statusCard.layer.shadowOpacity = 1 statusCard.layer.shadowRadius = 8 statusCard.layer.shadowOffset = CGSize(width: 0, height: 3) storagePanel.layer.cornerRadius = 12 storagePanel.layer.borderColor = AppColor.border.cgColor storagePanel.layer.borderWidth = 3 storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium) storageTitleLabel.textColor = AppColor.textPrimary storageTitleLabel.textAlignment = .center storageTitleLabel.adjustsFontSizeToFitWidth = true storageTitleLabel.minimumScaleFactor = 0.72 storageValueLabel.font = .systemFont(ofSize: 10) storageValueLabel.textColor = AppColor.primary storageValueLabel.textAlignment = .center storageValueLabel.adjustsFontSizeToFitWidth = true storageValueLabel.minimumScaleFactor = 0.72 statusLabel.font = .systemFont(ofSize: 11, weight: .medium) statusLabel.layer.cornerRadius = 4 statusLabel.clipsToBounds = true statusLabel.textAlignment = .center refreshButton.setTitleColor(.white, for: .normal) refreshButton.titleLabel?.font = .systemFont(ofSize: 12) refreshButton.backgroundColor = AppColor.primary refreshButton.layer.cornerRadius = 8 helpLabel.font = .systemFont(ofSize: 10) helpLabel.numberOfLines = 3 helpLabel.isUserInteractionEnabled = true chipsStack.axis = .horizontal chipsStack.spacing = 6 chipsStack.distribution = .fillEqually [retouchButton, formatButton, modeButton].forEach { configureChipButton($0) chipsStack.addArrangedSubview($0) } retouchButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false modeButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 32) modeChevronView.image = UIImage(systemName: "chevron.down")? .withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) modeChevronView.tintColor = AppColor.textTertiary modeChevronView.contentMode = .scaleAspectFit modeChevronView.isUserInteractionEnabled = false settingsStatsDivider.backgroundColor = AppColor.border statsCard.axis = .horizontal statsCard.distribution = .fillEqually firstStatsDivider.backgroundColor = AppColor.border secondStatsDivider.backgroundColor = AppColor.border photoPanelView.backgroundColor = .white selectAllBar.backgroundColor = .white selectAllIconView.contentMode = .scaleAspectFit selectAllTitleLabel.text = "全选" selectAllTitleLabel.font = .systemFont(ofSize: 13) selectAllTitleLabel.textColor = AppColor.textPrimary selectAllCountLabel.font = .systemFont(ofSize: 12) selectAllCountLabel.textColor = AppColor.textSecondary selectAllCountLabel.textAlignment = .right sidebarDivider.backgroundColor = AppColor.border listContainerView.backgroundColor = .white expandSidebarButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) expandSidebarButton.tintColor = AppColor.textSecondary expandSidebarButton.backgroundColor = UIColor(hex: 0xF5F6F8) expandSidebarButton.layer.cornerRadius = 8 expandSidebarButton.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView.backgroundColor = .white collectionView.delegate = self collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) collectionView.register( WiredTransferSectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier ) dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in let cell = collectionView.dequeueReusableCell( withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier, for: indexPath ) as! WiredTransferPhotoCell self?.configurePhotoCell(cell, item: item) return cell } dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in guard kind == UICollectionView.elementKindSectionHeader else { return nil } let header = collectionView.dequeueReusableSupplementaryView( ofKind: kind, withReuseIdentifier: WiredTransferSectionHeaderView.reuseIdentifier, for: indexPath ) as! WiredTransferSectionHeaderView header.apply(title: self?.currentSections[safe: indexPath.section]?.title ?? "") return header } emptyLabel.text = "暂无传输文件" emptyLabel.font = .systemFont(ofSize: 14) emptyLabel.textColor = AppColor.textTertiary emptyLabel.textAlignment = .center bottomBar.backgroundColor = .white bottomButtonsStack.axis = .horizontal bottomButtonsStack.spacing = 8 bottomButtonsStack.distribution = .fillEqually configureBottomButton(batchButton, title: "批量上传", filled: true) configureBottomButton(historyImportButton, title: "历史导入", filled: false) configureBottomButton(albumImportButton, title: "相册导入", filled: false) configureBottomButton(specifyButton, title: "指定上传", filled: false) historyImportButton.isHidden = true historyImportButton.alpha = 0 view.addSubview(headerView) headerView.addSubview(backButton) headerView.addSubview(titleLabel) headerView.addSubview(phoneLabel) view.addSubview(statusCard) statusCard.addSubview(storagePanel) storagePanel.addSubview(storageTitleLabel) storagePanel.addSubview(storageValueLabel) statusCard.addSubview(statusLabel) statusCard.addSubview(refreshButton) statusCard.addSubview(helpLabel) statusCard.addSubview(chipsStack) statusCard.addSubview(settingsStatsDivider) statusCard.addSubview(statsCard) statsCard.addSubview(firstStatsDivider) statsCard.addSubview(secondStatsDivider) view.addSubview(photoPanelView) photoPanelView.addSubview(selectAllBar) selectAllBar.addSubview(selectAllIconView) selectAllBar.addSubview(selectAllTitleLabel) selectAllBar.addSubview(selectAllCountLabel) photoPanelView.addSubview(photoContentView) photoContentView.addSubview(sidebarView) photoContentView.addSubview(sidebarDivider) photoContentView.addSubview(listContainerView) listContainerView.addSubview(collectionView) listContainerView.addSubview(expandSidebarButton) view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(bottomButtonsStack) modeButton.addSubview(modeChevronView) bottomButtonsStack.addArrangedSubview(batchButton) bottomButtonsStack.addArrangedSubview(specifyButton) bottomButtonsStack.addArrangedSubview(albumImportButton) bottomButtonsStack.addArrangedSubview(historyImportButton) } override func setupConstraints() { headerView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(118) } backButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(8) make.bottom.equalToSuperview().offset(-28) make.size.equalTo(44) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(backButton.snp.trailing).offset(4) make.trailing.equalToSuperview().offset(-16) make.top.equalTo(backButton).offset(4) } phoneLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(2) make.leading.trailing.equalTo(titleLabel) } statusCard.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom).offset(-10) make.leading.trailing.equalToSuperview().inset(16) } storagePanel.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.equalToSuperview().offset(14) make.width.equalTo(96) make.height.equalTo(76) } storageTitleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(10) make.leading.trailing.equalToSuperview().inset(8) } storageValueLabel.snp.makeConstraints { make in make.top.equalTo(storageTitleLabel.snp.bottom).offset(6) make.leading.trailing.equalTo(storageTitleLabel) } statusLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.equalTo(storagePanel.snp.trailing).offset(8) make.trailing.lessThanOrEqualTo(refreshButton.snp.leading).offset(-6) make.height.equalTo(22) make.width.greaterThanOrEqualTo(84) } refreshButton.snp.makeConstraints { make in make.centerY.equalTo(statusLabel) make.trailing.equalToSuperview().offset(-14) make.height.equalTo(34) make.width.greaterThanOrEqualTo(64) } helpLabel.snp.makeConstraints { make in make.top.equalTo(statusLabel.snp.bottom).offset(4) make.leading.equalTo(statusLabel) make.trailing.equalToSuperview().offset(-14) } chipsStack.snp.makeConstraints { make in make.top.equalTo(storagePanel.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(14) make.height.equalTo(34) } modeChevronView.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-10) make.centerY.equalToSuperview() make.size.equalTo(16) } settingsStatsDivider.snp.makeConstraints { make in make.top.equalTo(chipsStack.snp.bottom).offset(12) make.leading.trailing.equalToSuperview() make.height.equalTo(1 / UIScreen.main.scale) } statsCard.snp.makeConstraints { make in make.top.equalTo(settingsStatsDivider.snp.bottom).offset(2) make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(68) } firstStatsDivider.snp.makeConstraints { make in make.width.equalTo(1 / UIScreen.main.scale) make.top.bottom.equalToSuperview().inset(14) make.centerX.equalToSuperview().multipliedBy(2.0 / 3.0) } secondStatsDivider.snp.makeConstraints { make in make.width.equalTo(1 / UIScreen.main.scale) make.top.bottom.equalToSuperview().inset(14) make.centerX.equalToSuperview().multipliedBy(4.0 / 3.0) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } bottomButtonsStack.snp.makeConstraints { make in make.top.equalToSuperview().offset(12) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(44) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } photoPanelView.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom).offset(14) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } selectAllBar.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() selectAllBarHeightConstraint = make.height.equalTo(WiredTransferMetrics.selectAllBarHeight).constraint } selectAllIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.centerY.equalToSuperview() make.size.equalTo(20) } selectAllTitleLabel.snp.makeConstraints { make in make.leading.equalTo(selectAllIconView.snp.trailing).offset(8) make.centerY.equalToSuperview() } selectAllCountLabel.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(selectAllTitleLabel.snp.trailing).offset(8) make.trailing.equalToSuperview().offset(-10) make.centerY.equalToSuperview() } photoContentView.snp.makeConstraints { make in make.top.equalTo(selectAllBar.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } expandSidebarButton.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.width.equalTo(24) make.height.equalTo(48) } emptyLabel.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } updateSidebarLayout() } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside) specifyButton.addTarget(self, action: #selector(specifyTapped), for: .touchUpInside) selectAllBar.addTarget(self, action: #selector(selectAllTapped), for: .touchUpInside) expandSidebarButton.addTarget(self, action: #selector(toggleSidebarTapped), for: .touchUpInside) sidebarView.onToggleSidebar = { [weak self] in self?.viewModel.toggleSidebar() } sidebarView.onTimeSlotSelected = { [weak self] slotId in self?.pendingScrollSlotId = slotId self?.viewModel.selectTimeSlot(id: slotId) } modeButton.menu = UIMenu(children: TravelAlbumOTGTransferMode.allCases.map { mode in UIAction(title: mode.title) { [weak self] _ in self?.viewModel.selectTransferMode(mode.title) } }) modeButton.showsMenuAsPrimaryAction = true helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped))) } @MainActor private func applyViewModel() { storageTitleLabel.text = viewModel.deviceModelName storageValueLabel.text = viewModel.availableStorageText statusLabel.text = viewModel.cameraStatusText let isFailed = viewModel.cameraStatusText.contains("失败") || viewModel.cameraStatusText.contains("未连接") statusLabel.textColor = isFailed ? AppColor.danger : AppColor.primary statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10) refreshButton.setTitle(viewModel.actionButtonText, for: .normal) retouchButton.setTitle(viewModel.retouchOption, for: .normal) formatButton.setTitle("JPG", for: .normal) modeButton.setTitle(viewModel.transferModeOption, for: .normal) helpLabel.attributedText = helpText(viewModel.sonyMTPHint) rebuildStats() let sections = viewModel.photoSections currentSections = sections.map(WiredTransferListSection.init) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections(currentSections) for (index, section) in sections.enumerated() { snapshot.appendItems(section.photos, toSection: currentSections[index]) } dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in self?.reconfigureVisiblePhotoCells() self?.scrollToPendingTimeSlotIfNeeded() } sidebarView.apply( groups: viewModel.sidebarGroups, selectedSlotId: viewModel.selectedTimeSlotId ) updateSelectAllBar() updateSidebarLayout() emptyLabel.isHidden = !sections.isEmpty photoPanelView.isHidden = sections.isEmpty if viewModel.selectUploadMode { let count = viewModel.selectedPhotoIds.count batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal) } else { batchButton.setTitle("批量上传", for: .normal) } historyImportButton.setTitle("历史导入", for: .normal) albumImportButton.setTitle("相册导入", for: .normal) specifyButton.isEnabled = viewModel.canOpenSpecifyUploadChooser specifyButton.alpha = specifyButton.isEnabled ? 1 : 0.55 updateHistoryImportButtonVisibility(viewModel.isCameraConnected, animated: view.window != nil) } private func updateHistoryImportButtonVisibility(_ visible: Bool, animated: Bool) { guard visible != isHistoryImportButtonVisible else { return } isHistoryImportButtonVisible = visible if visible { historyImportButton.isHidden = false historyImportButton.alpha = 0 } let changes = { self.historyImportButton.alpha = visible ? 1 : 0 if !visible { self.historyImportButton.isHidden = true } self.bottomBar.layoutIfNeeded() self.view.layoutIfNeeded() } guard animated else { changes() return } UIView.animate( withDuration: 0.25, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut], animations: changes ) } private func updateSelectAllBar() { let showSelectAll = viewModel.selectUploadMode && !viewModel.photoSections.isEmpty selectAllBar.isHidden = !showSelectAll selectAllBarHeightConstraint?.update(offset: showSelectAll ? WiredTransferMetrics.selectAllBarHeight : 0) let allSelected = viewModel.allVisibleSelectablePhotosSelected selectAllIconView.image = UIImage(systemName: allSelected ? "checkmark.circle.fill" : "circle") selectAllIconView.tintColor = allSelected ? AppColor.primary : AppColor.textTertiary selectAllCountLabel.text = "已选 \(viewModel.selectedVisibleUploadCount) / \(viewModel.selectableVisiblePhotoIds.count)" selectAllBar.isEnabled = !viewModel.selectableVisiblePhotoIds.isEmpty selectAllBar.alpha = selectAllBar.isEnabled ? 1 : 0.55 } private func updateSidebarLayout() { let showSidebar = viewModel.sidebarExpanded && !viewModel.sidebarGroups.isEmpty sidebarView.isHidden = !showSidebar sidebarDivider.isHidden = !showSidebar expandSidebarButton.isHidden = viewModel.sidebarExpanded || viewModel.sidebarGroups.isEmpty sidebarView.snp.remakeConstraints { make in make.top.leading.bottom.equalToSuperview() make.width.equalTo(showSidebar ? WiredTransferMetrics.sidebarWidth : 0) } sidebarDivider.snp.remakeConstraints { make in make.top.bottom.equalToSuperview() make.leading.equalTo(sidebarView.snp.trailing) make.width.equalTo(showSidebar ? 1 : 0) } listContainerView.snp.remakeConstraints { make in make.top.bottom.trailing.equalToSuperview() make.leading.equalTo(sidebarDivider.snp.trailing) } } private func scrollToPendingTimeSlotIfNeeded() { guard let slotId = pendingScrollSlotId, let sectionIndex = currentSections.firstIndex(where: { $0.id == slotId }), collectionView.numberOfItems(inSection: sectionIndex) > 0 else { pendingScrollSlotId = nil return } collectionView.scrollToItem( at: IndexPath(item: 0, section: sectionIndex), at: .top, animated: true ) pendingScrollSlotId = nil } private func configurePhotoCell(_ cell: WiredTransferPhotoCell, item: TravelAlbumOTGPhotoItem) { let selected = viewModel.selectedPhotoIds.contains(item.id) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) } } private func reconfigureVisiblePhotoCells() { for indexPath in collectionView.indexPathsForVisibleItems { guard let cell = collectionView.cellForItem(at: indexPath) as? WiredTransferPhotoCell, let item = dataSource.itemIdentifier(for: indexPath) else { continue } configurePhotoCell(cell, item: item) } } private func syncSidebarSelectionWithCurrentScrollPosition() { guard !currentSections.isEmpty else { return } let visibleTopY = collectionView.contentOffset.y + collectionView.adjustedContentInset.top + WiredTransferMetrics.sectionHeaderHeight let visibleItems = collectionView.indexPathsForVisibleItems.compactMap { indexPath -> (IndexPath, CGRect)? in guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { return nil } return (indexPath, attributes.frame) } guard let target = visibleItems .filter({ $0.1.maxY >= visibleTopY }) .min(by: { lhs, rhs in abs(lhs.1.minY - visibleTopY) < abs(rhs.1.minY - visibleTopY) }), let slotId = currentSections[safe: target.0.section]?.id, slotId != viewModel.selectedTimeSlotId else { return } viewModel.syncVisibleTimeSlot(id: slotId) sidebarView.apply( groups: viewModel.sidebarGroups, selectedSlotId: viewModel.selectedTimeSlotId ) } private func rebuildStats() { statsCard.arrangedSubviews.forEach { view in statsCard.removeArrangedSubview(view) view.removeFromSuperview() } for tab in TravelAlbumOTGTransferTab.allCases { let selected = viewModel.selectedTab == tab let button = makeStatButton( title: tab.title, count: viewModel.tabCounts[tab.rawValue], selected: selected, danger: tab == .failed ) button.tag = tab.rawValue button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) statsCard.addArrangedSubview(button) } statsCard.bringSubviewToFront(firstStatsDivider) statsCard.bringSubviewToFront(secondStatsDivider) } private func configureChipButton(_ button: UIButton) { button.titleLabel?.font = .systemFont(ofSize: 11) button.setTitleColor(AppColor.textTertiary, for: .normal) button.backgroundColor = AppColor.pageBackground button.layer.cornerRadius = 8 button.layer.borderWidth = 1 button.layer.borderColor = AppColor.border.cgColor button.contentHorizontalAlignment = .leading button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) } private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton { var config = UIButton.Configuration.plain() config.title = "\(count)" config.subtitle = title config.titleAlignment = .center config.titlePadding = 2 config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) let foregroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary) config.baseForegroundColor = foregroundColor config.background.backgroundColor = .white config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var outgoing = incoming outgoing.font = .systemFont(ofSize: 17, weight: selected ? .semibold : .medium) outgoing.foregroundColor = foregroundColor return outgoing } config.subtitleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var outgoing = incoming outgoing.font = .systemFont(ofSize: 12, weight: selected ? .semibold : .regular) outgoing.foregroundColor = foregroundColor return outgoing } let button = UIButton(configuration: config) button.contentVerticalAlignment = .center return button } private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 15) button.layer.cornerRadius = 10 if filled { button.backgroundColor = AppColor.primary button.setTitleColor(.white, for: .normal) } else { button.backgroundColor = .white button.setTitleColor(AppColor.primary, for: .normal) button.layer.borderWidth = 1 button.layer.borderColor = AppColor.primary.cgColor } } private func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { _, _ in let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .estimated(WiredTransferMetrics.rowHeight) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .estimated(WiredTransferMetrics.rowHeight) ) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) let headerSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .absolute(WiredTransferMetrics.sectionHeaderHeight) ) let header = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top ) header.pinToVisibleBounds = true section.boundarySupplementaryItems = [header] return section } } private func helpText(_ sonyHint: String?) -> NSAttributedString { let text = NSMutableAttributedString( string: sonyHint ?? "未连接,查看", attributes: [.foregroundColor: AppColor.textSecondary] ) if sonyHint == nil { text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary])) } return text } @objc private func backTapped() { navigationController?.popViewController(animated: true) } @objc private func refreshTapped() { viewModel.refreshCameraFiles() } @objc private func batchTapped() { viewModel.onBatchUploadButtonClick() } @objc private func historyImportTapped() { guard let importViewModel = viewModel.makeCameraImportViewModel() else { return } viewModel.pauseLiveTransferForHistoricalImport() let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel) importController.onImportFinished = { [weak self] importedCount, failedCount, importedPhotoIds in guard let self else { return } self.viewModel.handleNewlyImportedPhotoIds(importedPhotoIds) self.viewModel.start() if failedCount > 0 { self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败") } else { self.showToast("已导入 \(importedCount) 张照片") } } importController.onImportCancelled = { [weak self] in self?.viewModel.start() } let navigationController = UINavigationController(rootViewController: importController) navigationController.modalPresentationStyle = .pageSheet present(navigationController, animated: true) } @objc private func albumImportTapped() { var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .images configuration.selectionLimit = 0 let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } @objc private func specifyTapped() { guard viewModel.canOpenSpecifyUploadChooser else { return } let controller = WiredSpecifyUploadViewController(options: viewModel.specifyUploadOptions) controller.onConfirm = { [weak self] option in self?.viewModel.onSpecifyUploadOptionSelected(option) } controller.modalPresentationStyle = .pageSheet if let sheet = controller.sheetPresentationController { sheet.detents = [ .custom(identifier: .init("wiredSpecifyUpload")) { _ in 260 } ] sheet.prefersGrabberVisible = false sheet.preferredCornerRadius = 16 } present(controller, animated: true) } @objc private func selectAllTapped() { viewModel.toggleVisibleSelectablePhotos() } @objc private func toggleSidebarTapped() { viewModel.toggleSidebar() } @objc private func tabTapped(_ sender: UIButton) { guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return } viewModel.selectTab(tab) } @objc private func helpTapped() { guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return } UIApplication.shared.open(url) } private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) { guard let url = item.thumbnailURL else { showToast("暂无可预览图片") return } let previewItem: MediaPreviewItem = url.isFileURL ? .localImage(url: url) : MediaPreviewItem(source: .remoteImage(url)) MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0) } } extension WiredCameraTransferViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } Task { @MainActor in showLoading() let items = await loadPhoneAlbumImportItems(from: results) let result = viewModel.importPhoneAlbumImages(items) hideLoading() if result.failedCount > 0 { showToast("已导入 \(result.importedCount) 张,\(result.failedCount) 张失败") } else if result.importedCount > 0 { showToast("已导入 \(result.importedCount) 张照片") } else { showToast("未能导入照片") } } } private func loadPhoneAlbumImportItems(from results: [PHPickerResult]) async -> [TravelAlbumPhoneAlbumImportItem] { var items: [TravelAlbumPhoneAlbumImportItem] = [] for (index, result) in results.enumerated() { if let item = await Self.loadPhoneAlbumImportItem(from: result, index: index) { items.append(item) } } return items } private static func loadPhoneAlbumImportItem( from result: PHPickerResult, index: Int ) async -> TravelAlbumPhoneAlbumImportItem? { let provider = result.itemProvider let typeIdentifier = preferredImageTypeIdentifier(from: provider) return await withCheckedContinuation { continuation in provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in guard let data, !data.isEmpty else { continuation.resume(returning: nil) return } continuation.resume( returning: TravelAlbumPhoneAlbumImportItem( data: data, fileName: importFileName(provider: provider, typeIdentifier: typeIdentifier, index: index), createdAt: Date() ) ) } } } private static func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String { provider.registeredTypeIdentifiers.first { identifier in UTType(identifier)?.conforms(to: .image) == true } ?? UTType.image.identifier } private static func importFileName(provider: NSItemProvider, typeIdentifier: String, index: Int) -> String { let fallback = "album_image_\(Int(Date().timeIntervalSince1970))_\(index)" let rawName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) let baseName = rawName?.isEmpty == false ? rawName! : fallback guard URL(fileURLWithPath: baseName).pathExtension.isEmpty else { return baseName } let ext = UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg" return "\(baseName).\(ext)" } } /// OTG 指定上传选项弹窗。 private final class WiredSpecifyUploadViewController: UIViewController { var onConfirm: ((TravelAlbumOTGSpecifyUploadOption) -> Void)? private let options: [TravelAlbumOTGSpecifyUploadOption] private var selectedOption: TravelAlbumOTGSpecifyUploadOption private var optionRows: [WiredSpecifyUploadOptionRow] = [] private let headerView = UIView() private let cancelTitleButton = UIButton(type: .system) private let titleLabel = UILabel() private let optionsStack = UIStackView() private let bottomButtonsStack = UIStackView() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) init(options: [TravelAlbumOTGSpecifyUploadOption]) { let resolvedOptions = options.isEmpty ? TravelAlbumOTGSpecifyUploadOption.allCases : options self.options = resolvedOptions selectedOption = resolvedOptions[0] super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() setupUI() setupConstraints() } private func setupUI() { view.backgroundColor = AppColor.pageBackground cancelTitleButton.setTitle("取消", for: .normal) cancelTitleButton.setTitleColor(AppColor.primary, for: .normal) cancelTitleButton.titleLabel?.font = .systemFont(ofSize: 16) cancelTitleButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) titleLabel.text = "指定上传" titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center optionsStack.axis = .vertical optionsStack.spacing = 12 for option in options { let row = WiredSpecifyUploadOptionRow(option: option) row.apply(selected: option == selectedOption) row.onTap = { [weak self] option in self?.selectOption(option) } optionsStack.addArrangedSubview(row) row.snp.makeConstraints { make in make.height.equalTo(52) } optionRows.append(row) } bottomButtonsStack.axis = .horizontal bottomButtonsStack.spacing = 12 bottomButtonsStack.distribution = .fillEqually configureActionButton(cancelButton, title: "取消", filled: false) configureActionButton(confirmButton, title: "确认上传", filled: true) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) view.addSubview(headerView) headerView.addSubview(cancelTitleButton) headerView.addSubview(titleLabel) view.addSubview(optionsStack) view.addSubview(bottomButtonsStack) bottomButtonsStack.addArrangedSubview(cancelButton) bottomButtonsStack.addArrangedSubview(confirmButton) } private func setupConstraints() { headerView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(52) } cancelTitleButton.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.height.equalTo(40) } titleLabel.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.greaterThanOrEqualTo(cancelTitleButton.snp.trailing).offset(12) } optionsStack.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom) make.leading.trailing.equalToSuperview().inset(16) } bottomButtonsStack.snp.makeConstraints { make in make.top.equalTo(optionsStack.snp.bottom).offset(20) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(48) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } } private func configureActionButton(_ button: UIButton, title: String, filled: Bool) { button.setTitle(title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) button.layer.cornerRadius = 10 if filled { button.backgroundColor = AppColor.primary button.setTitleColor(.white, for: .normal) } else { button.backgroundColor = .white button.setTitleColor(AppColor.primary, for: .normal) button.layer.borderWidth = 1 button.layer.borderColor = AppColor.primary.cgColor } } private func selectOption(_ option: TravelAlbumOTGSpecifyUploadOption) { selectedOption = option optionRows.forEach { row in row.apply(selected: row.option == option) } } @objc private func cancelTapped() { dismiss(animated: true) } @objc private func confirmTapped() { let option = selectedOption dismiss(animated: true) { [onConfirm] in onConfirm?(option) } } } /// OTG 指定上传单选行。 private final class WiredSpecifyUploadOptionRow: UIControl { let option: TravelAlbumOTGSpecifyUploadOption var onTap: ((TravelAlbumOTGSpecifyUploadOption) -> Void)? private let titleLabel = UILabel() private let radioView = UIImageView() init(option: TravelAlbumOTGSpecifyUploadOption) { self.option = option super.init(frame: .zero) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(selected: Bool) { radioView.image = UIImage(systemName: selected ? "largecircle.fill.circle" : "circle") radioView.tintColor = selected ? AppColor.textPrimary : AppColor.border } private func setupUI() { backgroundColor = .white layer.cornerRadius = 10 layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor titleLabel.text = option.title titleLabel.font = .systemFont(ofSize: 15) titleLabel.textColor = AppColor.textPrimary radioView.contentMode = .scaleAspectFit addTarget(self, action: #selector(rowTapped), for: .touchUpInside) addSubview(titleLabel) addSubview(radioView) titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(14) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualTo(radioView.snp.leading).offset(-10) } radioView.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-14) make.centerY.equalToSuperview() make.size.equalTo(24) } } @objc private func rowTapped() { onTap?(option) } } extension WiredCameraTransferViewController: UICollectionViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView === collectionView else { return } syncSidebarSelectionWithCurrentScrollPosition() } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } if viewModel.selectUploadMode { viewModel.toggleTransferPhotoSelection(photoId: item.id) } else { presentPhotoPreview(item) } } } /// 有线传输照片列表时间段 Header。 private final class WiredTransferSectionHeaderView: UICollectionReusableView { static let reuseIdentifier = "WiredTransferSectionHeaderView" private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.pageBackground titleLabel.font = .systemFont(ofSize: 11) titleLabel.textColor = AppColor.textSecondary addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(10) make.centerY.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(title: String) { titleLabel.text = title } } /// 有线传输照片列表 Cell。 private final class WiredTransferPhotoCell: UICollectionViewCell { static let reuseIdentifier = "WiredTransferPhotoCell" private static let previewImageSize = CGSize(width: 96, height: 96) private let selectionIconView = UIImageView() private let imageView = UIImageView() private let statusLabel = UILabel() private let titleLabel = UILabel() private let sizeLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .default) private let menuButton = UIButton(type: .system) private let separatorView = UIView() var onRetry: (() -> Void)? var onDelete: (() -> Void)? 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() imageView.kf.cancelDownloadTask() imageView.image = nil onRetry = nil onDelete = nil menuButton.menu = nil } func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { let canSelect = item.canSelectForUpload let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1 if let url = item.thumbnailURL { let processor = DownsamplingImageProcessor(size: Self.previewImageSize) imageView.kf.setImage( with: url, placeholder: UIImage(systemName: "photo"), options: [ .processor(processor), .scaleFactor(UIScreen.main.scale), .backgroundDecode ] ) } else { imageView.image = UIImage(systemName: "photo") imageView.tintColor = AppColor.textTertiary } imageView.alpha = rowAlpha titleLabel.text = item.fileName titleLabel.alpha = rowAlpha sizeLabel.text = item.fileSizeText sizeLabel.alpha = rowAlpha statusLabel.text = statusText(item.status) statusLabel.textColor = statusTextColor(item.status) statusLabel.backgroundColor = statusBackgroundColor(item.status) progressView.isHidden = item.status != .uploading && item.status != .transferring progressView.progress = Float(item.progress) / 100.0 selectionIconView.isHidden = !selectionMode selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) menuButton.isHidden = selectionMode menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending) updateImageConstraints(selectionMode: selectionMode) } private func setupUI() { contentView.backgroundColor = .white selectionIconView.contentMode = .scaleAspectFit imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 6 statusLabel.font = .systemFont(ofSize: 9) statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 3 statusLabel.clipsToBounds = true statusLabel.setContentHuggingPriority(.required, for: .horizontal) statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal) titleLabel.font = .systemFont(ofSize: 12, weight: .medium) titleLabel.textColor = AppColor.textPrimary titleLabel.lineBreakMode = .byTruncatingMiddle titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.textColor = AppColor.textTertiary progressView.progressTintColor = AppColor.primary progressView.trackTintColor = AppColor.border menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal) menuButton.tintColor = AppColor.textSecondary menuButton.showsMenuAsPrimaryAction = true separatorView.backgroundColor = AppColor.border contentView.addSubview(selectionIconView) contentView.addSubview(imageView) contentView.addSubview(titleLabel) contentView.addSubview(statusLabel) contentView.addSubview(sizeLabel) contentView.addSubview(progressView) contentView.addSubview(menuButton) contentView.addSubview(separatorView) selectionIconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.centerY.equalToSuperview() make.size.equalTo(20) } updateImageConstraints(selectionMode: true) titleLabel.snp.makeConstraints { make in make.top.equalTo(imageView).offset(1) make.leading.equalTo(imageView.snp.trailing).offset(8) make.trailing.equalTo(statusLabel.snp.leading).offset(-4) } statusLabel.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) make.trailing.equalTo(menuButton.snp.leading).offset(-4) make.height.equalTo(17) make.width.greaterThanOrEqualTo(44) } sizeLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(5) make.leading.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-10) } progressView.snp.makeConstraints { make in make.leading.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-10) make.top.equalTo(sizeLabel.snp.bottom).offset(7) make.height.equalTo(2) } menuButton.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) make.trailing.equalToSuperview().offset(-4) make.size.equalTo(32) } separatorView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(10) make.bottom.equalToSuperview() make.height.equalTo(1 / UIScreen.main.scale) } } private func makeMenu(canRetry: Bool) -> UIMenu { let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in self?.onRetry?() } retry.attributes = canRetry ? [] : [.disabled] let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.onDelete?() } return UIMenu(children: [retry, delete]) } private func updateImageConstraints(selectionMode: Bool) { imageView.snp.remakeConstraints { make in if selectionMode { make.leading.equalTo(selectionIconView.snp.trailing).offset(8) } else { make.leading.equalToSuperview().offset(10) } make.top.equalToSuperview().offset(8) make.size.equalTo(48) make.bottom.lessThanOrEqualToSuperview().offset(-8) } } private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String { switch status { case .pending: return "待上传" case .transferring: return "传输中" case .uploading: return "上传中" case .uploaded: return "已上传" case .failed: return "上传失败" } } private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { switch status { case .pending: return AppColor.textSecondary case .transferring, .uploading: return AppColor.primary case .uploaded: return UIColor(hex: 0x16A34A) case .failed: return AppColor.danger } } private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { switch status { case .pending: return AppColor.pageBackground case .transferring, .uploading: return AppColor.primary.withAlphaComponent(0.12) case .uploaded: return UIColor(hex: 0x16A34A).withAlphaComponent(0.12) case .failed: return AppColor.danger.withAlphaComponent(0.12) } } } /// 有线传输页左侧时间快速导航。 private final class WiredTransferTimeSidebarView: UIView { var onToggleSidebar: (() -> Void)? var onTimeSlotSelected: ((String) -> Void)? private let headerButton = UIButton(type: .system) private let scrollView = UIScrollView() private let stackView = UIStackView() private weak var selectedRowView: TimeSlotRowView? override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(groups: [TravelAlbumOTGDateGroup], selectedSlotId: String?) { selectedRowView = nil stackView.arrangedSubviews.forEach { view in stackView.removeArrangedSubview(view) view.removeFromSuperview() } for group in groups { let dateContainer = UIView() let dateLabel = UILabel() dateLabel.text = group.dateLabel dateLabel.font = .systemFont(ofSize: 12) dateLabel.textColor = AppColor.textTertiary dateContainer.addSubview(dateLabel) stackView.addArrangedSubview(dateContainer) dateContainer.snp.makeConstraints { make in make.height.equalTo(24) } dateLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.trailing.equalToSuperview() make.bottom.equalToSuperview().offset(-4) } for slot in group.slots { let row = TimeSlotRowView() let selected = slot.id == selectedSlotId row.apply(slot: slot, selected: selected) if selected { selectedRowView = row } row.onTap = { [weak self] in self?.onTimeSlotSelected?(slot.id) } stackView.addArrangedSubview(row) row.snp.makeConstraints { make in make.height.equalTo(52) } } } scrollSelectedRowIntoVisible() } private func setupUI() { backgroundColor = UIColor(hex: 0xF5F6F8) headerButton.setTitle("收起", for: .normal) headerButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) headerButton.tintColor = AppColor.textSecondary headerButton.setTitleColor(AppColor.textSecondary, for: .normal) headerButton.titleLabel?.font = .systemFont(ofSize: 11) headerButton.semanticContentAttribute = .forceLeftToRight headerButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) scrollView.showsVerticalScrollIndicator = false stackView.axis = .vertical addSubview(headerButton) addSubview(scrollView) scrollView.addSubview(stackView) headerButton.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(36) } scrollView.snp.makeConstraints { make in make.top.equalTo(headerButton.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } stackView.snp.makeConstraints { make in make.edges.equalToSuperview() make.width.equalToSuperview() } } @objc private func toggleTapped() { onToggleSidebar?() } private func scrollSelectedRowIntoVisible() { guard let selectedRowView else { return } layoutIfNeeded() let rowFrame = selectedRowView.convert(selectedRowView.bounds, to: stackView) scrollView.scrollRectToVisible(rowFrame.insetBy(dx: 0, dy: -8), animated: false) } } private final class TimeSlotRowView: UIControl { private let timeLabel = UILabel() private let countLabel = UILabel() private let arrowView = UIImageView(image: UIImage(systemName: "play.fill")) var onTap: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(slot: TravelAlbumOTGTimeSlot, selected: Bool) { backgroundColor = selected ? .white : .clear timeLabel.text = slot.slotStartLabel timeLabel.font = .systemFont(ofSize: 13, weight: selected ? .semibold : .regular) timeLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary arrowView.tintColor = selected ? AppColor.primary : AppColor.textPrimary countLabel.text = "\(slot.photoCount)张" } private func setupUI() { arrowView.contentMode = .scaleAspectFit timeLabel.textAlignment = .center countLabel.font = .systemFont(ofSize: 10) countLabel.textColor = AppColor.textTertiary countLabel.textAlignment = .center addTarget(self, action: #selector(rowTapped), for: .touchUpInside) addSubview(arrowView) addSubview(timeLabel) addSubview(countLabel) arrowView.snp.makeConstraints { make in make.centerY.equalTo(timeLabel) make.trailing.equalTo(timeLabel.snp.leading).offset(-2) make.size.equalTo(10) } timeLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(8) make.centerX.equalToSuperview().offset(6) } countLabel.snp.makeConstraints { make in make.top.equalTo(timeLabel.snp.bottom).offset(2) make.centerX.equalToSuperview() } } @objc private func rowTapped() { onTap?() } } private extension Array { subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } }