Files
suixinkan_uikit/suixinkan/UI/TravelAlbum/TravelAlbumAIJobListViewController.swift

676 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Kingfisher
import SnapKit
import UIKit
/// 当前账号的 AI 修图任务中心,提供筛选、游标分页、刷新与状态轮询。
final class TravelAlbumAIJobListViewController: BaseViewController {
private enum Section { case main }
private let viewModel = TravelAlbumAIJobListViewModel()
private let api: any TravelAlbumServing
private let filterContainer = UIView()
private let filterStack = UIStackView()
private var filterButtons: [TravelAlbumAIJobFilter: UIButton] = [:]
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private let refreshControl = UIRefreshControl()
private let emptyView = AIJobEmptyView()
private var dataSource: UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>!
private var pollingTask: Task<Void, Never>?
private var isVisible = false
private var isPresentingLoading = false
/// 创建任务中心,可注入 API 以支持测试。
init(api: (any TravelAlbumServing)? = nil) {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "AI修图任务"
titleLabel.textColor = UIColor(hex: 0x0F1F3D)
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
navigationItem.titleView = titleLabel
navigationItem.backButtonDisplayMode = .minimal
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
configureFilters()
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.refreshControl = refreshControl
collectionView.register(AIJobSummaryCell.self, forCellWithReuseIdentifier: AIJobSummaryCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>(collectionView: collectionView) {
collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: AIJobSummaryCell.reuseIdentifier,
for: indexPath
) as! AIJobSummaryCell
cell.apply(item)
return cell
}
emptyView.onRetry = { [weak self] in self?.reload() }
view.addSubview(filterContainer)
filterContainer.addSubview(filterStack)
view.addSubview(collectionView)
view.addSubview(emptyView)
}
override func setupConstraints() {
filterContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(46)
}
filterStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(2)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
}
emptyView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadFirstPage(api: api) }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isVisible = true
updateLoadingPresentation()
updatePolling()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isVisible = false
setLoadingPresented(false)
stopPolling()
}
deinit {
pollingTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
private func configureFilters() {
filterContainer.accessibilityIdentifier = "aiRetouchJob.filterContainer"
filterContainer.backgroundColor = .white
filterContainer.layer.cornerRadius = 12
filterContainer.layer.borderWidth = 1
filterContainer.layer.borderColor = UIColor(hex: 0xE8EEF7).cgColor
filterContainer.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.12).cgColor
filterContainer.layer.shadowOpacity = 1
filterContainer.layer.shadowRadius = 7
filterContainer.layer.shadowOffset = CGSize(width: 0, height: 3)
filterStack.axis = .horizontal
filterStack.spacing = 0
filterStack.distribution = .fillEqually
TravelAlbumAIJobFilter.allCases.forEach { filter in
var configuration = UIButton.Configuration.plain()
configuration.title = filter.title
configuration.contentInsets = .zero
let button = UIButton(configuration: configuration)
button.tag = TravelAlbumAIJobFilter.allCases.firstIndex(of: filter) ?? 0
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
button.layer.cornerRadius = 10
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
button.accessibilityLabel = "筛选:\(filter.title)"
filterButtons[filter] = button
filterStack.addArrangedSubview(button)
}
}
private func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { _, environment in
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(1),
heightDimension: .estimated(250)
))
let group = NSCollectionLayoutGroup.vertical(
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(250)),
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 14
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20)
return section
}
}
@MainActor
private func applyState() {
filterButtons.forEach { filter, button in
let selected = filter == viewModel.selectedFilter
button.configuration?.baseForegroundColor = selected ? .white : AppColor.textSecondary
button.configuration?.background.backgroundColor = .clear
button.backgroundColor = selected ? AppColor.primary : .clear
button.accessibilityTraits = selected ? [.button, .selected] : .button
}
refreshControl.endRefreshing()
var snapshot = NSDiffableDataSourceSnapshot<Section, TravelAlbumAIJobSummary>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: true)
let empty = viewModel.items.isEmpty && !viewModel.isLoading
emptyView.isHidden = !empty
emptyView.apply(
title: viewModel.errorMessage == nil ? "暂无AI修图任务" : "任务加载失败",
message: viewModel.errorMessage ?? "提交AI修图后,可以在这里查看进度和结果。",
showsRetry: viewModel.errorMessage != nil
)
updateLoadingPresentation()
updatePolling()
}
@MainActor
private func updateLoadingPresentation() {
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.items.isEmpty)
}
@MainActor
private func setLoadingPresented(_ presented: Bool) {
guard isPresentingLoading != presented else { return }
isPresentingLoading = presented
if presented {
showLoading()
} else {
hideLoading()
}
}
private func reload() { Task { await viewModel.loadFirstPage(api: api) } }
@objc private func refreshTriggered() {
Task { await viewModel.loadFirstPage(api: api, refreshing: true) }
}
@objc private func filterTapped(_ sender: UIButton) {
guard TravelAlbumAIJobFilter.allCases.indices.contains(sender.tag) else { return }
Task { await viewModel.selectFilter(TravelAlbumAIJobFilter.allCases[sender.tag], api: api) }
}
@objc private func applicationBecameActive() { updatePolling() }
@objc private func applicationEnteredBackground() { stopPolling() }
private func updatePolling() {
guard isVisible,
UIApplication.shared.applicationState == .active,
viewModel.containsInProgressJobs,
pollingTask == nil
else {
if !viewModel.containsInProgressJobs { stopPolling() }
return
}
pollingTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard !Task.isCancelled, let self else { break }
await self.viewModel.loadFirstPage(api: self.api, silent: true)
}
}
}
private func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
}
}
extension TravelAlbumAIJobListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
navigationController?.pushViewController(
TravelAlbumAIJobDetailViewController(batchId: item.aiRetouchBatchId, api: api),
animated: true
)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentSize.height > 0,
scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height - 240
else { return }
Task { await viewModel.loadMore(api: api) }
}
}
/// AI 修图任务列表卡片。
private final class AIJobSummaryCell: UICollectionViewCell {
static let reuseIdentifier = "AIJobSummaryCell"
private let topInfoView = UIView()
private let albumCoverImageView = UIImageView()
private let albumLabel = UILabel()
private let phoneLabel = UILabel()
private let taskLabel = UILabel()
private let timeIconView = UIImageView(image: UIImage(systemName: "clock"))
private let timeLabel = UILabel()
private let timeStack = UIStackView()
private let statusBadge = AIJobStatusBadgeView()
private let previewStack = UIStackView()
private var previewImageViews: [UIImageView] = []
private let progressRow = UIStackView()
private let completedLabel = UILabel()
private let outputLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
private let etaLabel = UILabel()
private let bottomRow = UIView()
private let outputPill = UIView()
private let outputIconView = UIImageView(image: UIImage(systemName: "wand.and.stars"))
private let detailsLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.right"))
private let contentStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 16
contentView.layer.borderWidth = 1
contentView.layer.borderColor = UIColor(hex: 0xE6EDF8).cgColor
contentView.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.1).cgColor
contentView.layer.shadowOpacity = 1
contentView.layer.shadowRadius = 9
contentView.layer.shadowOffset = CGSize(width: 0, height: 4)
configureTopInfo()
configurePreviewGrid()
completedLabel.font = .systemFont(ofSize: 14, weight: .medium)
completedLabel.textColor = UIColor(hex: 0x263754)
progressView.tintColor = AppColor.primary
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
percentLabel.textColor = UIColor(hex: 0x263754)
percentLabel.textAlignment = .right
progressRow.axis = .horizontal
progressRow.alignment = .center
progressRow.spacing = 10
progressRow.addArrangedSubview(completedLabel)
progressRow.addArrangedSubview(progressView)
progressRow.addArrangedSubview(percentLabel)
progressView.snp.makeConstraints { $0.height.equalTo(6) }
completedLabel.setContentHuggingPriority(.required, for: .horizontal)
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
etaLabel.font = .systemFont(ofSize: 13)
etaLabel.textColor = UIColor(hex: 0x7F8CA3)
configureBottomRow()
contentStack.axis = .vertical
contentStack.spacing = 12
[topInfoView, previewStack, progressRow, etaLabel, bottomRow].forEach {
contentStack.addArrangedSubview($0)
}
contentView.addSubview(contentStack)
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(14) }
topInfoView.snp.makeConstraints { $0.height.equalTo(72) }
previewStack.snp.makeConstraints { $0.height.equalTo(104) }
bottomRow.snp.makeConstraints { $0.height.equalTo(34) }
accessibilityIdentifier = "aiRetouchJob.summaryCell"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func prepareForReuse() {
super.prepareForReuse()
albumCoverImageView.kf.cancelDownloadTask()
albumCoverImageView.image = nil
previewImageViews.forEach {
$0.kf.cancelDownloadTask()
$0.image = nil
$0.isHidden = true
}
}
func apply(_ item: TravelAlbumAIJobSummary) {
albumLabel.text = item.album.name.isEmpty ? "未命名相册" : item.album.name
let phone = TravelAlbumDisplayFormatter.maskPhone(item.album.userPhone)
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
taskLabel.text = "任务 #\(item.aiRetouchBatchId)"
timeLabel.text = relativeTime(item.createdAt)
statusBadge.apply(item.status)
albumCoverImageView.kf.setImage(
with: URL(string: item.album.coverURL),
placeholder: UIImage(systemName: "photo")
)
previewImageViews.enumerated().forEach { index, imageView in
guard item.previewImages.indices.contains(index) else {
imageView.isHidden = true
return
}
imageView.isHidden = false
imageView.kf.setImage(
with: URL(string: item.previewImages[index].thumbnailURL),
placeholder: UIImage(systemName: "photo")
)
}
previewStack.isHidden = item.previewImages.isEmpty
progressView.progress = Float(item.progress.fraction)
if item.status.isInProgress {
completedLabel.text = "已完成 \(item.progress.completed) / \(item.progress.total)"
percentLabel.text = "\(Int((item.progress.fraction * 100).rounded()))%"
etaLabel.text = item.estimatedFinishAt.map {
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
} ?? "完成后将通过消息通知"
progressRow.isHidden = false
etaLabel.isHidden = false
outputIconView.image = UIImage(systemName: "wand.and.stars")
outputIconView.tintColor = AppColor.primary
outputPill.backgroundColor = AppColor.primaryLight
outputLabel.textColor = AppColor.primary
outputLabel.text = item.outputs.map { "\(shortTitle($0.type)) \($0.count)张" }.joined(separator: " · ")
} else if item.status == .partiallySucceeded {
progressRow.isHidden = true
etaLabel.isHidden = true
outputIconView.image = UIImage(systemName: "exclamationmark.circle.fill")
outputIconView.tintColor = AppColor.warning
outputPill.backgroundColor = .clear
outputLabel.textColor = UIColor(hex: 0xB56A00)
outputLabel.text = "\(item.progress.succeeded)张成功 · \(item.progress.failed)张失败"
} else {
progressRow.isHidden = true
etaLabel.isHidden = true
let succeeded = item.status == .succeeded
outputIconView.image = UIImage(systemName: succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
outputIconView.tintColor = succeeded ? AppColor.success : AppColor.danger
outputPill.backgroundColor = .clear
outputLabel.textColor = succeeded ? UIColor(hex: 0x178A55) : AppColor.danger
outputLabel.text = succeeded ? "\(item.progress.succeeded)张结果已生成" : item.status.title
}
accessibilityLabel = [albumLabel.text, statusBadge.accessibilityLabel, timeLabel.text, phoneLabel.text, taskLabel.text,
outputLabel.text, completedLabel.text, etaLabel.text]
.compactMap { $0 }.joined(separator: ",")
accessibilityTraits = .button
}
private func configureTopInfo() {
albumCoverImageView.accessibilityIdentifier = "aiRetouchJob.albumCover"
albumCoverImageView.contentMode = .scaleAspectFill
albumCoverImageView.clipsToBounds = true
albumCoverImageView.layer.cornerRadius = 10
albumCoverImageView.backgroundColor = AppColor.pageBackground
albumCoverImageView.tintColor = AppColor.textTertiary
albumLabel.font = .systemFont(ofSize: 17, weight: .semibold)
albumLabel.textColor = UIColor(hex: 0x0F1F3D)
albumLabel.lineBreakMode = .byTruncatingTail
phoneLabel.font = .systemFont(ofSize: 14)
phoneLabel.textColor = UIColor(hex: 0x72809A)
taskLabel.font = .systemFont(ofSize: 14)
taskLabel.textColor = UIColor(hex: 0x72809A)
timeIconView.tintColor = UIColor(hex: 0x7F8CA3)
timeIconView.contentMode = .scaleAspectFit
timeLabel.font = .systemFont(ofSize: 13)
timeLabel.textColor = UIColor(hex: 0x7F8CA3)
timeStack.axis = .horizontal
timeStack.spacing = 5
timeStack.alignment = .center
timeStack.addArrangedSubview(timeIconView)
timeStack.addArrangedSubview(timeLabel)
[albumCoverImageView, albumLabel, phoneLabel, taskLabel, timeStack, statusBadge].forEach {
topInfoView.addSubview($0)
}
albumCoverImageView.snp.makeConstraints { make in
make.leading.top.equalToSuperview()
make.size.equalTo(68)
}
timeIconView.snp.makeConstraints { $0.size.equalTo(15) }
timeStack.snp.makeConstraints { make in
make.top.trailing.equalToSuperview()
}
statusBadge.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview()
make.height.equalTo(30)
}
albumLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(1)
make.leading.equalTo(albumCoverImageView.snp.trailing).offset(12)
make.trailing.lessThanOrEqualTo(timeStack.snp.leading).offset(-8)
}
phoneLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(albumLabel.snp.bottom).offset(6)
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
}
taskLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(phoneLabel.snp.bottom).offset(5)
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
}
}
private func configurePreviewGrid() {
previewStack.axis = .horizontal
previewStack.spacing = 8
previewStack.distribution = .fillEqually
for index in 0..<3 {
let slot = UIView()
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 10
imageView.backgroundColor = AppColor.pageBackground
imageView.tintColor = AppColor.textTertiary
imageView.accessibilityIdentifier = "aiRetouchJob.preview.\(index)"
slot.addSubview(imageView)
imageView.snp.makeConstraints { $0.edges.equalToSuperview() }
previewStack.addArrangedSubview(slot)
previewImageViews.append(imageView)
}
}
private func configureBottomRow() {
outputPill.layer.cornerRadius = 9
outputPill.clipsToBounds = true
outputIconView.contentMode = .scaleAspectFit
outputLabel.font = .systemFont(ofSize: 13, weight: .medium)
outputLabel.numberOfLines = 1
outputLabel.adjustsFontSizeToFitWidth = true
outputLabel.minimumScaleFactor = 0.8
outputPill.addSubview(outputIconView)
outputPill.addSubview(outputLabel)
outputIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.centerY.equalToSuperview()
make.size.equalTo(17)
}
outputLabel.snp.makeConstraints { make in
make.leading.equalTo(outputIconView.snp.trailing).offset(6)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
detailsLabel.text = "查看详情"
detailsLabel.font = .systemFont(ofSize: 13)
detailsLabel.textColor = UIColor(hex: 0x7F8CA3)
chevronView.tintColor = UIColor(hex: 0x7F8CA3)
chevronView.contentMode = .scaleAspectFit
[outputPill, detailsLabel, chevronView].forEach { bottomRow.addSubview($0) }
outputPill.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.trailing.lessThanOrEqualTo(detailsLabel.snp.leading).offset(-8)
}
chevronView.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.size.equalTo(14)
}
detailsLabel.snp.makeConstraints { make in
make.trailing.equalTo(chevronView.snp.leading).offset(-6)
make.centerY.equalToSuperview()
}
}
private func shortTitle(_ type: TravelAlbumAIJobOutputType) -> String {
switch type {
case .refined: "精修"
case .atmosphere: "氛围感"
case .cover: "封面"
case .unknown: "其他"
}
}
private func relativeTime(_ value: String) -> String {
guard let date = TravelAlbumAIJobDateFormatter.date(value) else { return "--" }
let calendar = Calendar.current
let prefix: String
if calendar.isDateInToday(date) {
prefix = "今天"
} else if calendar.isDateInYesterday(date) {
prefix = "昨天"
} else {
prefix = date.formatted(.dateTime.month().day())
}
return "\(prefix) \(date.formatted(.dateTime.hour().minute()))"
}
}
/// 带图标与底色的任务状态徽标,保证状态不只依赖颜色表达。
private final class AIJobStatusBadgeView: UIView {
private let iconView = UIImageView()
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 9
iconView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
addSubview(iconView)
addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(9)
make.centerY.equalToSuperview()
make.size.equalTo(17)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(5)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ status: TravelAlbumAIJobStatus) {
titleLabel.text = status.title
let color: UIColor
let background: UIColor
let symbol: String
switch status {
case .queued:
color = AppColor.primary
background = AppColor.infoBackground
symbol = "clock.arrow.circlepath"
case .processing:
color = AppColor.primary
background = AppColor.infoBackground
symbol = "arrow.triangle.2.circlepath"
case .succeeded:
color = AppColor.success
background = AppColor.successBackground
symbol = "checkmark.circle.fill"
case .partiallySucceeded:
color = AppColor.warning
background = AppColor.warningBackground
symbol = "exclamationmark.circle.fill"
case .failed:
color = AppColor.danger
background = AppColor.dangerBackground
symbol = "xmark.circle.fill"
case .canceled, .unknown:
color = AppColor.textSecondary
background = AppColor.pageBackground
symbol = "minus.circle.fill"
}
titleLabel.textColor = color
iconView.tintColor = color
iconView.image = UIImage(systemName: symbol)
backgroundColor = background
accessibilityLabel = status.title
}
}
/// 任务列表空态和错误态。
private final class AIJobEmptyView: UIView {
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
private let titleLabel = UILabel()
private let messageLabel = UILabel()
private let retryButton = UIButton(type: .system)
var onRetry: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
imageView.tintColor = AppColor.primary
imageView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textAlignment = .center
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textColor = AppColor.textSecondary
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
retryButton.setTitle("重新加载", for: .normal)
retryButton.addTarget(self, action: #selector(retry), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, messageLabel, retryButton])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { make in
make.centerY.equalToSuperview().offset(-40)
make.leading.trailing.equalToSuperview().inset(40)
}
imageView.snp.makeConstraints { $0.size.equalTo(52) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(title: String, message: String, showsRetry: Bool) {
titleLabel.text = title
messageLabel.text = message
retryButton.isHidden = !showsRetry
}
@objc private func retry() { onRetry?() }
}