Files
suixinkan_uikit/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

1115 lines
43 KiB
Swift
Raw 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.

//
// MaterialFormViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import PhotosUI
import SnapKit
import UniformTypeIdentifiers
import UIKit
/// / Android `UploadMaterialScreen` `EditMaterialScreen`
///
final class MaterialFormViewController: BaseViewController {
private enum PickTarget {
case material
case cover
}
private let viewModel: MaterialFormViewModel
private let api: any MaterialManagementServing
private let uploader: any MaterialOSSUploading
private let scrollView = UIScrollView()
private let formCard = UIView()
private let formStack = UIStackView()
private let nameField = UITextField()
private let nameSection = MaterialFormFieldView(title: "素材名称")
private let spotButton = MaterialSelectButton()
private let spotSection = MaterialFormFieldView(title: "关联打卡点")
private let mediaTypeButton = MaterialSelectButton()
private let mediaTypeSection = MaterialFormFieldView(title: "素材类型")
private let mediaGridView = MaterialUploadGridView()
private let materialSection = MaterialFormFieldView(title: "上传素材")
private let coverView = MaterialCoverUploadView()
private let coverSection = MaterialFormFieldView(title: "上传封面")
private let tagView = MaterialTagEditorView()
private let tagSection = MaterialFormFieldView(title: "标签")
private let bottomBar = UIView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let progressOverlay = MaterialUploadProgressOverlay()
private let dropdownOverlay = MaterialAnchoredDropdownView()
private var pickTarget: PickTarget = .material
var onSubmitSuccess: (() -> Void)?
///
init(
mode: MaterialFormViewModel.Mode,
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
) {
viewModel = MaterialFormViewModel(mode: mode)
self.api = api
self.uploader = uploader
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
switch viewModel.mode {
case .create: title = "上传素材"
case .edit: title = "编辑素材"
}
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
formCard.backgroundColor = .white
formCard.layer.cornerRadius = 12
formCard.clipsToBounds = true
formStack.axis = .vertical
formStack.spacing = 16
nameField.placeholder = "请输入素材名称"
nameField.font = .systemFont(ofSize: 14)
nameField.textColor = .black
nameField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
nameField.layer.borderWidth = 1
nameField.layer.cornerRadius = 8
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
nameField.leftViewMode = .always
nameField.clearButtonMode = .whileEditing
nameSection.setContent(nameField)
spotSection.setContent(spotButton)
mediaTypeSection.setContent(mediaTypeButton)
materialSection.setContent(mediaGridView)
coverSection.setContent(coverView)
tagSection.setContent(tagView)
[nameSection, spotSection, mediaTypeSection, materialSection, coverSection, tagSection].forEach(formStack.addArrangedSubview)
bottomBar.backgroundColor = .white
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
configureBottomButton(confirmButton, title: "确认", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(formCard)
formCard.addSubview(formStack)
view.addSubview(bottomBar)
bottomBar.addSubview(cancelButton)
bottomBar.addSubview(confirmButton)
view.addSubview(progressOverlay)
view.addSubview(dropdownOverlay)
progressOverlay.isHidden = true
dropdownOverlay.isHidden = true
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
cancelButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalToSuperview().offset(15)
make.width.equalTo(100)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
confirmButton.snp.makeConstraints { make in
make.top.height.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(15)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
formCard.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
formStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
nameField.snp.makeConstraints { make in
make.height.equalTo(44)
}
[spotButton, mediaTypeButton].forEach { button in
button.snp.makeConstraints { make in
make.height.equalTo(44)
}
}
progressOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
dropdownOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
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.onSubmitSuccess = { [weak self] in
Task { @MainActor in
self?.onSubmitSuccess?()
self?.navigationController?.popViewController(animated: true)
}
}
nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside)
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
mediaGridView.onPreview = { [weak self] index in self?.presentMediaPreview(startingAt: index) }
coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) }
tagView.onAdd = { [weak self] in
guard let self else { return }
Task { await self.viewModel.addTag(api: self.api) }
}
tagView.onDelete = { [weak self] tag in self?.viewModel.requestDeleteTag(tag) }
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
applyViewModel()
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
showLoading()
await viewModel.loadInitial(api: api)
hideLoading()
}
}
@MainActor
private func applyViewModel() {
if nameField.text != viewModel.materialName {
nameField.text = viewModel.materialName
}
spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择打卡点")
mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "")
mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType)
coverView.apply(item: viewModel.coverImage)
tagView.apply(tags: viewModel.tagList, input: viewModel.tagInput)
confirmButton.alpha = viewModel.isSubmitting ? 0.6 : 1
confirmButton.isEnabled = !viewModel.isSubmitting
if let dialog = viewModel.uploadDialogState {
progressOverlay.apply(state: dialog)
progressOverlay.isHidden = false
} else {
progressOverlay.isHidden = true
}
presentTagDeleteDialogIfNeeded()
}
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
}
@objc private func nameChanged() {
viewModel.updateMaterialName(nameField.text ?? "")
}
@objc private func spotTapped() {
let spots = viewModel.scenicSpotList
dropdownOverlay.show(
in: view,
anchor: spotButton,
options: spots.map(\.name),
selectedIndex: spots.firstIndex { $0.id == viewModel.selectedScenicSpot?.id },
maximumHeight: 128
) { [weak self] index in
guard spots.indices.contains(index) else { return }
self?.viewModel.selectScenicSpot(spots[index])
}
}
@objc private func mediaTypeTapped() {
let types = MaterialMediaType.allCases
dropdownOverlay.show(
in: view,
anchor: mediaTypeButton,
options: types.map(\.title),
selectedIndex: types.firstIndex(of: viewModel.mediaType)
) { [weak self] index in
guard types.indices.contains(index) else { return }
self?.viewModel.selectMediaType(types[index])
}
}
@objc private func cancelTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func confirmTapped() {
Task {
showLoading()
await viewModel.submit(api: api)
hideLoading()
}
}
private func presentPickerSource(target: PickTarget) {
pickTarget = target
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraTitle = viewModel.mediaType == .video && target == .material ? "拍摄视频" : "拍照"
sheet.addAction(UIAlertAction(title: cameraTitle, style: .default) { [weak self] _ in
self?.presentCamera()
})
}
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
self?.presentPhotoPicker()
})
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func presentMediaPreview(startingAt sourceIndex: Int) {
let completed = viewModel.uploadedMediaList.enumerated().filter { !$0.element.isUploading && !$0.element.ossUrl.isEmpty }
guard let startIndex = completed.firstIndex(where: { $0.offset == sourceIndex }) else { return }
let items = completed.map {
MaterialPreviewItem(url: $0.element.ossUrl, mediaType: viewModel.mediaType)
}
present(MaterialMediaPreviewViewController(items: items, startIndex: startIndex), animated: true)
}
private func presentTagDeleteDialogIfNeeded() {
guard let tag = viewModel.pendingDeleteTag, presentedViewController == nil else { return }
let dialog = MaterialDeleteDialogViewController(
title: "删除标签",
itemName: tag.name,
itemSuffix: "标签吗?",
onCancel: { [weak self] in self?.viewModel.cancelDeleteTag() },
onConfirm: { [weak self] in self?.viewModel.confirmDeleteTag() }
)
present(dialog, animated: true)
}
private func presentPhotoPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
if pickTarget == .cover {
configuration.filter = .images
configuration.selectionLimit = 1
} else {
configuration.filter = viewModel.mediaType == .image ? .images : .videos
configuration.selectionLimit = viewModel.mediaType == .video
? 1
: max(1, viewModel.mediaType.maxCount - viewModel.uploadedMediaList.count)
}
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func presentCamera() {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = self
if pickTarget == .material, viewModel.mediaType == .video {
picker.mediaTypes = [UTType.movie.identifier]
picker.cameraCaptureMode = .video
} else {
picker.mediaTypes = [UTType.image.identifier]
picker.cameraCaptureMode = .photo
}
present(picker, animated: true)
}
private func uploadPickedItems(_ items: [MaterialUploadedMediaItem]) {
guard !items.isEmpty else { return }
Task {
switch pickTarget {
case .material:
await viewModel.addLocalMedia(items, uploader: uploader)
case .cover:
await viewModel.setCoverImage(items[0], uploader: uploader)
}
}
}
private func makeImageItem(image: UIImage, fileName: String) -> MaterialUploadedMediaItem? {
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
return MaterialUploadedMediaItem(
data: data,
fileName: fileName,
width: Int(image.size.width * image.scale),
height: Int(image.size.height * image.scale)
)
}
private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem {
let size = videoDisplaySize(url: sourceURL)
return MaterialUploadedMediaItem(
data: data,
thumbnailData: videoThumbnailData(url: sourceURL),
fileName: fileName,
width: Int(size.width),
height: Int(size.height)
)
}
private func videoDisplaySize(url: URL) -> CGSize {
let asset = AVAsset(url: url)
guard let track = asset.tracks(withMediaType: .video).first else { return .zero }
let transformed = track.naturalSize.applying(track.preferredTransform)
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
}
private func videoThumbnailData(url: URL) -> Data? {
let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
generator.appliesPreferredTrackTransform = true
guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil }
return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75)
}
}
extension MaterialFormViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
var loadedItems = [MaterialUploadedMediaItem?](repeating: nil, count: results.count)
let group = DispatchGroup()
for (index, result) in results.enumerated() {
let provider = result.itemProvider
if pickTarget == .material, viewModel.mediaType == .video {
group.enter()
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
defer { group.leave() }
guard let self, let url else { return }
let data = (try? Data(contentsOf: url)) ?? Data()
guard !data.isEmpty else { return }
let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "material_video_\(Int(Date().timeIntervalSince1970)).mp4"
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
}
} else {
group.enter()
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
defer { group.leave() }
guard let self, let image = object as? UIImage else { return }
let fileName = "material_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
}
}
}
group.notify(queue: .main) { [weak self] in
self?.uploadPickedItems(loadedItems.compactMap { $0 })
}
}
}
extension MaterialFormViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
if pickTarget == .material, viewModel.mediaType == .video,
let url = info[.mediaURL] as? URL,
let data = try? Data(contentsOf: url) {
uploadPickedItems([makeVideoItem(data: data, fileName: url.lastPathComponent, sourceURL: url)])
return
}
if let image = info[.originalImage] as? UIImage,
let item = makeImageItem(image: image, fileName: "material_image_\(Int(Date().timeIntervalSince1970)).jpg") {
uploadPickedItems([item])
}
}
}
///
///
private final class MaterialFormFieldView: UIView {
private let titleLabel = UILabel()
private let contentHolder = UIView()
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = .black
addSubview(titleLabel)
addSubview(contentHolder)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
contentHolder.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setContent(_ view: UIView) {
contentHolder.subviews.forEach { $0.removeFromSuperview() }
contentHolder.addSubview(view)
view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
///
///
private final class MaterialSelectButton: UIControl {
private let titleLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down"))
override init(frame: CGRect) {
super.init(frame: frame)
layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
layer.borderWidth = 1
layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: 14)
chevronView.tintColor = .black
addSubview(titleLabel)
addSubview(chevronView)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
}
chevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setTitle(_ title: String?, placeholder: String) {
let text = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
titleLabel.text = text.isEmpty ? placeholder : text
titleLabel.textColor = text.isEmpty ? UIColor(hex: 0x4B5563) : .black
}
}
/// Android
private final class MaterialAnchoredDropdownView: UIControl {
private let cardView = UIView()
private let scrollView = UIScrollView()
private let stackView = UIStackView()
private var onSelect: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 4
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.14
cardView.layer.shadowRadius = 8
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
stackView.axis = .vertical
addSubview(cardView)
cardView.addSubview(scrollView)
scrollView.addSubview(stackView)
addTarget(self, action: #selector(dismissMenu), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func show(
in container: UIView,
anchor: UIView,
options: [String],
selectedIndex: Int?,
maximumHeight: CGFloat = 128,
onSelect: @escaping (Int) -> Void
) {
guard !options.isEmpty else { return }
self.onSelect = onSelect
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, option) in options.enumerated() {
let button = UIButton(type: .system)
button.tag = index
button.contentHorizontalAlignment = .leading
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)
)
button.setTitle(option, for: .normal)
button.setTitleColor(index == selectedIndex ? AppColor.primary : .black, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: index == selectedIndex ? .medium : .regular)
button.backgroundColor = .white
button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside)
button.snp.makeConstraints { make in make.height.equalTo(28) }
stackView.addArrangedSubview(button)
}
isHidden = false
container.bringSubviewToFront(self)
layoutIfNeeded()
let anchorFrame = anchor.convert(anchor.bounds, to: self)
let menuHeight = min(maximumHeight, CGFloat(options.count) * 28)
cardView.frame = CGRect(x: anchorFrame.minX, y: anchorFrame.maxY, width: anchorFrame.width, height: menuHeight)
scrollView.frame = cardView.bounds
stackView.frame = CGRect(x: 0, y: 0, width: cardView.bounds.width, height: CGFloat(options.count) * 28)
scrollView.contentSize = stackView.frame.size
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
isHidden ? false : true
}
@objc private func optionTapped(_ sender: UIButton) {
isHidden = true
onSelect?(sender.tag)
}
@objc private func dismissMenu() {
isHidden = true
}
}
///
///
private final class MaterialUploadGridView: UIView {
private let stack = UIStackView()
var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)?
var onPreview: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(items: [MaterialUploadedMediaItem], mediaType: MaterialMediaType) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let showAdd = items.count < mediaType.maxCount
let cells = items.map(Optional.some) + (showAdd ? [nil] : [])
let rows = Int(ceil(Double(cells.count) / 3.0))
guard rows > 0 else { return }
for row in 0 ..< rows {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 8
rowStack.distribution = .fillEqually
stack.addArrangedSubview(rowStack)
for column in 0 ..< 3 {
let index = row * 3 + column
if index < cells.count {
rowStack.addArrangedSubview(makeCell(item: cells[index], index: index, mediaType: mediaType))
} else {
rowStack.addArrangedSubview(UIView())
}
}
}
}
private func makeCell(item: MaterialUploadedMediaItem?, index: Int, mediaType: MaterialMediaType) -> UIView {
let cell = MaterialUploadThumbView(mediaType: mediaType)
cell.snp.makeConstraints { make in
make.height.equalTo(cell.snp.width).dividedBy(1.3)
}
if let item {
cell.apply(item: item)
cell.onDelete = { [weak self] in self?.onDelete?(index) }
cell.onPreview = { [weak self] in self?.onPreview?(index) }
} else {
cell.applyAdd()
cell.onAdd = { [weak self] in self?.onAdd?() }
}
return cell
}
}
///
///
private final class MaterialUploadThumbView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
private let textLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let playIcon = UIImageView(image: UIImage(named: "material_ic_play"))
private let mediaType: MaterialMediaType
private let dashedLayer = CAShapeLayer()
private var previewEnabled = false
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
var onPreview: (() -> Void)?
init(mediaType: MaterialMediaType) {
self.mediaType = mediaType
super.init(frame: .zero)
layer.cornerRadius = 8
clipsToBounds = true
backgroundColor = .white
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addCircle.backgroundColor = AppColor.primary
addCircle.layer.cornerRadius = 14
addIcon.tintColor = .white
addCircle.isUserInteractionEnabled = false
addIcon.isUserInteractionEnabled = false
textLabel.font = .systemFont(ofSize: 12)
textLabel.textColor = UIColor(hex: 0x4B5563)
textLabel.textAlignment = .center
textLabel.isUserInteractionEnabled = false
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 10
playIcon.tintColor = .white
playIcon.isHidden = true
addSubview(imageView)
addSubview(addCircle)
addCircle.addSubview(addIcon)
addSubview(textLabel)
addSubview(deleteButton)
addSubview(playIcon)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addCircle.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-10)
make.size.equalTo(28)
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
textLabel.snp.makeConstraints { make in
make.top.equalTo(addCircle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(4)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
}
playIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(32)
}
dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [6, 6]
layer.addSublayer(dashedLayer)
addTarget(self, action: #selector(cellTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
}
func applyAdd() {
imageView.image = nil
imageView.kf.cancelDownloadTask()
backgroundColor = .white
layer.borderWidth = 0
dashedLayer.isHidden = false
previewEnabled = false
addCircle.isHidden = false
textLabel.isHidden = false
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
textLabel.numberOfLines = 1
deleteButton.isHidden = true
playIcon.isHidden = true
}
func apply(item: MaterialUploadedMediaItem) {
dashedLayer.isHidden = true
previewEnabled = !item.ossUrl.isEmpty && !item.isUploading
backgroundColor = UIColor(hex: 0xF5F5F5)
addCircle.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
playIcon.isHidden = mediaType != .video || item.ossUrl.isEmpty || item.isUploading
if item.isUploading {
imageView.image = nil
textLabel.isHidden = false
textLabel.text = "上传中...\n\(item.uploadProgress)%"
textLabel.numberOfLines = 2
} else {
textLabel.isHidden = true
if mediaType == .video {
if let thumbnailData = item.thumbnailData, let image = UIImage(data: thumbnailData) {
imageView.image = image
} else if let urlText = (item.previewURL?.isEmpty == false ? item.previewURL : item.ossUrl),
!urlText.isEmpty, let url = URL(string: urlText) {
loadVideoThumbnail(url: url)
}
} else if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(data: item.thumbnailData ?? item.data)
}
}
}
@objc private func cellTapped() {
if !addCircle.isHidden {
onAdd?()
} else if previewEnabled {
onPreview?()
}
}
@objc private func deleteTapped() {
onDelete?()
}
private func loadVideoThumbnail(url: URL) {
imageView.image = nil
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
generator.appliesPreferredTrackTransform = true
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: .zero)]) { [weak self] _, image, _, _, _ in
guard let image else { return }
DispatchQueue.main.async { self?.imageView.image = UIImage(cgImage: image) }
}
}
}
///
///
private final class MaterialCoverUploadView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
private let titleLabel = UILabel()
private let hintLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let dashedLayer = CAShapeLayer()
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
dashedLayer.fillColor = UIColor.clear.cgColor
dashedLayer.lineWidth = 1
dashedLayer.lineDashPattern = [6, 6]
layer.addSublayer(dashedLayer)
clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.isUserInteractionEnabled = false
addCircle.backgroundColor = AppColor.primary
addCircle.layer.cornerRadius = 16
addIcon.tintColor = .white
addCircle.isUserInteractionEnabled = false
addIcon.isUserInteractionEnabled = false
titleLabel.text = "点击上传图片"
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.textAlignment = .center
titleLabel.isUserInteractionEnabled = false
hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB"
hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center
hintLabel.isUserInteractionEnabled = false
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 12
addSubview(imageView)
addSubview(addCircle)
addCircle.addSubview(addIcon)
addSubview(titleLabel)
addSubview(hintLabel)
addSubview(deleteButton)
snp.makeConstraints { make in
make.height.equalTo(snp.width).dividedBy(2.59)
}
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addCircle.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-22)
make.size.equalTo(32)
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(addCircle.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(12)
}
hintLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(12)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(8)
make.size.equalTo(24)
}
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
dashedLayer.frame = bounds
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
}
func apply(item: MaterialUploadedMediaItem?) {
guard let item else {
imageView.image = nil
imageView.kf.cancelDownloadTask()
addCircle.isHidden = false
titleLabel.isHidden = false
titleLabel.text = "点击上传图片"
titleLabel.numberOfLines = 1
hintLabel.isHidden = false
deleteButton.isHidden = true
dashedLayer.isHidden = false
return
}
if item.isUploading {
imageView.image = nil
addCircle.isHidden = true
titleLabel.isHidden = false
titleLabel.text = "上传中...\n\(item.uploadProgress)%"
titleLabel.numberOfLines = 2
hintLabel.isHidden = true
deleteButton.isHidden = true
dashedLayer.isHidden = true
} else {
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(data: item.data)
}
addCircle.isHidden = true
titleLabel.isHidden = true
hintLabel.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty
dashedLayer.isHidden = true
}
}
@objc private func addTapped() {
guard addCircle.isHidden == false else { return }
onAdd?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
///
///
private final class MaterialTagEditorView: UIView {
private let stack = UIStackView()
private let tagsStack = UIStackView()
private let inputRow = UIStackView()
private let textField = UITextField()
private let addButton = UIButton(type: .system)
private var tags: [MaterialTagItem] = []
var onTextChanged: ((String) -> Void)?
var onAdd: (() -> Void)?
var onDelete: ((MaterialTagItem) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = 8
tagsStack.axis = .horizontal
tagsStack.spacing = 8
tagsStack.alignment = .center
inputRow.axis = .horizontal
inputRow.spacing = 8
textField.font = .systemFont(ofSize: 14)
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
textField.layer.borderWidth = 1
textField.layer.cornerRadius = 4
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
textField.leftViewMode = .always
textField.placeholder = "请输入标签,最低5个字符"
addButton.setTitle("添加标签", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 4
addSubview(stack)
stack.addArrangedSubview(tagsStack)
stack.addArrangedSubview(inputRow)
inputRow.addArrangedSubview(textField)
inputRow.addArrangedSubview(addButton)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.height.equalTo(36)
}
addButton.snp.makeConstraints { make in
make.width.equalTo(80)
make.height.equalTo(36)
}
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(tags: [MaterialTagItem], input: String) {
self.tags = tags
if textField.text != input {
textField.text = input
}
let canAdd = (5 ... 15).contains(input.count)
addButton.isEnabled = canAdd
addButton.backgroundColor = canAdd ? AppColor.primary : AppColor.buttonDisabled
tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
tags.forEach { tag in
let button = UIButton(type: .system)
button.setTitle(tag.name, for: .normal)
button.setTitleColor(AppColor.primary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 12)
button.backgroundColor = UIColor(hex: 0xEFF6FF)
button.layer.cornerRadius = 4
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)
)
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.snp.makeConstraints { make in
make.height.equalTo(28)
}
button.addAction(UIAction { [weak self] _ in
self?.onDelete?(tag)
}, for: .touchUpInside)
tagsStack.addArrangedSubview(button)
}
if !tags.isEmpty {
// Android Row
tagsStack.addArrangedSubview(UIView())
}
tagsStack.isHidden = tags.isEmpty
}
@objc private func textChanged() {
onTextChanged?(textField.text ?? "")
}
@objc private func addTapped() {
onAdd?()
}
}
///
///
private final class MaterialUploadProgressOverlay: UIView {
private let card = UIView()
private let titleLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.2)
card.backgroundColor = .white
card.layer.cornerRadius = 12
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
progressView.progressTintColor = AppColor.primary
progressView.trackTintColor = UIColor(hex: 0xF3F4F6)
percentLabel.font = .systemFont(ofSize: 14)
percentLabel.textColor = AppColor.textSecondary
percentLabel.textAlignment = .center
addSubview(card)
card.addSubview(titleLabel)
card.addSubview(progressView)
card.addSubview(percentLabel)
card.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(44)
make.height.equalTo(card.snp.width).dividedBy(1.7)
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(24)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(32)
make.leading.trailing.equalToSuperview().inset(24)
make.height.equalTo(6)
}
percentLabel.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(24)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(state: MaterialUploadDialogState) {
titleLabel.text = state.title
let progress = Float(max(0, min(100, state.progress))) / 100
progressView.setProgress(progress, animated: true)
percentLabel.text = "\(max(0, min(100, state.progress)))%"
}
}