兼容 Android 媒体项 size 字段格式,增强灵活解码容错,并统一样片列表开关确认逻辑与安全区布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
996 lines
39 KiB
Swift
996 lines
39 KiB
Swift
//
|
||
// 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 = NetworkServices.shared.sampleManagementAPI,
|
||
uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService
|
||
) {
|
||
self.viewModel = viewModel
|
||
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() {
|
||
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)
|
||
}
|
||
}
|