Files
suixinkan_uikit/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift
lujiuyinandCursor 6336feff27 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:21:53 +08:00

851 lines
31 KiB
Swift

//
// MaterialDetailViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
/// 素材详情页,对齐 Android `MaterialDetailNewScreen`。
/// 素材详情页,展示轮播、信息、统计、审核与底部操作。
final class MaterialDetailViewController: BaseViewController {
private let viewModel: MaterialDetailViewModel
private let api: any MaterialManagementServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let carouselView = MaterialMediaCarouselView()
private let infoSection = MaterialDetailInfoSection()
private let uploaderSection = MaterialDetailUploaderSection()
private let reviewSection = MaterialDetailReviewSection()
private let bottomBar = UIView()
private let deleteButton = UIButton(type: .system)
private let editButton = UIButton(type: .system)
var onDeleted: ((Int) -> Void)?
var onEdited: (() -> Void)?
/// 初始化素材详情页。
init(
materialId: Int,
api: (any MaterialManagementServing)? = nil
) {
viewModel = MaterialDetailViewModel(materialId: materialId)
self.api = api ?? NetworkServices.shared.materialManagementAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "素材详情"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
contentStack.axis = .vertical
contentStack.spacing = 0
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
bottomBar.backgroundColor = .white
configureBottomButton(deleteButton, title: "删除", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
configureBottomButton(editButton, title: "编辑", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(carouselView)
contentStack.addArrangedSubview(infoSection)
contentStack.addArrangedSubview(makeVerticalSpacer(height: 12))
contentStack.addArrangedSubview(uploaderSection)
contentStack.addArrangedSubview(makeVerticalSpacer(height: 12))
contentStack.addArrangedSubview(reviewSection)
contentStack.addArrangedSubview(makeVerticalSpacer(height: 16))
view.addSubview(bottomBar)
bottomBar.addSubview(deleteButton)
bottomBar.addSubview(editButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
deleteButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalToSuperview().offset(16)
make.width.equalTo(100)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
editButton.snp.makeConstraints { make in
make.top.height.equalTo(deleteButton)
make.leading.equalTo(deleteButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView)
}
carouselView.snp.makeConstraints { make in
make.height.equalTo(carouselView.snp.width).multipliedBy(9.0 / 16.0)
}
}
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) }
}
viewModel.onDeleted = { [weak self] id in
Task { @MainActor in
self?.onDeleted?(id)
self?.navigationController?.popViewController(animated: true)
}
}
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
carouselView.pause()
}
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = backgroundColor
button.layer.cornerRadius = 8
}
private func makeVerticalSpacer(height: CGFloat) -> UIView {
let spacer = UIView()
spacer.backgroundColor = .clear
spacer.snp.makeConstraints { make in
make.height.equalTo(height)
}
return spacer
}
@MainActor
private func applyViewModel() {
if (viewModel.isLoading || viewModel.isDeleting) && viewModel.detail == nil {
showLoading()
} else {
hideLoading()
}
guard let detail = viewModel.detail else { return }
carouselView.apply(media: detail.mediaList ?? [])
infoSection.apply(detail: detail)
uploaderSection.apply(detail: detail)
reviewSection.apply(detail: detail)
}
@objc private func deleteTapped() {
let dialog = MaterialDeleteDialogViewController(itemName: viewModel.detail?.name ?? "") { [weak self] in
guard let self else { return }
Task {
self.showLoading()
await self.viewModel.delete(api: self.api)
self.hideLoading()
}
}
present(dialog, animated: true)
}
@objc private func editTapped() {
let controller = MaterialFormViewController(mode: .edit(id: viewModel.materialId))
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
self.onEdited?()
Task { await self.viewModel.load(api: self.api) }
}
navigationController?.pushViewController(controller, animated: true)
}
}
/// 素材详情无限轮播的页码映射规则。
struct MaterialCarouselIndexMapper {
/// 初始渲染页;多媒体时跳过头部复制页。
static func initialRenderedPage(mediaCount: Int) -> Int {
mediaCount > 1 ? 1 : 0
}
/// 将头尾复制页归一到对应的真实渲染页。
static func normalizedRenderedPage(_ page: Int, mediaCount: Int) -> Int {
guard mediaCount > 1 else { return 0 }
if page <= 0 { return mediaCount }
if page >= mediaCount + 1 { return 1 }
return page
}
/// 将渲染页映射到圆点使用的真实素材索引。
static func logicalPage(renderedPage: Int, mediaCount: Int) -> Int {
guard mediaCount > 1 else { return 0 }
return normalizedRenderedPage(renderedPage, mediaCount: mediaCount) - 1
}
}
/// 素材详情媒体轮播,支持图片自动切换与视频播完切换。
/// 素材详情顶部媒体轮播视图,支持图片与视频播放。
private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
private let scrollView = UIScrollView()
private let pageControl = UIPageControl()
private let placeholderLabel = UILabel()
private var media: [MaterialDetailMediaItem] = []
private var renderedMedia: [MaterialDetailMediaItem] = []
private var players: [Int: AVPlayer] = [:]
private var playerViews: [Int: MaterialPlayerLayerView] = [:]
private var timer: Timer?
private var renderedPage = 0
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hex: 0xF5F5F5)
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.delegate = self
scrollView.bounces = false
pageControl.currentPageIndicatorTintColor = .white
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
placeholderLabel.text = "暂无媒体"
placeholderLabel.font = .systemFont(ofSize: 14)
placeholderLabel.textColor = AppColor.textSecondary
placeholderLabel.textAlignment = .center
addSubview(scrollView)
addSubview(pageControl)
addSubview(placeholderLabel)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
pageControl.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(8)
}
placeholderLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
layoutPages()
}
/// 应用媒体列表。
func apply(media: [MaterialDetailMediaItem]) {
stopTimer()
NotificationCenter.default.removeObserver(self)
players.values.forEach { $0.pause() }
players.removeAll()
playerViews.removeAll()
self.media = media
renderedMedia = media.count > 1 ? [media.last!] + media + [media.first!] : media
scrollView.subviews.forEach { $0.removeFromSuperview() }
pageControl.numberOfPages = media.count
pageControl.currentPage = 0
pageControl.isHidden = media.count <= 1
placeholderLabel.isHidden = !media.isEmpty
scrollView.isHidden = media.isEmpty
guard !media.isEmpty else { return }
renderedPage = MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: media.count)
for (index, item) in renderedMedia.enumerated() {
let page = UIView()
page.backgroundColor = UIColor(hex: 0xF5F5F5)
scrollView.addSubview(page)
if item.type == 2 {
addVideo(urlText: item.displayURL, to: page, index: index)
} else {
addImage(urlText: item.displayURL, to: page)
}
}
setNeedsLayout()
layoutIfNeeded()
scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * bounds.width, y: 0)
playCurrentPageIfNeeded()
}
/// 暂停当前轮播。
func pause() {
stopTimer()
players.values.forEach { $0.pause() }
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updateCurrentPage()
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
updateCurrentPage()
}
private func addImage(urlText: String, to page: UIView) {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
page.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
if let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
}
}
private func addVideo(urlText: String, to page: UIView, index: Int) {
guard let url = URL(string: urlText), !urlText.isEmpty else {
addImage(urlText: "", to: page)
return
}
let player = AVPlayer(url: url)
players[index] = player
let playerView = MaterialPlayerLayerView()
playerView.configure(player: player)
playerViews[index] = playerView
page.addSubview(playerView)
playerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
NotificationCenter.default.addObserver(
self,
selector: #selector(videoDidEnd(_:)),
name: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem
)
}
private func layoutPages() {
let size = bounds.size
guard size.width > 0, size.height > 0 else { return }
for (index, page) in scrollView.subviews.enumerated() {
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
}
scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMedia.count), height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * size.width, y: 0)
}
private func updateCurrentPage() {
guard bounds.width > 0 else { return }
let rawPage = Int(round(scrollView.contentOffset.x / bounds.width))
let page = MaterialCarouselIndexMapper.normalizedRenderedPage(rawPage, mediaCount: media.count)
if media.count > 1 {
if page != rawPage {
scrollView.setContentOffset(CGPoint(x: CGFloat(page) * bounds.width, y: 0), animated: false)
}
pageControl.currentPage = MaterialCarouselIndexMapper.logicalPage(renderedPage: page, mediaCount: media.count)
} else {
pageControl.currentPage = 0
}
renderedPage = page
playCurrentPageIfNeeded()
}
private func playCurrentPageIfNeeded() {
stopTimer()
players.forEach { index, player in
if index == renderedPage {
player.seek(to: .zero)
player.play()
playerViews[index]?.setPlaying(true)
} else {
player.pause()
playerViews[index]?.setPlaying(false)
}
}
guard renderedMedia.indices.contains(renderedPage) else { return }
if renderedMedia[renderedPage].type == 1 {
startImageTimer()
}
}
private func startImageTimer() {
guard media.count > 1 else { return }
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
self?.advancePage()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
@objc private func videoDidEnd(_ notification: Notification) {
guard players[renderedPage]?.currentItem === notification.object as? AVPlayerItem else { return }
advancePage()
}
private func advancePage() {
guard media.count > 1, bounds.width > 0 else { return }
let nextPage = renderedPage + 1
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.playCurrentPageIfNeeded()
}
}
}
/// AVPlayerLayer 容器。
/// 承载 AVPlayerLayer 的视频播放容器。
private final class MaterialPlayerLayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
private let controlButton = UIButton(type: .system)
private weak var player: AVPlayer?
private var hideWorkItem: DispatchWorkItem?
override init(frame: CGRect) {
super.init(frame: frame)
controlButton.tintColor = .white
controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
controlButton.layer.cornerRadius = 24
controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside)
addSubview(controlButton)
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls)))
controlButton.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(48) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(player: AVPlayer) {
self.player = player
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
setPlaying(false)
}
func setPlaying(_ playing: Bool) {
controlButton.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
if playing {
scheduleHide()
} else {
hideWorkItem?.cancel()
controlButton.isHidden = false
}
}
@objc private func controlTapped() {
guard let player else { return }
if player.timeControlStatus == .playing {
player.pause()
setPlaying(false)
} else {
player.play()
setPlaying(true)
}
}
@objc private func showControls() {
controlButton.isHidden = false
if player?.timeControlStatus == .playing { scheduleHide() }
}
private func scheduleHide() {
hideWorkItem?.cancel()
controlButton.isHidden = false
let work = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true }
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work)
}
}
/// 素材详情基础信息区。
/// 素材详情的基础信息区块。
private final class MaterialDetailInfoSection: UIView {
private let titleLabel = UILabel()
private let listingBadge = UILabel()
private let tagStack = UIStackView()
private let statsStack = UIStackView()
private let downloadStat = MaterialDetailStatView(iconName: "material_ic_download_count")
private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count")
private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count")
private var statsTopConstraint: Constraint?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = UIColor(hex: 0x2E3746)
titleLabel.numberOfLines = 0
listingBadge.font = .systemFont(ofSize: 12)
listingBadge.layer.cornerRadius = 4
listingBadge.clipsToBounds = true
listingBadge.textAlignment = .center
tagStack.axis = .horizontal
tagStack.spacing = 8
tagStack.alignment = .center
statsStack.axis = .horizontal
statsStack.spacing = 16
[downloadStat, likeStat, collectStat].forEach(statsStack.addArrangedSubview)
addSubview(titleLabel)
addSubview(listingBadge)
addSubview(tagStack)
addSubview(statsStack)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(listingBadge.snp.leading).offset(-12)
}
listingBadge.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(titleLabel)
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(54)
}
tagStack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
}
statsStack.snp.makeConstraints { make in
statsTopConstraint = make.top.equalTo(tagStack.snp.bottom).offset(12).constraint
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
titleLabel.text = detail.name ?? ""
let listed = detail.listingStatus == 1
listingBadge.text = listed ? "已上架" : "未上架"
let color = listed ? UIColor(hex: 0x22C55E) : AppColor.textSecondary
listingBadge.textColor = color
listingBadge.backgroundColor = color.withAlphaComponent(0.1)
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let tags = detail.materialTag ?? []
tags.forEach { tagStack.addArrangedSubview(makeTag($0.name)) }
if !tags.isEmpty {
tagStack.addArrangedSubview(UIView())
}
tagStack.isHidden = tags.isEmpty
statsTopConstraint?.update(offset: tags.isEmpty ? 0 : 12)
downloadStat.setCount(detail.downloadCount)
likeStat.setCount(detail.likesCount)
collectStat.setCount(detail.collectCount)
}
private func makeTag(_ title: String) -> UILabel {
let label = MaterialDetailTagLabel(horizontalInset: 6)
label.text = title
label.font = .systemFont(ofSize: 12)
label.textColor = AppColor.primary
label.textAlignment = .center
label.backgroundColor = UIColor(hex: 0xEFF6FF)
label.layer.cornerRadius = 4
label.clipsToBounds = true
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
label.snp.makeConstraints { make in
make.height.equalTo(22)
}
return label
}
}
/// 素材上传人信息区。
/// 素材详情的上传人信息区块。
private final class MaterialDetailUploaderSection: UIView {
private let titleLabel = UILabel()
private let avatarView = UIImageView()
private let nameLabel = UILabel()
private let tagStack = UIStackView()
private let descriptionLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
titleLabel.text = "上传人"
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
avatarView.contentMode = .scaleAspectFill
avatarView.clipsToBounds = true
avatarView.layer.cornerRadius = 24
avatarView.backgroundColor = UIColor(hex: 0xF5F5F5)
nameLabel.font = .systemFont(ofSize: 14, weight: .medium)
nameLabel.textColor = UIColor(hex: 0x002255)
descriptionLabel.font = .systemFont(ofSize: 14)
descriptionLabel.textColor = UIColor(hex: 0x7B8EAA)
descriptionLabel.numberOfLines = 0
tagStack.axis = .horizontal
tagStack.spacing = 8
addSubview(titleLabel)
addSubview(avatarView)
addSubview(nameLabel)
addSubview(tagStack)
addSubview(descriptionLabel)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
avatarView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.size.equalTo(48)
make.bottom.lessThanOrEqualToSuperview().inset(16)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(avatarView)
make.leading.equalTo(avatarView.snp.trailing).offset(12)
}
tagStack.snp.makeConstraints { make in
make.leading.equalTo(nameLabel.snp.trailing).offset(8)
make.centerY.equalTo(nameLabel)
make.trailing.lessThanOrEqualToSuperview().inset(16)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(4)
make.leading.equalTo(nameLabel)
make.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
nameLabel.text = detail.uploaderName ?? ""
descriptionLabel.text = detail.uploaderDescription ?? ""
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if detail.platformCertification {
tagStack.addArrangedSubview(makeTag("平台认证"))
}
if detail.scenicCertification {
tagStack.addArrangedSubview(makeTag("景区认证"))
}
if let urlText = detail.uploaderAvatar, let url = URL(string: urlText), !urlText.isEmpty {
avatarView.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle.fill"))
} else {
avatarView.image = UIImage(systemName: "person.crop.circle.fill")
avatarView.tintColor = AppColor.textTertiary
}
}
private func makeTag(_ title: String) -> UILabel {
let label = MaterialDetailTagLabel(horizontalInset: 6)
label.text = title
label.textColor = .white
label.font = .systemFont(ofSize: 8)
label.textAlignment = .center
label.backgroundColor = AppColor.primary
label.layer.cornerRadius = 4
label.clipsToBounds = true
label.snp.makeConstraints { make in
make.height.equalTo(16)
}
return label
}
}
/// 为详情页标签文本提供固定水平内边距,使背景宽度随内容自适应。
private final class MaterialDetailTagLabel: UILabel {
private let horizontalInset: CGFloat
init(horizontalInset: CGFloat) {
self.horizontalInset = horizontalInset
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.insetBy(dx: horizontalInset, dy: 0))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + horizontalInset * 2, height: size.height)
}
}
/// 素材审核信息区。
/// 素材详情的审核信息区块。
private final class MaterialDetailReviewSection: UIView {
private let titleLabel = UILabel()
private let stack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
titleLabel.text = "审核信息"
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
titleLabel.textColor = .black
stack.axis = .vertical
stack.spacing = 0
addSubview(titleLabel)
addSubview(stack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
stack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
stack.addArrangedSubview(MaterialInfoRow(label: "审核人", value: detail.reviewer ?? "--"))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialInfoRow(label: "审核时间", value: detail.reviewTime ?? "--"))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialStatusRow(status: detail.status))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialInfoRow(label: "审核备注", value: detail.reviewNotes ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12)))
}
private func separator() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0xEEEEEE)
view.snp.makeConstraints { make in
make.height.equalTo(1)
}
return view
}
}
/// 详情统计图标与数字。
/// 素材详情中的统计指标视图。
private final class MaterialDetailStatView: UIView {
private let iconView = UIImageView()
private let countLabel = UILabel()
init(iconName: String? = nil, systemIconName: String? = nil) {
super.init(frame: .zero)
if let iconName {
iconView.image = UIImage(named: iconName)
} else if let systemIconName {
iconView.image = UIImage(systemName: systemIconName)
}
iconView.tintColor = UIColor(hex: 0x6B7280)
iconView.contentMode = .scaleAspectFit
countLabel.font = .systemFont(ofSize: 14)
countLabel.textColor = UIColor(hex: 0x6B7280)
addSubview(iconView)
addSubview(countLabel)
iconView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.size.equalTo(16)
}
countLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setCount(_ count: Int) {
countLabel.text = MaterialDisplayFormatter.formatCount(count)
}
}
/// 素材详情普通信息行。
/// 标题和值组成的信息行。
private final class MaterialInfoRow: UIView {
private let label = UILabel()
private let valueLabel = UILabel()
init(label: String, value: String, valueColor: UIColor = .black, valueFont: UIFont = .systemFont(ofSize: 14, weight: .medium)) {
super.init(frame: .zero)
self.label.text = label
self.label.font = .systemFont(ofSize: 14)
self.label.textColor = UIColor(hex: 0x2E3746)
valueLabel.text = value
valueLabel.font = valueFont
valueLabel.textColor = valueColor
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 1
addSubview(self.label)
addSubview(valueLabel)
snp.makeConstraints { make in
make.height.equalTo(44)
}
self.label.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(self.label.snp.trailing).offset(12)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 素材审核状态行。
/// 素材详情中展示上架状态的信息行。
private final class MaterialStatusRow: UIView {
private let label = UILabel()
private let badge = MaterialStatusBadgeView()
init(status: Int) {
super.init(frame: .zero)
label.text = "审核状态"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746)
badge.apply(status: status)
addSubview(label)
addSubview(badge)
snp.makeConstraints { make in
make.height.equalTo(44)
}
label.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
badge.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.height.equalTo(24)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}