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

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

996 lines
39 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.

//
// UploadSampleViewController.swift
// suixinkan
//
import AVFoundation
import PhotosUI
import SnapKit
import UniformTypeIdentifiers
import UIKit
/// Android `UploadSampleScreen`
final class UploadSampleViewController: SampleBaseViewController {
private enum PickTarget {
case material
case cover
}
private let viewModel: UploadSampleViewModel
private let api: any SampleManagementServing
private let uploader: any SampleOSSUploading
private let scrollView = UIScrollView()
private let formCard = UIView()
private let formStack = UIStackView()
private let nameField = UITextField()
private let nameSection = SampleFormFieldView(title: "相册名称")
private let mediaTypeButton = SampleSelectButton()
private let mediaTypeSection = SampleFormFieldView(title: "素材类型")
private let mediaGridView = SampleUploadGridView()
private let materialSection = SampleFormFieldView(title: "上传素材")
private let materialHintLabel = UILabel()
private let coverView = SampleCoverUploadView()
private let coverSection = SampleFormFieldView(title: "上传封面")
private let scenicButton = SampleSelectButton()
private let scenicSection = SampleFormFieldView(title: "关联景区")
private let spotButton = SampleSelectButton()
private let spotSection = SampleFormFieldView(title: "关联打卡点")
private let projectButton = SampleSelectButton()
private let projectSection = SampleFormFieldView(title: "关联项目")
private let bottomBar = UIView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let progressOverlay = SampleUploadProgressOverlay()
private let dropdownOverlay = SampleAnchoredDropdownView()
private var pickTarget: PickTarget = .material
var onUploadSuccess: (() -> Void)?
///
init(
viewModel: UploadSampleViewModel = UploadSampleViewModel(),
api: (any SampleManagementServing)? = nil,
uploader: (any SampleOSSUploading)? = nil
) {
self.viewModel = viewModel
self.api = api ?? NetworkServices.shared.sampleManagementAPI
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
super.setupNavigationBar()
setSampleTitle("样片相册")
}
override func setupUI() {
super.setupUI()
view.backgroundColor = SampleManagementUITokens.pageBackground
formCard.backgroundColor = .white
formCard.layer.cornerRadius = 12
formCard.clipsToBounds = true
formStack.axis = .vertical
formStack.spacing = 0
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)
mediaTypeSection.setContent(mediaTypeButton)
materialSection.setContent(makeMaterialContent())
coverSection.setContent(coverView)
scenicSection.setContent(scenicButton)
spotSection.setContent(spotButton)
projectSection.setContent(projectButton)
[nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection]
.forEach(formStack.addArrangedSubview)
formStack.setCustomSpacing(8, after: nameSection)
formStack.setCustomSpacing(8, after: mediaTypeSection)
formStack.setCustomSpacing(16, after: materialSection)
formStack.setCustomSpacing(16, after: coverSection)
formStack.setCustomSpacing(8, after: scenicSection)
formStack.setCustomSpacing(8, after: spotSection)
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(dropdownOverlay)
view.addSubview(progressOverlay)
progressOverlay.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.equalTo(view.safeAreaLayoutGuide)
make.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)
}
[mediaTypeButton, scenicButton, spotButton, projectButton].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.onUploadSuccess = { [weak self] in
Task { @MainActor in
self?.onUploadSuccess?()
self?.navigationController?.popViewController(animated: true)
}
}
nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
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() }
scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside)
projectButton.addTarget(self, action: #selector(projectTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
applyViewModel()
}
private func makeMaterialContent() -> UIView {
let container = UIView()
materialHintLabel.text = "建议尺寸: 1920x1080px, 大小不超过20MB"
materialHintLabel.font = .systemFont(ofSize: 12)
materialHintLabel.textColor = UIColor(hex: 0xB6BECA)
container.addSubview(mediaGridView)
container.addSubview(materialHintLabel)
mediaGridView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
materialHintLabel.snp.makeConstraints { make in
make.top.equalTo(mediaGridView.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
return container
}
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 applyViewModel() {
if nameField.text != viewModel.albumName {
nameField.text = viewModel.albumName
}
mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "")
scenicButton.setTitle(viewModel.selectedScenic?.name, placeholder: "请选择景区")
spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择关联打卡点")
projectButton.setTitle(viewModel.selectedProject?.name, placeholder: "请选择关联项目")
mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType)
coverView.apply(item: viewModel.coverImage)
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
}
}
@objc private func nameChanged() {
viewModel.updateAlbumName(nameField.text ?? "")
}
@objc private func mediaTypeTapped() {
let types = SampleMediaType.allCases
dropdownOverlay.show(
anchor: mediaTypeButton,
options: types.map { .init(title: $0.title, isSelected: $0 == viewModel.mediaType) }
) { [weak self] index in
guard types.indices.contains(index) else { return }
self?.viewModel.selectMediaType(types[index])
}
}
@objc private func scenicTapped() {
let scenics = viewModel.scenicList
dropdownOverlay.show(
anchor: scenicButton,
options: scenics.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenic?.id) }
) { [weak self] index in
guard let self, scenics.indices.contains(index) else { return }
Task { await self.viewModel.selectScenic(scenics[index], api: self.api) }
}
}
@objc private func spotTapped() {
guard viewModel.selectedScenic != nil else {
showToast("请先选择关联景区")
return
}
let spots = viewModel.scenicSpotList
dropdownOverlay.show(
anchor: spotButton,
options: spots.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenicSpot?.id) }
) { [weak self] index in
guard spots.indices.contains(index) else { return }
self?.viewModel.selectScenicSpot(spots[index])
}
}
@objc private func projectTapped() {
guard viewModel.selectedScenic != nil else {
showToast("请先选择景区")
return
}
Task {
if viewModel.projectList.isEmpty {
showLoading()
await viewModel.loadProjects(refresh: true, api: api)
hideLoading()
}
await MainActor.run { self.presentProjectDropdown() }
}
}
@MainActor
private func presentProjectDropdown() {
let projects = viewModel.projectList
var options = projects.map { SampleAnchoredDropdownView.Option(title: $0.name, isSelected: $0.id == viewModel.selectedProject?.id) }
if viewModel.projectCanLoadMore {
options.append(.init(title: "加载更多...", isLoadMore: true))
}
dropdownOverlay.show(
anchor: projectButton,
options: options,
onReachedBottom: { [weak self] in self?.loadMoreProjectsAndRefreshDropdown() }
) { [weak self] index in
guard let self else { return }
if projects.indices.contains(index) {
self.viewModel.selectProject(projects[index])
} else if self.viewModel.projectCanLoadMore {
self.loadMoreProjectsAndRefreshDropdown()
}
}
}
private func loadMoreProjectsAndRefreshDropdown() {
Task {
await viewModel.loadProjects(refresh: false, api: api)
await MainActor.run { self.presentProjectDropdown() }
}
}
@objc private func cancelTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func confirmTapped() {
Task {
showLoading()
defer { hideLoading() }
await viewModel.uploadSample(api: api)
}
}
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 previewItems = completed.compactMap { entry -> MediaPreviewItem? in
switch viewModel.mediaType {
case .image: return .remoteImage(entry.element.ossUrl)
case .video: return .remoteVideo(entry.element.ossUrl)
}
}
guard !previewItems.isEmpty else { return }
presentMediaPreview(items: previewItems, startIndex: min(startIndex, previewItems.count - 1))
}
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: [SampleUploadedMediaItem]) {
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) -> SampleUploadedMediaItem? {
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
return SampleUploadedMediaItem(
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) -> SampleUploadedMediaItem {
let size = videoDisplaySize(url: sourceURL)
return SampleUploadedMediaItem(
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 UploadSampleViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
var loadedItems = [SampleUploadedMediaItem?](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 = self.resolvedFileName(provider: provider, url: url, fallbackExtension: "mp4")
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
}
} else {
group.enter()
let imageType = provider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .image) == true
} ?? UTType.image.identifier
provider.loadFileRepresentation(forTypeIdentifier: imageType) { [weak self] url, _ in
defer { group.leave() }
guard let self, let url, let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return }
let pathExtension = url.pathExtension
let ext = UTType(imageType)?.preferredFilenameExtension ?? (pathExtension.isEmpty ? "jpg" : pathExtension)
let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: ext)
loadedItems[index] = SampleUploadedMediaItem(
data: data,
thumbnailData: image.jpegData(compressionQuality: 0.75),
fileName: fileName,
size: Int64(data.count),
width: Int(image.size.width * image.scale),
height: Int(image.size.height * image.scale)
)
}
}
}
group.notify(queue: .main) { [weak self] in
self?.uploadPickedItems(loadedItems.compactMap { $0 })
}
}
private func resolvedFileName(provider: NSItemProvider, url: URL, fallbackExtension: String) -> String {
let suggested = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !suggested.isEmpty {
return URL(fileURLWithPath: suggested).pathExtension.isEmpty
? "\(suggested).\(fallbackExtension)"
: suggested
}
let urlName = url.lastPathComponent
if !urlName.isEmpty, urlName != "." { return urlName }
return "sample_\(Int(Date().timeIntervalSince1970)).\(fallbackExtension)"
}
}
extension UploadSampleViewController: 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: "sample_image_\(Int(Date().timeIntervalSince1970)).jpg") {
uploadPickedItems([item])
}
}
}
///
private final class SampleFormFieldView: 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 SampleSelectButton: UIControl {
private let titleLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.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
}
}
///
private final class SampleUploadGridView: UIView {
private let stack = UIStackView()
private var items: [SampleUploadedMediaItem] = []
private var mediaType: SampleMediaType = .image
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: [SampleUploadedMediaItem], mediaType: SampleMediaType) {
self.items = items
self.mediaType = mediaType
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))
} else {
rowStack.addArrangedSubview(UIView())
}
}
}
}
private func makeCell(item: SampleUploadedMediaItem?, index: Int) -> UIView {
let cell = SampleUploadThumbView(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 SampleUploadThumbView: 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 = SampleExpandedTouchButton(type: .system)
private let playIcon = UIImageView(image: UIImage(named: "material_ic_play"))
private let mediaType: SampleMediaType
private let dashedLayer = CAShapeLayer()
private var previewEnabled = false
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
var onPreview: (() -> Void)?
init(mediaType: SampleMediaType) {
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
textLabel.font = .systemFont(ofSize: 12)
textLabel.textColor = UIColor(hex: 0x4B5563)
textLabel.textAlignment = .center
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
[imageView, addCircle, addIcon, textLabel, playIcon].forEach {
$0.isUserInteractionEnabled = false
}
accessibilityLabel = mediaType == .image ? "上传图片" : "上传视频"
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
backgroundColor = .white
layer.borderWidth = 0
dashedLayer.isHidden = false
previewEnabled = false
addCircle.isHidden = false
textLabel.isHidden = false
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
deleteButton.isHidden = true
playIcon.isHidden = true
isUserInteractionEnabled = true
}
func apply(item: SampleUploadedMediaItem) {
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
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 final class SampleCoverUploadView: 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 = SampleExpandedTouchButton(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
addCircle.backgroundColor = AppColor.primary
addCircle.layer.cornerRadius = 16
addIcon.tintColor = .white
titleLabel.text = "点击上传图片"
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.textAlignment = .center
hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB"
hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center
[imageView, addCircle, addIcon, titleLabel, hintLabel].forEach {
$0.isUserInteractionEnabled = false
}
accessibilityLabel = "上传封面图片"
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: SampleUploadedMediaItem?) {
guard let item else {
imageView.image = nil
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 {
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 SampleUploadProgressOverlay: 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: SampleUploadDialogState) {
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)))%"
}
}
/// Android 44pt
private final class SampleExpandedTouchButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let horizontalInset = max(0, (44 - bounds.width) / 2)
let verticalInset = max(0, (44 - bounds.height) / 2)
return bounds.insetBy(dx: -horizontalInset, dy: -verticalInset).contains(point)
}
}