模块化 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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user