模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -146,17 +146,15 @@ final class MaterialDetailViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
let alert = UIAlertController(title: "确认删除", message: "确定删除该素材吗?", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
let dialog = MaterialDeleteDialogViewController(itemName: viewModel.detail?.name ?? "") { [weak self] in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
self.showLoading()
|
||||
await self.viewModel.delete(api: self.api)
|
||||
self.hideLoading()
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
present(dialog, animated: true)
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
@ -170,6 +168,28 @@ final class MaterialDetailViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情无限轮播的页码映射规则。
|
||||
struct MaterialCarouselIndexMapper {
|
||||
/// 初始渲染页;多媒体时跳过头部复制页。
|
||||
static func initialRenderedPage(mediaCount: Int) -> Int {
|
||||
mediaCount > 1 ? 1 : 0
|
||||
}
|
||||
|
||||
/// 将头尾复制页归一到对应的真实渲染页。
|
||||
static func normalizedRenderedPage(_ page: Int, mediaCount: Int) -> Int {
|
||||
guard mediaCount > 1 else { return 0 }
|
||||
if page <= 0 { return mediaCount }
|
||||
if page >= mediaCount + 1 { return 1 }
|
||||
return page
|
||||
}
|
||||
|
||||
/// 将渲染页映射到圆点使用的真实素材索引。
|
||||
static func logicalPage(renderedPage: Int, mediaCount: Int) -> Int {
|
||||
guard mediaCount > 1 else { return 0 }
|
||||
return normalizedRenderedPage(renderedPage, mediaCount: mediaCount) - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情媒体轮播,支持图片自动切换与视频播完切换。
|
||||
/// 素材详情顶部媒体轮播视图,支持图片与视频播放。
|
||||
private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
@ -177,8 +197,11 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
private let pageControl = UIPageControl()
|
||||
private let placeholderLabel = UILabel()
|
||||
private var media: [MaterialDetailMediaItem] = []
|
||||
private var renderedMedia: [MaterialDetailMediaItem] = []
|
||||
private var players: [Int: AVPlayer] = [:]
|
||||
private var playerViews: [Int: MaterialPlayerLayerView] = [:]
|
||||
private var timer: Timer?
|
||||
private var renderedPage = 0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@ -224,7 +247,9 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
players.values.forEach { $0.pause() }
|
||||
players.removeAll()
|
||||
playerViews.removeAll()
|
||||
self.media = media
|
||||
renderedMedia = media.count > 1 ? [media.last!] + media + [media.first!] : media
|
||||
scrollView.subviews.forEach { $0.removeFromSuperview() }
|
||||
pageControl.numberOfPages = media.count
|
||||
pageControl.currentPage = 0
|
||||
@ -233,7 +258,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
scrollView.isHidden = media.isEmpty
|
||||
guard !media.isEmpty else { return }
|
||||
|
||||
for (index, item) in media.enumerated() {
|
||||
renderedPage = MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: media.count)
|
||||
for (index, item) in renderedMedia.enumerated() {
|
||||
let page = UIView()
|
||||
page.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
scrollView.addSubview(page)
|
||||
@ -245,6 +271,7 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
}
|
||||
setNeedsLayout()
|
||||
layoutIfNeeded()
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * bounds.width, y: 0)
|
||||
playCurrentPageIfNeeded()
|
||||
}
|
||||
|
||||
@ -283,7 +310,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
let player = AVPlayer(url: url)
|
||||
players[index] = player
|
||||
let playerView = MaterialPlayerLayerView()
|
||||
playerView.playerLayer.player = player
|
||||
playerView.configure(player: player)
|
||||
playerViews[index] = playerView
|
||||
page.addSubview(playerView)
|
||||
playerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
@ -302,29 +330,40 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
for (index, page) in scrollView.subviews.enumerated() {
|
||||
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
|
||||
}
|
||||
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
|
||||
scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMedia.count), height: size.height)
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * size.width, y: 0)
|
||||
}
|
||||
|
||||
private func updateCurrentPage() {
|
||||
guard bounds.width > 0 else { return }
|
||||
let page = Int(round(scrollView.contentOffset.x / bounds.width))
|
||||
pageControl.currentPage = max(0, min(page, media.count - 1))
|
||||
let rawPage = Int(round(scrollView.contentOffset.x / bounds.width))
|
||||
let page = MaterialCarouselIndexMapper.normalizedRenderedPage(rawPage, mediaCount: media.count)
|
||||
if media.count > 1 {
|
||||
if page != rawPage {
|
||||
scrollView.setContentOffset(CGPoint(x: CGFloat(page) * bounds.width, y: 0), animated: false)
|
||||
}
|
||||
pageControl.currentPage = MaterialCarouselIndexMapper.logicalPage(renderedPage: page, mediaCount: media.count)
|
||||
} else {
|
||||
pageControl.currentPage = 0
|
||||
}
|
||||
renderedPage = page
|
||||
playCurrentPageIfNeeded()
|
||||
}
|
||||
|
||||
private func playCurrentPageIfNeeded() {
|
||||
stopTimer()
|
||||
players.forEach { index, player in
|
||||
if index == pageControl.currentPage {
|
||||
if index == renderedPage {
|
||||
player.seek(to: .zero)
|
||||
player.play()
|
||||
playerViews[index]?.setPlaying(true)
|
||||
} else {
|
||||
player.pause()
|
||||
playerViews[index]?.setPlaying(false)
|
||||
}
|
||||
}
|
||||
guard !media.isEmpty else { return }
|
||||
if media[pageControl.currentPage].type == 1 {
|
||||
guard renderedMedia.indices.contains(renderedPage) else { return }
|
||||
if renderedMedia[renderedPage].type == 1 {
|
||||
startImageTimer()
|
||||
}
|
||||
}
|
||||
@ -342,13 +381,13 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
}
|
||||
|
||||
@objc private func videoDidEnd(_ notification: Notification) {
|
||||
guard players[renderedPage]?.currentItem === notification.object as? AVPlayerItem else { return }
|
||||
advancePage()
|
||||
}
|
||||
|
||||
private func advancePage() {
|
||||
guard media.count > 1, bounds.width > 0 else { return }
|
||||
let nextPage = (pageControl.currentPage + 1) % media.count
|
||||
pageControl.currentPage = nextPage
|
||||
let nextPage = renderedPage + 1
|
||||
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.playCurrentPageIfNeeded()
|
||||
@ -361,6 +400,64 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
private final class MaterialPlayerLayerView: UIView {
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
private let controlButton = UIButton(type: .system)
|
||||
private weak var player: AVPlayer?
|
||||
private var hideWorkItem: DispatchWorkItem?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
controlButton.tintColor = .white
|
||||
controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||
controlButton.layer.cornerRadius = 24
|
||||
controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside)
|
||||
addSubview(controlButton)
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls)))
|
||||
controlButton.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(48) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func configure(player: AVPlayer) {
|
||||
self.player = player
|
||||
playerLayer.player = player
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
func setPlaying(_ playing: Bool) {
|
||||
controlButton.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
|
||||
if playing {
|
||||
scheduleHide()
|
||||
} else {
|
||||
hideWorkItem?.cancel()
|
||||
controlButton.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func controlTapped() {
|
||||
guard let player else { return }
|
||||
if player.timeControlStatus == .playing {
|
||||
player.pause()
|
||||
setPlaying(false)
|
||||
} else {
|
||||
player.play()
|
||||
setPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showControls() {
|
||||
controlButton.isHidden = false
|
||||
if player?.timeControlStatus == .playing { scheduleHide() }
|
||||
}
|
||||
|
||||
private func scheduleHide() {
|
||||
hideWorkItem?.cancel()
|
||||
controlButton.isHidden = false
|
||||
let work = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true }
|
||||
hideWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情基础信息区。
|
||||
@ -370,7 +467,7 @@ private final class MaterialDetailInfoSection: UIView {
|
||||
private let listingBadge = UILabel()
|
||||
private let tagStack = UIStackView()
|
||||
private let statsStack = UIStackView()
|
||||
private let downloadStat = MaterialDetailStatView(systemIconName: "arrow.down.circle")
|
||||
private let downloadStat = MaterialDetailStatView(iconName: "material_ic_download_count")
|
||||
private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count")
|
||||
private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count")
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ final class MaterialFormViewController: BaseViewController {
|
||||
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)?
|
||||
@ -106,7 +107,9 @@ final class MaterialFormViewController: BaseViewController {
|
||||
bottomBar.addSubview(cancelButton)
|
||||
bottomBar.addSubview(confirmButton)
|
||||
view.addSubview(progressOverlay)
|
||||
view.addSubview(dropdownOverlay)
|
||||
progressOverlay.isHidden = true
|
||||
dropdownOverlay.isHidden = true
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
@ -147,6 +150,9 @@ final class MaterialFormViewController: BaseViewController {
|
||||
progressOverlay.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
dropdownOverlay.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
@ -167,6 +173,7 @@ final class MaterialFormViewController: BaseViewController {
|
||||
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) }
|
||||
@ -174,7 +181,7 @@ final class MaterialFormViewController: BaseViewController {
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.addTag(api: self.api) }
|
||||
}
|
||||
tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) }
|
||||
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()
|
||||
@ -207,6 +214,7 @@ final class MaterialFormViewController: BaseViewController {
|
||||
} else {
|
||||
progressOverlay.isHidden = true
|
||||
}
|
||||
presentTagDeleteDialogIfNeeded()
|
||||
}
|
||||
|
||||
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
|
||||
@ -222,25 +230,30 @@ final class MaterialFormViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@objc private func spotTapped() {
|
||||
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
|
||||
viewModel.scenicSpotList.forEach { spot in
|
||||
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectScenicSpot(spot)
|
||||
})
|
||||
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])
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func mediaTypeTapped() {
|
||||
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
|
||||
MaterialMediaType.allCases.forEach { type in
|
||||
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectMediaType(type)
|
||||
})
|
||||
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])
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
@ -267,24 +280,29 @@ final class MaterialFormViewController: BaseViewController {
|
||||
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
|
||||
self?.presentPhotoPicker()
|
||||
})
|
||||
if target == .material {
|
||||
sheet.addAction(UIAlertAction(title: "从云盘导入", style: .default) { [weak self] _ in
|
||||
self?.openCloudPicker()
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
private func openCloudPicker() {
|
||||
let controller = CloudStoragePickForTaskViewController(
|
||||
importedFileIDs: viewModel.importedCloudFileIDs,
|
||||
allowedFileType: viewModel.mediaType.cloudFileType
|
||||
)
|
||||
controller.onConfirmed = { [weak self] files in
|
||||
self?.viewModel.addCloudMediaFiles(files)
|
||||
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)
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
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() {
|
||||
@ -462,7 +480,7 @@ private final class MaterialFormFieldView: UIView {
|
||||
/// 带标题和值的表单选择控件。
|
||||
private final class MaterialSelectButton: UIControl {
|
||||
private let titleLabel = UILabel()
|
||||
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||
private let chevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down"))
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@ -497,12 +515,92 @@ private final class MaterialSelectButton: UIControl {
|
||||
}
|
||||
}
|
||||
|
||||
/// 锚定在表单选择框下方的 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.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 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)
|
||||
@ -550,6 +648,7 @@ private final class MaterialUploadGridView: UIView {
|
||||
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?() }
|
||||
@ -563,14 +662,17 @@ private final class MaterialUploadGridView: UIView {
|
||||
private final class MaterialUploadThumbView: UIControl {
|
||||
private let imageView = UIImageView()
|
||||
private let addCircle = UIView()
|
||||
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||
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(systemName: "play.fill"))
|
||||
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
|
||||
@ -586,7 +688,7 @@ private final class MaterialUploadThumbView: UIControl {
|
||||
textLabel.font = .systemFont(ofSize: 12)
|
||||
textLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
textLabel.textAlignment = .center
|
||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
deleteButton.layer.cornerRadius = 10
|
||||
@ -622,7 +724,12 @@ private final class MaterialUploadThumbView: UIControl {
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -631,12 +738,19 @@ private final class MaterialUploadThumbView: UIControl {
|
||||
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.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
layer.borderWidth = 1
|
||||
layer.borderWidth = 0
|
||||
dashedLayer.isHidden = false
|
||||
previewEnabled = false
|
||||
addCircle.isHidden = false
|
||||
textLabel.isHidden = false
|
||||
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
|
||||
@ -646,7 +760,8 @@ private final class MaterialUploadThumbView: UIControl {
|
||||
}
|
||||
|
||||
func apply(item: MaterialUploadedMediaItem) {
|
||||
layer.borderWidth = 0
|
||||
dashedLayer.isHidden = true
|
||||
previewEnabled = !item.ossUrl.isEmpty && !item.isUploading
|
||||
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
addCircle.isHidden = true
|
||||
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
|
||||
@ -658,7 +773,14 @@ private final class MaterialUploadThumbView: UIControl {
|
||||
textLabel.numberOfLines = 2
|
||||
} else {
|
||||
textLabel.isHidden = true
|
||||
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
|
||||
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)
|
||||
@ -666,14 +788,27 @@ private final class MaterialUploadThumbView: UIControl {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func addTapped() {
|
||||
guard !addCircle.isHidden else { return }
|
||||
onAdd?()
|
||||
@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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材封面上传视图。
|
||||
@ -681,18 +816,22 @@ private final class MaterialUploadThumbView: UIControl {
|
||||
private final class MaterialCoverUploadView: UIControl {
|
||||
private let imageView = UIImageView()
|
||||
private let addCircle = UIView()
|
||||
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||
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
|
||||
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
layer.borderWidth = 1
|
||||
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
|
||||
@ -707,7 +846,7 @@ private final class MaterialCoverUploadView: UIControl {
|
||||
hintLabel.font = .systemFont(ofSize: 12)
|
||||
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||
hintLabel.textAlignment = .center
|
||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
deleteButton.layer.cornerRadius = 12
|
||||
@ -753,6 +892,12 @@ private final class MaterialCoverUploadView: UIControl {
|
||||
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
|
||||
@ -763,6 +908,7 @@ private final class MaterialCoverUploadView: UIControl {
|
||||
titleLabel.numberOfLines = 1
|
||||
hintLabel.isHidden = false
|
||||
deleteButton.isHidden = true
|
||||
dashedLayer.isHidden = false
|
||||
return
|
||||
}
|
||||
if item.isUploading {
|
||||
@ -773,6 +919,7 @@ private final class MaterialCoverUploadView: UIControl {
|
||||
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)
|
||||
@ -783,6 +930,7 @@ private final class MaterialCoverUploadView: UIControl {
|
||||
titleLabel.isHidden = true
|
||||
hintLabel.isHidden = true
|
||||
deleteButton.isHidden = item.ossUrl.isEmpty
|
||||
dashedLayer.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -819,17 +967,17 @@ private final class MaterialTagEditorView: UIView {
|
||||
tagsStack.alignment = .leading
|
||||
inputRow.axis = .horizontal
|
||||
inputRow.spacing = 8
|
||||
textField.placeholder = "请输入标签"
|
||||
textField.font = .systemFont(ofSize: 14)
|
||||
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||||
textField.layer.borderWidth = 1
|
||||
textField.layer.cornerRadius = 8
|
||||
textField.layer.cornerRadius = 4
|
||||
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
|
||||
textField.leftViewMode = .always
|
||||
addButton.setTitle("添加", for: .normal)
|
||||
textField.placeholder = "请输入标签,最低5个字符"
|
||||
addButton.setTitle("添加标签", for: .normal)
|
||||
addButton.setTitleColor(.white, for: .normal)
|
||||
addButton.backgroundColor = AppColor.primary
|
||||
addButton.layer.cornerRadius = 8
|
||||
addButton.layer.cornerRadius = 4
|
||||
addSubview(stack)
|
||||
stack.addArrangedSubview(tagsStack)
|
||||
stack.addArrangedSubview(inputRow)
|
||||
@ -839,11 +987,11 @@ private final class MaterialTagEditorView: UIView {
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
textField.snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
addButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(72)
|
||||
make.height.equalTo(44)
|
||||
make.width.equalTo(80)
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
|
||||
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||
@ -859,10 +1007,13 @@ private final class MaterialTagEditorView: UIView {
|
||||
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.setTitle(tag.name, for: .normal)
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 12)
|
||||
button.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
|
||||
@ -15,16 +15,15 @@ final class MaterialListViewController: BaseViewController {
|
||||
|
||||
private let searchContainer = UIView()
|
||||
private let searchBox = UIView()
|
||||
private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
||||
private let searchIconView = UIImageView(image: UIImage(named: "material_ic_search"))
|
||||
private let searchTextField = UITextField()
|
||||
private let filterContainer = UIView()
|
||||
private let filterButton = UIButton(type: .system)
|
||||
private let filterTitleLabel = UILabel()
|
||||
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||
private let filterChevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down"))
|
||||
private let filterDropdownView = UIView()
|
||||
private let filterDropdownStack = UIStackView()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
private let bottomBar = UIView()
|
||||
private let addButton = UIButton(type: .system)
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>!
|
||||
@ -115,12 +114,6 @@ final class MaterialListViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
emptyLabel.text = "暂无素材"
|
||||
emptyLabel.font = .systemFont(ofSize: 16)
|
||||
emptyLabel.textColor = AppColor.textSecondary
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
addButton.setTitle("添加", for: .normal)
|
||||
addButton.setTitleColor(.white, for: .normal)
|
||||
@ -137,7 +130,6 @@ final class MaterialListViewController: BaseViewController {
|
||||
filterButton.addSubview(filterTitleLabel)
|
||||
filterButton.addSubview(filterChevronView)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(addButton)
|
||||
view.addSubview(filterDropdownView)
|
||||
@ -206,11 +198,6 @@ final class MaterialListViewController: BaseViewController {
|
||||
filterDropdownStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalTo(tableView)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
@ -242,7 +229,7 @@ final class MaterialListViewController: BaseViewController {
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
|
||||
filterTitleLabel.text = "筛选"
|
||||
filterOptionButtons.forEach { status, button in
|
||||
let selected = status == viewModel.filterStatus
|
||||
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
|
||||
@ -256,7 +243,6 @@ final class MaterialListViewController: BaseViewController {
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item))
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
|
||||
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
|
||||
if !viewModel.isRefreshing {
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
@ -371,6 +357,10 @@ private final class MaterialStatsCell: UITableViewCell {
|
||||
contentView.backgroundColor = AppColor.pageBackground
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.12
|
||||
cardView.layer.shadowRadius = 2
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 12
|
||||
stack.distribution = .fillEqually
|
||||
@ -451,7 +441,7 @@ private final class MaterialListCell: UITableViewCell {
|
||||
private let statusBadge = MaterialStatusBadgeView()
|
||||
private let listingSwitch = UISwitch()
|
||||
private let statsStack = UIStackView()
|
||||
private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle")
|
||||
private let downloadStatView = MaterialStatView(iconName: "material_ic_download_count")
|
||||
private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count")
|
||||
private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count")
|
||||
private var isApplying = false
|
||||
|
||||
258
suixinkan/UI/MaterialManagement/MaterialManagementDialogs.swift
Normal file
258
suixinkan/UI/MaterialManagement/MaterialManagementDialogs.swift
Normal file
@ -0,0 +1,258 @@
|
||||
//
|
||||
// MaterialManagementDialogs.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 素材模块 Android 风格红色删除确认弹窗。
|
||||
final class MaterialDeleteDialogViewController: UIViewController {
|
||||
private let dialogTitle: String
|
||||
private let itemName: String
|
||||
private let itemSuffix: String
|
||||
private let onConfirm: () -> Void
|
||||
private let onCancel: () -> Void
|
||||
private let dimView = UIView()
|
||||
private let cardView = UIView()
|
||||
|
||||
/// 创建素材删除确认弹窗。
|
||||
init(
|
||||
title: String = "删除素材",
|
||||
itemName: String,
|
||||
itemSuffix: String = "素材吗?",
|
||||
onCancel: @escaping () -> Void = {},
|
||||
onConfirm: @escaping () -> Void
|
||||
) {
|
||||
dialogTitle = title
|
||||
self.itemName = itemName
|
||||
self.itemSuffix = itemSuffix
|
||||
self.onConfirm = onConfirm
|
||||
self.onCancel = onCancel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 16
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = dialogTitle
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = UIColor(hex: 0xFF0000)
|
||||
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.numberOfLines = 0
|
||||
let prefix = "您确定要删除"
|
||||
let suffix = itemSuffix
|
||||
let message = NSMutableAttributedString(
|
||||
string: prefix + itemName + suffix,
|
||||
attributes: [
|
||||
.font: UIFont.systemFont(ofSize: 16),
|
||||
.foregroundColor: UIColor(hex: 0x333333),
|
||||
]
|
||||
)
|
||||
if !itemName.isEmpty {
|
||||
let prefixLength = (prefix as NSString).length
|
||||
let itemLength = (itemName as NSString).length
|
||||
message.addAttribute(
|
||||
.font,
|
||||
value: UIFont.systemFont(ofSize: 16, weight: .bold),
|
||||
range: NSRange(location: prefixLength, length: itemLength)
|
||||
)
|
||||
}
|
||||
messageLabel.attributedText = message
|
||||
|
||||
let buttons = UIStackView()
|
||||
buttons.axis = .horizontal
|
||||
buttons.spacing = 12
|
||||
buttons.distribution = .fillEqually
|
||||
let cancelButton = makeButton(title: "取消", titleColor: UIColor(hex: 0xFF0000), background: UIColor(hex: 0xFFE5E5))
|
||||
let confirmButton = makeButton(title: "确认", titleColor: .white, background: UIColor(hex: 0xFF0000))
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
[cancelButton, confirmButton].forEach { button in
|
||||
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
buttons.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
view.addSubview(dimView)
|
||||
view.addSubview(cardView)
|
||||
cardView.addSubview(stack)
|
||||
[titleLabel, messageLabel, buttons].forEach(stack.addArrangedSubview)
|
||||
stack.setCustomSpacing(24, after: messageLabel)
|
||||
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(24) }
|
||||
}
|
||||
|
||||
private func makeButton(title: String, titleColor: UIColor, background: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(titleColor, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.backgroundColor = background
|
||||
button.layer.cornerRadius = 8
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true) { [onCancel] in onCancel() }
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
dismiss(animated: true) { [onConfirm] in onConfirm() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材预览数据,统一描述图片和视频。
|
||||
struct MaterialPreviewItem: Equatable {
|
||||
let url: String
|
||||
let mediaType: MaterialMediaType
|
||||
}
|
||||
|
||||
/// Android `MediaPreviewDialog` 对齐的全屏素材预览页。
|
||||
final class MaterialMediaPreviewViewController: UIViewController, UIScrollViewDelegate {
|
||||
private let items: [MaterialPreviewItem]
|
||||
private let startIndex: Int
|
||||
private let scrollView = UIScrollView()
|
||||
private let closeButton = UIButton(type: .system)
|
||||
private var pages: [UIView] = []
|
||||
private var players: [Int: AVPlayerViewController] = [:]
|
||||
private var currentIndex = 0
|
||||
|
||||
/// 创建全屏素材预览页。
|
||||
init(items: [MaterialPreviewItem], startIndex: Int) {
|
||||
self.items = items
|
||||
self.startIndex = max(0, min(startIndex, max(0, items.count - 1)))
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
scrollView.backgroundColor = .black
|
||||
scrollView.isPagingEnabled = true
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.bounces = false
|
||||
scrollView.delegate = self
|
||||
closeButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||
closeButton.tintColor = .white
|
||||
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||
closeButton.layer.cornerRadius = 22
|
||||
closeButton.accessibilityLabel = "关闭"
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(scrollView)
|
||||
view.addSubview(closeButton)
|
||||
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(5)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
buildPages()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
let size = scrollView.bounds.size
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
for (index, page) in pages.enumerated() {
|
||||
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
|
||||
}
|
||||
scrollView.contentSize = CGSize(width: CGFloat(items.count) * size.width, height: size.height)
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(currentIndex) * size.width, y: 0)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
currentIndex = startIndex
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
playCurrentVideo()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
players.values.forEach { $0.player?.pause() }
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
updateCurrentIndex()
|
||||
}
|
||||
|
||||
private func buildPages() {
|
||||
for (index, item) in items.enumerated() {
|
||||
let page = UIView()
|
||||
page.backgroundColor = .black
|
||||
scrollView.addSubview(page)
|
||||
pages.append(page)
|
||||
if item.mediaType == .video, let url = URL(string: item.url) {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.player = AVPlayer(url: url)
|
||||
controller.showsPlaybackControls = true
|
||||
addChild(controller)
|
||||
page.addSubview(controller.view)
|
||||
controller.view.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
controller.didMove(toParent: self)
|
||||
players[index] = controller
|
||||
} else {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.backgroundColor = .black
|
||||
page.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
if let url = URL(string: item.url) {
|
||||
imageView.kf.setImage(with: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrentIndex() {
|
||||
guard scrollView.bounds.width > 0 else { return }
|
||||
currentIndex = max(0, min(Int(round(scrollView.contentOffset.x / scrollView.bounds.width)), items.count - 1))
|
||||
playCurrentVideo()
|
||||
}
|
||||
|
||||
private func playCurrentVideo() {
|
||||
players.forEach { index, controller in
|
||||
if index == currentIndex {
|
||||
controller.player?.play()
|
||||
} else {
|
||||
controller.player?.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user