@@ -0,0 +1,768 @@
//
// T r a v e l A l b u m P h o t o P r e v i e w V i e w C o n t r o l l e r . s w i f t
// s u i x i n k a n
//
import Kingfisher
import SnapKit
import UIKit
// / 相 册 项 目 续 页 回 调 , 返 回 当 前 筛 选 和 排 序 下 的 完 整 已 加 载 项 目 及 总 数 。
typealias TravelAlbumPreviewLoadMore = ( ) async -> ( projects : [ TravelAlbumPreviewProject ] , totalCount : Int )
// / 旅 拍 相 册 全 屏 图 片 预 览 页 , 支 持 项 目 分 页 、 关 联 图 T a b 、 缩 放 和 沉 浸 式 工 具 栏 。
final class TravelAlbumPhotoPreviewViewController : UIViewController {
private var projects : [ TravelAlbumPreviewProject ]
private var totalCount : Int
private let configuration : TravelAlbumPreviewConfiguration
private let actionHandler : any TravelAlbumPreviewActionHandling
private let loadMore : TravelAlbumPreviewLoadMore ?
private var nodes : [ TravelAlbumPreviewNode ] = [ ]
private var currentNodeIndex = 0
private var dragStartIndex = 0
private var selectedKind : TravelAlbumPreviewAssetKind = . original
private var chromeVisible = true
private var isLoadingMore = false
private var didApplyInitialPosition = false
private var lastCollectionSize : CGSize = . zero
private var previewPrefetcher : ImagePrefetcher ?
private lazy var collectionView = UICollectionView ( frame : . zero , collectionViewLayout : makeLayout ( ) )
private let topChrome = UIView ( )
private let closeButton = UIButton ( type : . system )
private let titleLabel = UILabel ( )
private let sizeLabel = UILabel ( )
private let counterLabel = UILabel ( )
private let bottomChrome = UIView ( )
private let divider = UIView ( )
private let tabStack = UIStackView ( )
private let actionStack = UIStackView ( )
private var tabHeightConstraint : Constraint ?
// / 创 建 全 屏 预 览 页 。
init (
projects : [ TravelAlbumPreviewProject ] ,
totalCount : Int ,
startProjectIndex : Int ,
configuration : TravelAlbumPreviewConfiguration = . init ( ) ,
actionHandler : any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler ( ) ,
loadMore : TravelAlbumPreviewLoadMore ? = nil
) {
self . projects = Self . deduplicated ( projects )
self . totalCount = max ( totalCount , projects . count )
self . configuration = configuration
self . actionHandler = actionHandler
self . loadMore = loadMore
super . init ( nibName : nil , bundle : nil )
modalPresentationStyle = . fullScreen
rebuildNodes ( keepingProjectIndex : max ( 0 , min ( startProjectIndex , projects . count - 1 ) ) , kind : . original )
}
@ available ( * , unavailable )
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
override var prefersStatusBarHidden : Bool { true }
override var preferredScreenEdgesDeferringSystemGestures : UIRectEdge { [ . bottom ] }
override func viewDidLoad ( ) {
super . viewDidLoad ( )
setupUI ( )
updateForCurrentNode ( )
prefetchAdjacentImages ( )
}
override func viewDidLayoutSubviews ( ) {
super . viewDidLayoutSubviews ( )
let collectionSize = collectionView . bounds . size
if collectionSize != lastCollectionSize {
lastCollectionSize = collectionSize
collectionView . collectionViewLayout . invalidateLayout ( )
if didApplyInitialPosition {
setPage ( currentNodeIndex , animated : false )
prefetchAdjacentImages ( )
}
}
guard ! didApplyInitialPosition , collectionView . bounds . width > 0 else { return }
didApplyInitialPosition = true
setPage ( currentNodeIndex , animated : false )
upgradeCurrentCellToDetailImage ( )
}
private func setupUI ( ) {
view . backgroundColor = . black
collectionView . backgroundColor = . black
collectionView . isPagingEnabled = true
collectionView . alwaysBounceHorizontal = nodes . count > 1
collectionView . showsHorizontalScrollIndicator = false
collectionView . delegate = self
collectionView . dataSource = self
collectionView . register (
TravelAlbumPreviewImageCell . self ,
forCellWithReuseIdentifier : TravelAlbumPreviewImageCell . reuseIdentifier
)
topChrome . backgroundColor = UIColor . black . withAlphaComponent ( 0.72 )
bottomChrome . backgroundColor = UIColor . black . withAlphaComponent ( 0.78 )
divider . backgroundColor = UIColor . white . withAlphaComponent ( 0.2 )
closeButton . setImage ( UIImage ( systemName : " chevron.down " , withConfiguration : UIImage . SymbolConfiguration ( weight : . semibold ) ) , for : . normal )
closeButton . tintColor = . white
closeButton . accessibilityLabel = " 关闭图片预览 "
closeButton . addTarget ( self , action : #selector ( closeTapped ) , for : . touchUpInside )
titleLabel . textColor = . white
titleLabel . font = . systemFont ( ofSize : 15 , weight : . medium )
titleLabel . textAlignment = . center
titleLabel . lineBreakMode = . byTruncatingMiddle
sizeLabel . textColor = UIColor . white . withAlphaComponent ( 0.65 )
sizeLabel . font = . systemFont ( ofSize : 11 , weight : . regular )
sizeLabel . textAlignment = . center
counterLabel . textColor = . white
counterLabel . font = . monospacedDigitSystemFont ( ofSize : 14 , weight : . medium )
counterLabel . textAlignment = . right
counterLabel . setContentCompressionResistancePriority ( . required , for : . horizontal )
tabStack . axis = . horizontal
tabStack . distribution = . fillEqually
tabStack . alignment = . fill
actionStack . axis = . horizontal
actionStack . distribution = . fillEqually
actionStack . alignment = . fill
configureActions ( )
let titleStack = UIStackView ( arrangedSubviews : [ titleLabel , sizeLabel ] )
titleStack . axis = . vertical
titleStack . spacing = 3
titleStack . alignment = . fill
view . addSubview ( collectionView )
view . addSubview ( topChrome )
topChrome . addSubview ( closeButton )
topChrome . addSubview ( titleStack )
topChrome . addSubview ( counterLabel )
view . addSubview ( bottomChrome )
bottomChrome . addSubview ( divider )
bottomChrome . addSubview ( tabStack )
bottomChrome . addSubview ( actionStack )
collectionView . snp . makeConstraints { $0 . edges . equalToSuperview ( ) }
topChrome . snp . makeConstraints { make in
make . top . leading . trailing . equalToSuperview ( )
make . bottom . equalTo ( view . safeAreaLayoutGuide . snp . top ) . offset ( 64 )
}
closeButton . snp . makeConstraints { make in
make . leading . equalToSuperview ( ) . offset ( 8 )
make . bottom . equalToSuperview ( ) . offset ( - 10 )
make . size . equalTo ( 44 )
}
counterLabel . snp . makeConstraints { make in
make . trailing . equalToSuperview ( ) . inset ( 16 )
make . centerY . equalTo ( closeButton )
make . width . greaterThanOrEqualTo ( 52 )
}
titleStack . snp . makeConstraints { make in
make . centerY . equalTo ( closeButton )
make . leading . greaterThanOrEqualTo ( closeButton . snp . trailing ) . offset ( 8 )
make . trailing . lessThanOrEqualTo ( counterLabel . snp . leading ) . offset ( - 8 )
make . centerX . equalToSuperview ( )
make . width . lessThanOrEqualTo ( 220 )
}
bottomChrome . snp . makeConstraints { make in
make . leading . trailing . bottom . equalToSuperview ( )
}
divider . snp . makeConstraints { make in
make . top . leading . trailing . equalToSuperview ( )
make . height . equalTo ( 0.5 )
}
tabStack . snp . makeConstraints { make in
make . top . equalTo ( divider . snp . bottom )
make . leading . trailing . equalToSuperview ( ) . inset ( 12 )
tabHeightConstraint = make . height . equalTo ( 0 ) . constraint
}
actionStack . snp . makeConstraints { make in
make . top . equalTo ( tabStack . snp . bottom )
make . leading . trailing . equalToSuperview ( ) . inset ( 8 )
make . height . equalTo ( 70 )
make . bottom . equalTo ( view . safeAreaLayoutGuide )
}
}
private func configureActions ( ) {
let aiButton = makeActionButton ( title : " AI修复 " , systemName : " wand.and.stars " , color : UIColor ( hex : 0x60A5FA ) )
let deleteButton = makeActionButton ( title : " 删除 " , systemName : " trash " , color : UIColor ( hex : 0xF87171 ) )
let refreshButton = makeActionButton ( title : " 刷新 " , systemName : " arrow.clockwise " , color : . white )
aiButton . addTarget ( self , action : #selector ( aiTapped ) , for : . touchUpInside )
deleteButton . addTarget ( self , action : #selector ( deleteTapped ) , for : . touchUpInside )
refreshButton . addTarget ( self , action : #selector ( refreshTapped ) , for : . touchUpInside )
[ aiButton , deleteButton , refreshButton ] . forEach ( actionStack . addArrangedSubview )
}
private func makeActionButton ( title : String , systemName : String , color : UIColor ) -> UIButton {
var configuration = UIButton . Configuration . plain ( )
configuration . title = title
configuration . image = UIImage ( systemName : systemName )
configuration . imagePlacement = . top
configuration . imagePadding = 5
configuration . baseForegroundColor = color
configuration . contentInsets = NSDirectionalEdgeInsets ( top : 8 , leading : 6 , bottom : 8 , trailing : 6 )
let button = UIButton ( configuration : configuration )
button . titleLabel ? . font = . systemFont ( ofSize : 12 , weight : . medium )
button . accessibilityLabel = title
return button
}
private func makeLayout ( ) -> UICollectionViewLayout {
let layout = UICollectionViewFlowLayout ( )
layout . scrollDirection = . horizontal
layout . minimumLineSpacing = 0
layout . minimumInteritemSpacing = 0
return layout
}
private var currentNode : TravelAlbumPreviewNode ? {
nodes . indices . contains ( currentNodeIndex ) ? nodes [ currentNodeIndex ] : nil
}
private var currentProject : TravelAlbumPreviewProject ? {
guard let node = currentNode , projects . indices . contains ( node . projectIndex ) else { return nil }
return projects [ node . projectIndex ]
}
private var currentAsset : TravelAlbumPreviewAsset ? {
guard let node = currentNode , let project = currentProject else { return nil }
let kind = configuration . swipeMode = = . projectsOnly ? selectedKind : node . kind
return project . asset ( for : kind ) ? ? project . asset ( for : . original )
}
private func updateForCurrentNode ( ) {
guard let node = currentNode else { return }
let asset = currentAsset
titleLabel . text = asset ? . fileName . isEmpty = = false ? asset ? . fileName : " 未命名照片 "
sizeLabel . text = TravelAlbumDisplayFormatter . fileSizeText ( asset ? . fileSize ? ? 0 )
counterLabel . text = " \( node . projectIndex + 1 ) / \( max ( totalCount , projects . count ) ) "
counterLabel . accessibilityLabel = " 第 \( node . projectIndex + 1 ) 张,共 \( max ( totalCount , projects . count ) ) 张 "
rebuildTabs ( )
loadMoreIfNeeded ( projectIndex : node . projectIndex )
}
private func rebuildTabs ( ) {
tabStack . arrangedSubviews . forEach {
tabStack . removeArrangedSubview ( $0 )
$0 . removeFromSuperview ( )
}
guard let project = currentProject , project . hasVariants else {
tabStack . isHidden = true
tabHeightConstraint ? . update ( offset : 0 )
return
}
tabStack . isHidden = false
tabHeightConstraint ? . update ( offset : 44 )
let activeKind = currentAsset ? . kind ? ? . original
for asset in project . orderedAssets {
let button = UIButton ( type : . system )
button . tag = asset . kind . rawValue
button . setTitle ( asset . kind . title , for : . normal )
button . titleLabel ? . font = . systemFont ( ofSize : 13 , weight : asset . kind = = activeKind ? . semibold : . medium )
button . setTitleColor ( asset . kind = = activeKind ? . white : UIColor . white . withAlphaComponent ( 0.55 ) , for : . normal )
button . backgroundColor = asset . kind = = activeKind ? UIColor . white . withAlphaComponent ( 0.12 ) : . clear
button . layer . cornerRadius = 8
button . accessibilityLabel = asset . kind . title
button . accessibilityTraits = asset . kind = = activeKind ? [ . button , . selected ] : . button
button . addTarget ( self , action : #selector ( tabTapped ( _ : ) ) , for : . touchUpInside )
tabStack . addArrangedSubview ( button )
}
view . layoutIfNeeded ( )
if ! chromeVisible {
bottomChrome . transform = CGAffineTransform ( translationX : 0 , y : bottomChrome . bounds . height )
}
}
@objc private func tabTapped ( _ sender : UIButton ) {
guard let kind = TravelAlbumPreviewAssetKind ( rawValue : sender . tag ) ,
let node = currentNode ,
currentProject ? . asset ( for : kind ) != nil
else { return }
switch configuration . swipeMode {
case . projectsOnly :
selectedKind = kind
collectionView . reloadItems ( at : [ IndexPath ( item : currentNodeIndex , section : 0 ) ] )
updateForCurrentNode ( )
case . includeVariants :
guard let target = nodes . firstIndex ( where : { $0 . projectIndex = = node . projectIndex && $0 . kind = = kind } ) else { return }
currentNodeIndex = target
setPage ( target , animated : true )
updateForCurrentNode ( )
}
}
private func didChangePage ( to index : Int ) {
guard nodes . indices . contains ( index ) else { return }
let oldNodeIndex = currentNodeIndex
let oldProjectIndex = currentNode ? . projectIndex
guard oldNodeIndex != index else {
updateForCurrentNode ( )
upgradeCurrentCellToDetailImage ( )
prefetchAdjacentImages ( )
return
}
currentNodeIndex = index
if oldProjectIndex != currentNode ? . projectIndex {
selectedKind = . original
resetProjectCellToOriginalIfNeeded ( at : oldNodeIndex )
}
collectionView . visibleCells . compactMap { $0 as ? TravelAlbumPreviewImageCell } . forEach { $0 . resetZoom ( ) }
updateForCurrentNode ( )
upgradeCurrentCellToDetailImage ( )
prefetchAdjacentImages ( )
}
private func resetProjectCellToOriginalIfNeeded ( at nodeIndex : Int ) {
guard configuration . swipeMode = = . projectsOnly ,
nodes . indices . contains ( nodeIndex ) ,
let cell = collectionView . cellForItem ( at : IndexPath ( item : nodeIndex , section : 0 ) ) as ? TravelAlbumPreviewImageCell
else { return }
let project = projects [ nodes [ nodeIndex ] . projectIndex ]
cell . apply ( asset : project . asset ( for : . original ) , quality : . preview )
}
private func upgradeCurrentCellToDetailImage ( ) {
guard let cell = collectionView . cellForItem (
at : IndexPath ( item : currentNodeIndex , section : 0 )
) as ? TravelAlbumPreviewImageCell else { return }
cell . apply ( asset : currentAsset , quality : . detail )
}
private func prefetchAdjacentImages ( ) {
previewPrefetcher ? . stop ( )
let indexes = [ currentNodeIndex - 2 , currentNodeIndex - 1 , currentNodeIndex + 1 , currentNodeIndex + 2 ]
let urls = indexes . compactMap { index -> URL ? in
guard nodes . indices . contains ( index ) else { return nil }
let node = nodes [ index ]
let project = projects [ node . projectIndex ]
let asset = project . asset ( for : node . kind ) ? ? project . asset ( for : . original )
guard let text = asset ? . previewURL , ! text . isEmpty else { return nil }
return URL ( string : text )
}
guard ! urls . isEmpty else {
previewPrefetcher = nil
return
}
let prefetcher = ImagePrefetcher (
urls : urls ,
options : TravelAlbumPreviewImageRequest . previewOptions ( viewSize : collectionView . bounds . size )
)
prefetcher . maxConcurrentDownloads = 2
previewPrefetcher = prefetcher
prefetcher . start ( )
}
private func setPage ( _ index : Int , animated : Bool ) {
guard collectionView . bounds . width > 0 , nodes . indices . contains ( index ) else { return }
collectionView . setContentOffset ( CGPoint ( x : CGFloat ( index ) * collectionView . bounds . width , y : 0 ) , animated : animated )
}
private func rebuildNodes ( keepingProjectIndex : Int , kind : TravelAlbumPreviewAssetKind ) {
nodes = TravelAlbumPreviewNavigator . nodes ( projects : projects , mode : configuration . swipeMode )
currentNodeIndex = nodes . firstIndex {
$0 . projectIndex = = keepingProjectIndex &&
( configuration . swipeMode = = . projectsOnly || $0 . kind = = kind )
} ? ? nodes . firstIndex { $0 . projectIndex = = keepingProjectIndex } ? ? 0
}
private func loadMoreIfNeeded ( projectIndex : Int ) {
guard projectIndex >= projects . count - 4 ,
projects . count < totalCount ,
! isLoadingMore ,
let loadMore
else { return }
isLoadingMore = true
let currentProjectId = currentProject ? . id
let kind = currentAsset ? . kind ? ? . original
Task { [ weak self ] in
let result = await loadMore ( )
guard let self else { return }
let incoming = Self . deduplicated ( result . projects )
guard incoming . count > self . projects . count else {
self . totalCount = max ( result . totalCount , incoming . count )
self . isLoadingMore = false
return
}
self . projects = incoming
self . totalCount = max ( result . totalCount , incoming . count )
let projectIndex = currentProjectId . flatMap { id in incoming . firstIndex { $0 . id = = id } } ? ? 0
self . rebuildNodes ( keepingProjectIndex : projectIndex , kind : kind )
self . collectionView . reloadData ( )
self . collectionView . layoutIfNeeded ( )
self . setPage ( self . currentNodeIndex , animated : false )
self . isLoadingMore = false
self . updateForCurrentNode ( )
self . upgradeCurrentCellToDetailImage ( )
self . prefetchAdjacentImages ( )
}
}
private static func deduplicated ( _ projects : [ TravelAlbumPreviewProject ] ) -> [ TravelAlbumPreviewProject ] {
var seen = Set < Int > ( )
return projects . filter { seen . insert ( $0 . id ) . inserted }
}
private func toggleChrome ( ) {
chromeVisible . toggle ( )
let reduceMotion = UIAccessibility . isReduceMotionEnabled
let animations = {
self . topChrome . alpha = self . chromeVisible ? 1 : 0
self . bottomChrome . alpha = self . chromeVisible ? 1 : 0
self . topChrome . transform = self . chromeVisible
? . identity
: CGAffineTransform ( translationX : 0 , y : - self . topChrome . bounds . height )
self . bottomChrome . transform = self . chromeVisible
? . identity
: CGAffineTransform ( translationX : 0 , y : self . bottomChrome . bounds . height )
}
UIView . animate (
withDuration : reduceMotion ? 0.12 : 0.22 ,
delay : 0 ,
options : reduceMotion ? [ . curveEaseOut ] : [ . curveEaseOut , . beginFromCurrentState ] ,
animations : animations
)
}
private func performAction ( _ operation : @ escaping ( ) async -> TravelAlbumPreviewActionResult ) {
Task { [ weak self ] in
guard let self else { return }
let result = await operation ( )
switch result {
case . success ( let message ) :
if let message , ! message . isEmpty { self . showPreviewToast ( message ) }
case . failure ( let message ) , . unavailable ( let message ) :
self . showPreviewToast ( message )
}
}
}
private func showPreviewToast ( _ message : String ) {
let label = TravelAlbumPreviewToastLabel ( )
label . text = message
label . textColor = . white
label . font = . systemFont ( ofSize : 14 , weight : . medium )
label . textAlignment = . center
label . backgroundColor = UIColor ( white : 0.12 , alpha : 0.94 )
label . layer . cornerRadius = 10
label . clipsToBounds = true
label . alpha = 0
view . addSubview ( label )
label . snp . makeConstraints { make in
make . centerX . equalToSuperview ( )
make . bottom . equalTo ( view . safeAreaLayoutGuide ) . offset ( - 110 )
make . width . lessThanOrEqualToSuperview ( ) . inset ( 32 )
}
UIView . animate ( withDuration : 0.18 , animations : { label . alpha = 1 } ) { _ in
UIView . animate ( withDuration : 0.2 , delay : 1.5 , options : . curveEaseIn , animations : { label . alpha = 0 } ) { _ in
label . removeFromSuperview ( )
}
}
}
@objc private func closeTapped ( ) {
dismiss ( animated : true )
}
@objc private func aiTapped ( ) {
guard let id = currentProject ? . originalMaterialId else { return }
performAction { [ actionHandler ] in await actionHandler . requestAIRetouch ( originalMaterialId : id ) }
}
@objc private func deleteTapped ( ) {
guard let id = currentProject ? . originalMaterialId else { return }
let alert = UIAlertController (
title : " 删除整个项目 " ,
message : " 将同时删除原图及其全部关联图片,是否继续? " ,
preferredStyle : . alert
)
alert . addAction ( UIAlertAction ( title : " 取消 " , style : . cancel ) )
alert . addAction ( UIAlertAction ( title : " 删除 " , style : . destructive ) { [ weak self ] _ in
guard let self else { return }
self . performAction { [ actionHandler = self . actionHandler ] in
await actionHandler . deleteProject ( originalMaterialId : id )
}
} )
present ( alert , animated : true )
}
@objc private func refreshTapped ( ) {
guard let id = currentProject ? . originalMaterialId else { return }
performAction { [ actionHandler ] in await actionHandler . refreshProject ( originalMaterialId : id ) }
}
}
extension TravelAlbumPhotoPreviewViewController : UICollectionViewDataSource , UICollectionViewDelegateFlowLayout {
func collectionView ( _ collectionView : UICollectionView , numberOfItemsInSection section : Int ) -> Int {
nodes . count
}
func collectionView (
_ collectionView : UICollectionView ,
cellForItemAt indexPath : IndexPath
) -> UICollectionViewCell {
let cell = collectionView . dequeueReusableCell (
withReuseIdentifier : TravelAlbumPreviewImageCell . reuseIdentifier ,
for : indexPath
) as ! TravelAlbumPreviewImageCell
let node = nodes [ indexPath . item ]
let project = projects [ node . projectIndex ]
let kind = configuration . swipeMode = = . projectsOnly && indexPath . item = = currentNodeIndex
? selectedKind
: node . kind
let quality : TravelAlbumPreviewImageQuality = indexPath . item = = currentNodeIndex ? . detail : . preview
cell . apply ( asset : project . asset ( for : kind ) ? ? project . asset ( for : . original ) , quality : quality )
cell . onSingleTap = { [ weak self ] in self ? . toggleChrome ( ) }
cell . onZoomChanged = { [ weak self ] zoomed in
self ? . collectionView . isScrollEnabled = ! zoomed
}
return cell
}
func collectionView (
_ collectionView : UICollectionView ,
layout collectionViewLayout : UICollectionViewLayout ,
sizeForItemAt indexPath : IndexPath
) -> CGSize {
collectionView . bounds . size
}
func scrollViewWillBeginDragging ( _ scrollView : UIScrollView ) {
dragStartIndex = currentNodeIndex
}
func scrollViewWillEndDragging (
_ scrollView : UIScrollView ,
withVelocity velocity : CGPoint ,
targetContentOffset : UnsafeMutablePointer < CGPoint >
) {
guard configuration . swipeMode = = . includeVariants , scrollView . bounds . width > 0 else { return }
let proposed = Int ( round ( targetContentOffset . pointee . x / scrollView . bounds . width ) )
guard proposed < dragStartIndex else { return }
let target = TravelAlbumPreviewNavigator . backwardTargetIndex ( nodes : nodes , currentIndex : dragStartIndex )
targetContentOffset . pointee . x = CGFloat ( target ) * scrollView . bounds . width
}
func scrollViewDidEndDecelerating ( _ scrollView : UIScrollView ) {
updatePageFromOffset ( )
}
func scrollViewDidEndScrollingAnimation ( _ scrollView : UIScrollView ) {
updatePageFromOffset ( )
}
private func updatePageFromOffset ( ) {
guard collectionView . bounds . width > 0 else { return }
let index = Int ( round ( collectionView . contentOffset . x / collectionView . bounds . width ) )
didChangePage ( to : min ( max ( 0 , index ) , max ( 0 , nodes . count - 1 ) ) )
}
}
// / 预 览 C e l l 的 图 片 清 晰 度 层 级 ; 滑 动 时 使 用 轻 量 预 览 , 停 稳 后 升 级 为 高 清 图 。
private enum TravelAlbumPreviewImageQuality : String {
case preview
case detail
}
// / 统 一 生 成 预 览 页 图 片 处 理 参 数 , 确 保 展 示 请 求 与 预 取 请 求 共 用 缓 存 键 。
private enum TravelAlbumPreviewImageRequest {
static func previewOptions ( viewSize : CGSize ) -> KingfisherOptionsInfo {
options ( viewSize : viewSize , sizeMultiplier : 1 )
}
static func detailOptions ( viewSize : CGSize ) -> KingfisherOptionsInfo {
options ( viewSize : viewSize , sizeMultiplier : 2 )
}
private static func options ( viewSize : CGSize , sizeMultiplier : CGFloat ) -> KingfisherOptionsInfo {
let fallbackSize = UIScreen . main . bounds . size
let baseSize = viewSize . width > 0 && viewSize . height > 0 ? viewSize : fallbackSize
let targetSize = CGSize (
width : baseSize . width * sizeMultiplier ,
height : baseSize . height * sizeMultiplier
)
return [
. processor ( DownsamplingImageProcessor ( size : targetSize ) ) ,
. scaleFactor ( UIScreen . main . scale ) ,
. backgroundDecode ,
. keepCurrentImageWhileLoading ,
]
}
}
// / 预 览 图 片 C e l l , 使 用 U I S c r o l l V i e w 提 供 远 程 加 载 、 双 击 和 双 指 缩 放 。
private final class TravelAlbumPreviewImageCell : UICollectionViewCell , UIScrollViewDelegate {
static let reuseIdentifier = " TravelAlbumPreviewImageCell "
var onSingleTap : ( ( ) -> Void ) ?
var onZoomChanged : ( ( Bool ) -> Void ) ?
private let scrollView = UIScrollView ( )
private let imageView = UIImageView ( )
private let retryButton = UIButton ( type : . system )
private var asset : TravelAlbumPreviewAsset ?
private var requestKey : String ?
override init ( frame : CGRect ) {
super . init ( frame : frame )
setupUI ( )
}
@ available ( * , unavailable )
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
override func prepareForReuse ( ) {
super . prepareForReuse ( )
imageView . kf . cancelDownloadTask ( )
imageView . image = nil
asset = nil
requestKey = nil
retryButton . isHidden = true
resetZoom ( )
}
override func layoutSubviews ( ) {
super . layoutSubviews ( )
imageView . frame = scrollView . bounds
}
func apply ( asset newAsset : TravelAlbumPreviewAsset ? , quality : TravelAlbumPreviewImageQuality ) {
let urlText = quality = = . preview ? newAsset ? . previewURL : newAsset ? . displayURL
let newRequestKey = " \( quality . rawValue ) : \( urlText ? ? " " ) "
let isSameRequest = requestKey = = newRequestKey && imageView . image != nil
asset = newAsset
resetZoom ( )
if ! isSameRequest {
loadImage ( quality : quality , requestKey : newRequestKey )
}
accessibilityLabel = newAsset . map {
" \( $0 . kind . title ) , \( $0 . fileName . isEmpty ? " 未命名照片 " : $0 . fileName ) "
}
}
func resetZoom ( ) {
scrollView . setZoomScale ( 1 , animated : false )
scrollView . contentOffset = . zero
scrollView . panGestureRecognizer . isEnabled = false
onZoomChanged ? ( false )
}
func viewForZooming ( in scrollView : UIScrollView ) -> UIView ? {
imageView
}
func scrollViewDidZoom ( _ scrollView : UIScrollView ) {
centerImage ( )
let isZoomed = scrollView . zoomScale > 1.01
if scrollView . panGestureRecognizer . isEnabled != isZoomed {
scrollView . panGestureRecognizer . isEnabled = isZoomed
}
onZoomChanged ? ( isZoomed )
}
private func setupUI ( ) {
backgroundColor = . black
scrollView . backgroundColor = . black
scrollView . delegate = self
scrollView . minimumZoomScale = 1
scrollView . maximumZoomScale = 4
scrollView . showsHorizontalScrollIndicator = false
scrollView . showsVerticalScrollIndicator = false
scrollView . contentInsetAdjustmentBehavior = . never
scrollView . panGestureRecognizer . isEnabled = false
imageView . contentMode = . scaleAspectFit
imageView . backgroundColor = . black
imageView . kf . indicatorType = . activity
retryButton . setTitle ( " 图片加载失败,点击重试 " , for : . normal )
retryButton . setTitleColor ( UIColor . white . withAlphaComponent ( 0.82 ) , for : . normal )
retryButton . titleLabel ? . font = . systemFont ( ofSize : 14 , weight : . medium )
retryButton . isHidden = true
retryButton . accessibilityLabel = " 图片加载失败,重新加载 "
retryButton . addTarget ( self , action : #selector ( retryTapped ) , for : . touchUpInside )
contentView . addSubview ( scrollView )
scrollView . addSubview ( imageView )
contentView . addSubview ( retryButton )
scrollView . snp . makeConstraints { $0 . edges . equalToSuperview ( ) }
retryButton . snp . makeConstraints { $0 . center . equalToSuperview ( ) }
let singleTap = UITapGestureRecognizer ( target : self , action : #selector ( singleTapped ) )
let doubleTap = UITapGestureRecognizer ( target : self , action : #selector ( doubleTapped ( _ : ) ) )
doubleTap . numberOfTapsRequired = 2
singleTap . require ( toFail : doubleTap )
scrollView . addGestureRecognizer ( singleTap )
scrollView . addGestureRecognizer ( doubleTap )
}
private func loadImage ( quality : TravelAlbumPreviewImageQuality , requestKey : String ) {
retryButton . isHidden = true
let text = quality = = . preview ? asset ? . previewURL : asset ? . displayURL
guard let text , let url = URL ( string : text ) , ! text . isEmpty else {
imageView . image = nil
self . requestKey = nil
retryButton . isHidden = false
return
}
self . requestKey = requestKey
let options = quality = = . preview
? TravelAlbumPreviewImageRequest . previewOptions ( viewSize : contentView . bounds . size )
: TravelAlbumPreviewImageRequest . detailOptions ( viewSize : contentView . bounds . size )
imageView . kf . setImage ( with : url , options : options ) { [ weak self ] result in
guard let self , self . requestKey = = requestKey else { return }
if case . failure = result , self . imageView . image = = nil {
self . retryButton . isHidden = false
}
}
}
private func centerImage ( ) {
let horizontal = max ( 0 , ( scrollView . bounds . width - imageView . frame . width ) / 2 )
let vertical = max ( 0 , ( scrollView . bounds . height - imageView . frame . height ) / 2 )
scrollView . contentInset = UIEdgeInsets ( top : vertical , left : horizontal , bottom : vertical , right : horizontal )
}
@objc private func singleTapped ( ) {
onSingleTap ? ( )
}
@objc private func doubleTapped ( _ gesture : UITapGestureRecognizer ) {
if scrollView . zoomScale > 1.01 {
scrollView . setZoomScale ( 1 , animated : true )
return
}
let point = gesture . location ( in : imageView )
let width = scrollView . bounds . width / 2
let height = scrollView . bounds . height / 2
scrollView . zoom ( to : CGRect ( x : point . x - width / 2 , y : point . y - height / 2 , width : width , height : height ) , animated : true )
}
@objc private func retryTapped ( ) {
guard let requestKey else { return }
let quality : TravelAlbumPreviewImageQuality = requestKey . hasPrefix ( " preview: " ) ? . preview : . detail
loadImage ( quality : quality , requestKey : requestKey )
}
}
// / 带 内 边 距 的 轻 量 T o a s t 标 签 。
private final class TravelAlbumPreviewToastLabel : UILabel {
private let insets = UIEdgeInsets ( top : 10 , left : 14 , bottom : 10 , right : 14 )
override func drawText ( in rect : CGRect ) {
super . drawText ( in : rect . inset ( by : insets ) )
}
override var intrinsicContentSize : CGSize {
let size = super . intrinsicContentSize
return CGSize ( width : size . width + insets . left + insets . right , height : size . height + insets . top + insets . bottom )
}
}