//
// C o p y r i g h t ( c ) 2 0 1 8 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
import Foundation
public protocol MediaTileViewControllerDelegate : class {
func mediaTileViewController ( _ viewController : MediaTileViewController , didTapView tappedView : UIView , mediaGalleryItem : MediaGalleryItem )
}
public class MediaTileViewController : UICollectionViewController , MediaGalleryDataSourceDelegate {
private weak var mediaGalleryDataSource : MediaGalleryDataSource ?
private var galleryItems : [ GalleryDate : [ MediaGalleryItem ] ] {
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFail ( " \( logTag ) in \( #function ) mediaGalleryDataSource was unexpectedly nil " )
return [ : ]
}
return mediaGalleryDataSource . sections
}
private var galleryDates : [ GalleryDate ] {
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFail ( " \( logTag ) in \( #function ) mediaGalleryDataSource was unexpectedly nil " )
return [ ]
}
return mediaGalleryDataSource . sectionDates
}
public var focusedItem : MediaGalleryItem ?
private let uiDatabaseConnection : YapDatabaseConnection
public weak var delegate : MediaTileViewControllerDelegate ?
deinit {
Logger . debug ( " \( logTag ) deinit " )
}
fileprivate let mediaTileViewLayout : MediaTileViewLayout
init ( mediaGalleryDataSource : MediaGalleryDataSource , uiDatabaseConnection : YapDatabaseConnection ) {
self . mediaGalleryDataSource = mediaGalleryDataSource
assert ( uiDatabaseConnection . isInLongLivedReadTransaction ( ) )
self . uiDatabaseConnection = uiDatabaseConnection
// L a y o u t S e t u p
let screenWidth = UIScreen . main . bounds . size . width
let kItemsPerRow = 4
let kInterItemSpacing : CGFloat = 2
let availableWidth = screenWidth - CGFloat ( kItemsPerRow + 1 ) * kInterItemSpacing
let kItemWidth = floor ( availableWidth / CGFloat ( kItemsPerRow ) )
let layout : MediaTileViewLayout = MediaTileViewLayout ( )
layout . sectionInset = UIEdgeInsets ( top : 0 , left : 0 , bottom : 0 , right : 0 )
layout . itemSize = CGSize ( width : kItemWidth , height : kItemWidth )
layout . minimumInteritemSpacing = kInterItemSpacing
layout . minimumLineSpacing = kInterItemSpacing
layout . sectionHeadersPinToVisibleBounds = true
self . mediaTileViewLayout = layout
super . init ( collectionViewLayout : layout )
}
required public init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
// MARK: V i e w L i f e c y c l e O v e r r i d e s
override public func viewDidLoad ( ) {
super . viewDidLoad ( )
self . title = MediaStrings . allMedia
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpectedly nil " )
return
}
collectionView . backgroundColor = UIColor . white
collectionView . register ( MediaGalleryCell . self , forCellWithReuseIdentifier : MediaGalleryCell . reuseIdentifier )
collectionView . register ( MediaGallerySectionHeader . self , forSupplementaryViewOfKind : UICollectionElementKindSectionHeader , withReuseIdentifier : MediaGallerySectionHeader . reuseIdentifier )
collectionView . register ( MediaGalleryStaticHeader . self , forSupplementaryViewOfKind : UICollectionElementKindSectionHeader , withReuseIdentifier : MediaGalleryStaticHeader . reuseIdentifier )
collectionView . delegate = self
// f e e l s a b i t w e i r d t o h a v e c o n t e n t s m a s h e d a l l t h e w a y t o t h e b o t t o m e d g e .
collectionView . contentInset = UIEdgeInsets ( top : 0 , left : 0 , bottom : 20 , right : 0 )
let footerBar = UIToolbar ( )
self . footerBar = footerBar
let deleteButton = UIBarButtonItem ( barButtonSystemItem : . trash ,
target : self ,
action : #selector ( didPressDelete ) )
self . deleteButton = deleteButton
let footerItems = [
UIBarButtonItem ( barButtonSystemItem : . flexibleSpace , target : nil , action : nil ) ,
deleteButton ,
UIBarButtonItem ( barButtonSystemItem : . flexibleSpace , target : nil , action : nil )
]
footerBar . setItems ( footerItems , animated : false )
self . view . addSubview ( self . footerBar )
footerBar . barTintColor = UIColor . ows_signalBrandBlue
footerBar . autoPinWidthToSuperview ( )
footerBar . autoSetDimension ( . height , toSize : kFooterBarHeight )
self . footerBarBottomConstraint = footerBar . autoPinEdge ( toSuperviewEdge : . bottom , withInset : - kFooterBarHeight )
updateSelectButton ( )
}
private func indexPath ( galleryItem : MediaGalleryItem ) -> IndexPath ? {
guard let sectionIdx = galleryDates . index ( of : galleryItem . galleryDate ) else {
return nil
}
guard let rowIdx = galleryItems [ galleryItem . galleryDate ] ! . index ( of : galleryItem ) else {
return nil
}
return IndexPath ( row : rowIdx , section : sectionIdx + 1 )
}
override public func viewWillAppear ( _ animated : Bool ) {
super . viewWillAppear ( animated )
guard let focusedItem = self . focusedItem else {
return
}
guard let indexPath = self . indexPath ( galleryItem : focusedItem ) else {
owsFail ( " \( logTag ) unexpectedly unable to find indexPath for focusedItem: \( focusedItem ) " )
return
}
Logger . debug ( " \( logTag ) scrolling to focused item at indexPath: \( indexPath ) " )
self . view . layoutIfNeeded ( )
self . collectionView ? . scrollToItem ( at : indexPath , at : . centeredVertically , animated : false )
self . autoLoadMoreIfNecessary ( )
}
// MARK: U I C o l l e c t i o n V i e w D e l e g a t e
override public func scrollViewDidScroll ( _ scrollView : UIScrollView ) {
self . autoLoadMoreIfNecessary ( )
}
override public func scrollViewWillBeginDragging ( _ scrollView : UIScrollView ) {
self . isUserScrolling = true
}
override public func scrollViewDidEndDragging ( _ scrollView : UIScrollView , willDecelerate decelerate : Bool ) {
self . isUserScrolling = false
}
override public func collectionView ( _ collectionView : UICollectionView , shouldSelectItemAt indexPath : IndexPath ) -> Bool {
Logger . debug ( " \( self . logTag ) in \( #function ) " )
guard galleryDates . count > 0 else {
return false
}
switch indexPath . section {
case kLoadOlderSectionIdx , loadNewerSectionIdx :
return false
default :
return true
}
}
override public func collectionView ( _ collectionView : UICollectionView , shouldDeselectItemAt indexPath : IndexPath ) -> Bool {
Logger . debug ( " \( self . logTag ) in \( #function ) " )
guard galleryDates . count > 0 else {
return false
}
switch indexPath . section {
case kLoadOlderSectionIdx , loadNewerSectionIdx :
return false
default :
return true
}
}
public override func collectionView ( _ collectionView : UICollectionView , shouldHighlightItemAt indexPath : IndexPath ) -> Bool {
Logger . debug ( " \( self . logTag ) in \( #function ) " )
guard galleryDates . count > 0 else {
return false
}
switch indexPath . section {
case kLoadOlderSectionIdx , loadNewerSectionIdx :
return false
default :
return true
}
}
override public func collectionView ( _ collectionView : UICollectionView , didSelectItemAt indexPath : IndexPath ) {
Logger . debug ( " \( self . logTag ) in \( #function ) " )
guard let galleryCell = self . collectionView ( collectionView , cellForItemAt : indexPath ) as ? MediaGalleryCell else {
owsFail ( " \( logTag ) in \( #function ) galleryCell was unexpectedly nil " )
return
}
guard let galleryItem = galleryCell . item else {
owsFail ( " \( logTag ) in \( #function ) galleryItem was unexpectedly nil " )
return
}
if isInBatchSelectMode {
updateDeleteButton ( )
} else {
collectionView . deselectItem ( at : indexPath , animated : true )
self . delegate ? . mediaTileViewController ( self , didTapView : galleryCell . imageView , mediaGalleryItem : galleryItem )
}
}
public override func collectionView ( _ collectionView : UICollectionView , didDeselectItemAt indexPath : IndexPath ) {
Logger . debug ( " \( self . logTag ) in \( #function ) " )
if isInBatchSelectMode {
updateDeleteButton ( )
}
}
private var isUserScrolling : Bool = false {
didSet {
autoLoadMoreIfNecessary ( )
}
}
// MARK: U I C o l l e c t i o n V i e w D a t a S o u r c e
override public func numberOfSections ( in collectionView : UICollectionView ) -> Int {
guard galleryDates . count > 0 else {
// e m p t y g a l l e r y
return 1
}
// O n e f o r e a c h g a l l e r y D a t e p l u s a " l o a d i n g o l d e r " a n d " l o a d i n g n e w e r " s e c t i o n
return galleryItems . keys . count + 2
}
override public func collectionView ( _ collectionView : UICollectionView , numberOfItemsInSection sectionIdx : Int ) -> Int {
guard galleryDates . count > 0 else {
// e m p t y g a l l e r y
return 0
}
if sectionIdx = = kLoadOlderSectionIdx {
// l o a d o l d e r
return 0
}
if sectionIdx = = loadNewerSectionIdx {
// l o a d m o r e r e c e n t
return 0
}
guard let sectionDate = self . galleryDates [ safe : sectionIdx - 1 ] else {
owsFail ( " \( logTag ) in \( #function ) unknown section: \( sectionIdx ) " )
return 0
}
guard let section = self . galleryItems [ sectionDate ] else {
owsFail ( " \( logTag ) in \( #function ) no section for date: \( sectionDate ) " )
return 0
}
return section . count
}
override public func collectionView ( _ collectionView : UICollectionView , viewForSupplementaryElementOfKind kind : String , at indexPath : IndexPath ) -> UICollectionReusableView {
let defaultView = UICollectionReusableView ( )
guard galleryDates . count > 0 else {
guard let sectionHeader = collectionView . dequeueReusableSupplementaryView ( ofKind : kind , withReuseIdentifier : MediaGalleryStaticHeader . reuseIdentifier , for : indexPath ) as ? MediaGalleryStaticHeader else {
owsFail ( " \( logTag ) in \( #function ) unable to build section header for kLoadOlderSectionIdx " )
return defaultView
}
let title = NSLocalizedString ( " GALLERY_TILES_EMPTY_GALLERY " , comment : " Label indicating media gallery is empty " )
sectionHeader . configure ( title : title )
return sectionHeader
}
if ( kind = = UICollectionElementKindSectionHeader ) {
switch indexPath . section {
case kLoadOlderSectionIdx :
guard let sectionHeader = collectionView . dequeueReusableSupplementaryView ( ofKind : kind , withReuseIdentifier : MediaGalleryStaticHeader . reuseIdentifier , for : indexPath ) as ? MediaGalleryStaticHeader else {
owsFail ( " \( logTag ) in \( #function ) unable to build section header for kLoadOlderSectionIdx " )
return defaultView
}
let title = NSLocalizedString ( " GALLERY_TILES_LOADING_OLDER_LABEL " , comment : " Label indicating loading is in progress " )
sectionHeader . configure ( title : title )
return sectionHeader
case loadNewerSectionIdx :
guard let sectionHeader = collectionView . dequeueReusableSupplementaryView ( ofKind : kind , withReuseIdentifier : MediaGalleryStaticHeader . reuseIdentifier , for : indexPath ) as ? MediaGalleryStaticHeader else {
owsFail ( " \( logTag ) in \( #function ) unable to build section header for kLoadOlderSectionIdx " )
return defaultView
}
let title = NSLocalizedString ( " GALLERY_TILES_LOADING_MORE_RECENT_LABEL " , comment : " Label indicating loading is in progress " )
sectionHeader . configure ( title : title )
return sectionHeader
default :
guard let sectionHeader = collectionView . dequeueReusableSupplementaryView ( ofKind : kind , withReuseIdentifier : MediaGallerySectionHeader . reuseIdentifier , for : indexPath ) as ? MediaGallerySectionHeader else {
owsFail ( " \( logTag ) in \( #function ) unable to build section header for indexPath: \( indexPath ) " )
return defaultView
}
guard let date = self . galleryDates [ safe : indexPath . section - 1 ] else {
owsFail ( " \( logTag ) in \( #function ) unknown section for indexPath: \( indexPath ) " )
return defaultView
}
sectionHeader . configure ( title : date . localizedString )
return sectionHeader
}
}
return defaultView
}
override public func collectionView ( _ collectionView : UICollectionView , cellForItemAt indexPath : IndexPath ) -> UICollectionViewCell {
Logger . debug ( " \( logTag ) in \( #function ) indexPath: \( indexPath ) " )
let defaultCell = UICollectionViewCell ( )
guard galleryDates . count > 0 else {
owsFail ( " \( logTag ) in \( #function ) unexpected cell for loadNewerSectionIdx " )
return defaultCell
}
switch indexPath . section {
case kLoadOlderSectionIdx :
owsFail ( " \( logTag ) in \( #function ) unexpected cell for kLoadOlderSectionIdx " )
return defaultCell
case loadNewerSectionIdx :
owsFail ( " \( logTag ) in \( #function ) unexpected cell for loadNewerSectionIdx " )
return defaultCell
default :
guard let galleryItem = galleryItem ( at : indexPath ) else {
owsFail ( " \( logTag ) in \( #function ) no message for path: \( indexPath ) " )
return defaultCell
}
guard let cell = self . collectionView ? . dequeueReusableCell ( withReuseIdentifier : MediaGalleryCell . reuseIdentifier , for : indexPath ) as ? MediaGalleryCell else {
owsFail ( " \( logTag ) in \( #function ) unexpected cell for indexPath: \( indexPath ) " )
return defaultCell
}
cell . configure ( item : galleryItem )
return cell
}
}
func galleryItem ( at indexPath : IndexPath ) -> MediaGalleryItem ? {
guard let sectionDate = self . galleryDates [ safe : indexPath . section - 1 ] else {
owsFail ( " \( logTag ) in \( #function ) unknown section: \( indexPath . section ) " )
return nil
}
guard let sectionItems = self . galleryItems [ sectionDate ] else {
owsFail ( " \( logTag ) in \( #function ) no section for date: \( sectionDate ) " )
return nil
}
guard let galleryItem = sectionItems [ safe : indexPath . row ] else {
owsFail ( " \( logTag ) in \( #function ) no message for row: \( indexPath . row ) " )
return nil
}
return galleryItem
}
// MARK: U I C o l l e c t i o n V i e w D e l e g a t e F l o w L a y o u t
public func collectionView ( _ collectionView : UICollectionView ,
layout collectionViewLayout : UICollectionViewLayout ,
referenceSizeForHeaderInSection section : Int ) -> CGSize {
let kMonthHeaderSize : CGSize = CGSize ( width : 0 , height : 50 )
let kStaticHeaderSize : CGSize = CGSize ( width : 0 , height : 100 )
guard galleryDates . count > 0 else {
return kStaticHeaderSize
}
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFail ( " \( logTag ) in \( #function ) mediaGalleryDataSource was unexpectedly nil " )
return CGSize . zero
}
switch section {
case kLoadOlderSectionIdx :
// S h o w " l o a d i n g o l d e r . . . " i f f t h e r e i s s t i l l o l d e r d a t a t o b e f e t c h e d
return mediaGalleryDataSource . hasFetchedOldest ? CGSize . zero : kStaticHeaderSize
case loadNewerSectionIdx :
// S h o w " l o a d i n g n e w e r . . . " i f f t h e r e i s s t i l l m o r e r e c e n t d a t a t o b e f e t c h e d
return mediaGalleryDataSource . hasFetchedMostRecent ? CGSize . zero : kStaticHeaderSize
default :
return kMonthHeaderSize
}
}
// MARK: B a t c h S e l e c t i o n
var isInBatchSelectMode = false {
didSet {
collectionView ! . allowsMultipleSelection = isInBatchSelectMode
updateSelectButton ( )
updateDeleteButton ( )
}
}
func updateDeleteButton ( ) {
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpectedly nil " )
return
}
if let count = collectionView . indexPathsForSelectedItems ? . count , count > 0 {
self . deleteButton . isEnabled = true
} else {
self . deleteButton . isEnabled = false
}
}
func updateSelectButton ( ) {
if isInBatchSelectMode {
self . navigationItem . rightBarButtonItem = UIBarButtonItem ( barButtonSystemItem : . cancel , target : self , action : #selector ( didCancelSelect ) )
} else {
self . navigationItem . rightBarButtonItem = UIBarButtonItem ( title : NSLocalizedString ( " BUTTON_SELECT " , comment : " Button text to enable batch selection mode " ) ,
style : . plain ,
target : self ,
action : #selector ( didTapSelect ) )
}
}
@objc
func didTapSelect ( _ sender : Any ) {
isInBatchSelectMode = true
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpectedly nil " )
return
}
// s h o w t o o l b a r
UIView . animate ( withDuration : 0.1 , delay : 0 , options : . curveEaseInOut , animations : {
NSLayoutConstraint . deactivate ( [ self . footerBarBottomConstraint ] )
self . footerBarBottomConstraint = self . footerBar . autoPin ( toBottomLayoutGuideOf : self , withInset : 0 )
self . footerBar . superview ? . layoutIfNeeded ( )
// e n s u r e t o o l b a r d o e s n ' t c o v e r b o t t o m r o w .
collectionView . contentInset . bottom += self . kFooterBarHeight
} , completion : nil )
// d i s a b l e d u n t i l a t l e a s t o n e i t e m i s s e l e c t e d
self . deleteButton . isEnabled = false
// D o n ' t a l l o w t h e u s e r t o l e a v e m i d - s e l e c t i o n , s o t h e y r e a l i z e d t h e y h a v e
// t o c a n c e l ( l o s e ) t h e i r s e l e c t i o n i f t h e y l e a v e .
self . navigationItem . hidesBackButton = true
}
@objc
func didCancelSelect ( _ sender : Any ) {
endSelectMode ( )
}
func endSelectMode ( ) {
isInBatchSelectMode = false
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpectedly nil " )
return
}
// h i d e t o o l b a r
UIView . animate ( withDuration : 0.1 , delay : 0 , options : . curveEaseInOut , animations : {
NSLayoutConstraint . deactivate ( [ self . footerBarBottomConstraint ] )
self . footerBarBottomConstraint = self . footerBar . autoPinEdge ( toSuperviewEdge : . bottom , withInset : - self . kFooterBarHeight )
self . footerBar . superview ? . layoutIfNeeded ( )
// u n d o " e n s u r e t o o l b a r d o e s n ' t c o v e r b o t t o m r o w . "
collectionView . contentInset . bottom -= self . kFooterBarHeight
} , completion : nil )
self . navigationItem . hidesBackButton = false
// d e s e l e c t a n y s e l e c t e d
collectionView . indexPathsForSelectedItems ? . forEach { collectionView . deselectItem ( at : $0 , animated : false ) }
}
@objc
func didPressDelete ( _ sender : Any ) {
Logger . debug ( " \( self . logTag ) in \( #function ) " )
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpectedly nil " )
return
}
guard let indexPaths = collectionView . indexPathsForSelectedItems else {
owsFail ( " \( logTag ) in \( #function ) indexPaths was unexpectedly nil " )
return
}
let items : [ MediaGalleryItem ] = indexPaths . flatMap { return self . galleryItem ( at : $0 ) }
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFail ( " \( logTag ) in \( #function ) mediaGalleryDataSource was unexpectedly nil " )
return
}
let confirmationTitle : String = {
if indexPaths . count = = 1 {
return NSLocalizedString ( " MEDIA_GALLERY_DELETE_SINGLE_MESSAGE " , comment : " Confirmation button text to delete selected media message from the gallery " )
} else {
let format = NSLocalizedString ( " MEDIA_GALLERY_DELETE_MULTIPLE_MESSAGES_FORMAT " , comment : " Confirmation button text to delete selected media from the gallery, embeds {{number of messages}} " )
return String ( format : format , indexPaths . count )
}
} ( )
let deleteAction = UIAlertAction ( title : confirmationTitle , style : . destructive ) { _ in
mediaGalleryDataSource . delete ( items : items , initiatedBy : self )
self . endSelectMode ( )
}
let actionSheet = UIAlertController ( title : nil , message : nil , preferredStyle : . actionSheet )
actionSheet . addAction ( deleteAction )
actionSheet . addAction ( OWSAlerts . cancelAction )
present ( actionSheet , animated : true )
}
var footerBar : UIToolbar !
var deleteButton : UIBarButtonItem !
var footerBarBottomConstraint : NSLayoutConstraint !
let kFooterBarHeight : CGFloat = 40
// MARK: M e d i a G a l l e r y D a t a S o u r c e D e l e g a t e
func mediaGalleryDataSource ( _ mediaGalleryDataSource : MediaGalleryDataSource , willDelete items : [ MediaGalleryItem ] , initiatedBy : MediaGalleryDataSourceDelegate ) {
Logger . debug ( " \( self . logTag ) in \( #function ) " )
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpectedly nil " )
return
}
// W e ' v e g o t t o l a y o u t t h e c o l l e c t i o n V i e w b e f o r e a n y c h a n g e s a r e m a d e t o t h e d a t e s o u r c e
// o t h e r w i s e w e ' l l f a i l w h e n w e t r y t o r e m o v e t h e d e l e t e d s e c t i o n s / r o w s
collectionView . layoutIfNeeded ( )
}
func mediaGalleryDataSource ( _ mediaGalleryDataSource : MediaGalleryDataSource , deletedSections : IndexSet , deletedItems : [ IndexPath ] ) {
Logger . debug ( " \( self . logTag ) in \( #function ) with deletedSections: \( deletedSections ) deletedItems: \( deletedItems ) " )
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpetedly nil " )
return
}
guard mediaGalleryDataSource . galleryItemCount > 0 else {
// S h o w E m p t y
self . collectionView ? . reloadData ( )
return
}
collectionView . performBatchUpdates ( {
collectionView . deleteSections ( deletedSections )
collectionView . deleteItems ( at : deletedItems )
} )
}
// MARK: L a z y L o a d i n g
// T h i s s h o u l d b e s u b s t a n t i a l l y l a r g e r t h a n o n e s c r e e n s i z e s o w e d o n ' t h a v e t o c a l l i t
// m u l t i p l e t i m e s i n a r a p i d s u c c e s s i o n , b u t n o t s o l a r g e t h a t l o a d i n g g e t ' s r e a l l y c h o p p i n g
let kMediaTileViewLoadBatchSize : UInt = 40
var oldestLoadedItem : MediaGalleryItem ? {
guard let oldestDate = galleryDates . first else {
return nil
}
return galleryItems [ oldestDate ] ? . first
}
var mostRecentLoadedItem : MediaGalleryItem ? {
guard let mostRecentDate = galleryDates . last else {
return nil
}
return galleryItems [ mostRecentDate ] ? . last
}
var isFetchingMoreData : Bool = false
let kLoadOlderSectionIdx = 0
var loadNewerSectionIdx : Int {
return galleryDates . count + 1
}
public func autoLoadMoreIfNecessary ( ) {
let kEdgeThreshold : CGFloat = 800
if ( self . isUserScrolling ) {
return
}
guard let collectionView = self . collectionView else {
owsFail ( " \( logTag ) in \( #function ) collectionView was unexpectedly nil " )
return
}
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFail ( " \( logTag ) in \( #function ) mediaGalleryDataSource was unexpectedly nil " )
return
}
let contentOffsetY = collectionView . contentOffset . y
let oldContentHeight = collectionView . contentSize . height
if contentOffsetY < kEdgeThreshold {
// N e a r t h e t o p , l o a d o l d e r c o n t e n t
guard let oldestLoadedItem = self . oldestLoadedItem else {
Logger . debug ( " \( logTag ) in \( #function ) no oldest item " )
return
}
guard ! mediaGalleryDataSource . hasFetchedOldest else {
return
}
guard ! isFetchingMoreData else {
Logger . debug ( " \( logTag ) in \( #function ) already fetching more data " )
return
}
isFetchingMoreData = true
CATransaction . begin ( )
CATransaction . setDisableActions ( true )
// m e d i a T i l e V i e w L a y o u t w i l l a d j u s t c o n t e n t o f f s e t t o c o m p e n s a t e f o r t h e c h a n g e i n c o n t e n t h e i g h t s o t h a t
// t h e s a m e c o n t e n t i s v i s i b l e a f t e r t h e u p d a t e . I c o n s i d e r e d d o i n g s o m e t h i n g l i k e s e t C o n t e n t O f f s e t i n t h e
// b a t c h U p d a t e c o m p l e t i o n b l o c k , b u t i t c a u s e d a d i s t i n c t f l i c k e r , w h i c h I w a s a b l e t o a v o i d w i t h t h e
// ` C o l l e c t i o n V i e w L a y o u t . p r e p a r e ` b a s e d a p p r o a c h .
mediaTileViewLayout . isInsertingCellsToTop = true
mediaTileViewLayout . contentSizeBeforeInsertingToTop = collectionView . contentSize
collectionView . performBatchUpdates ( {
mediaGalleryDataSource . ensureGalleryItemsLoaded ( . before , item : oldestLoadedItem , amount : self . kMediaTileViewLoadBatchSize ) { addedSections , addedItems in
Logger . debug ( " \( self . logTag ) in \( #function ) insertingSections: \( addedSections ) items: \( addedItems ) " )
collectionView . insertSections ( addedSections )
collectionView . insertItems ( at : addedItems )
}
} , completion : { finished in
Logger . debug ( " \( self . logTag ) in \( #function ) performBatchUpdates finished: \( finished ) " )
self . isFetchingMoreData = false
CATransaction . commit ( )
} )
} else if oldContentHeight - contentOffsetY < kEdgeThreshold {
// N e a r t h e b o t t o m , l o a d n e w e r c o n t e n t
guard let mostRecentLoadedItem = self . mostRecentLoadedItem else {
Logger . debug ( " \( logTag ) in \( #function ) no mostRecent item " )
return
}
guard ! mediaGalleryDataSource . hasFetchedMostRecent else {
return
}
guard ! isFetchingMoreData else {
Logger . debug ( " \( logTag ) in \( #function ) already fetching more data " )
return
}
isFetchingMoreData = true
CATransaction . begin ( )
CATransaction . setDisableActions ( true )
UIView . performWithoutAnimation {
collectionView . performBatchUpdates ( {
mediaGalleryDataSource . ensureGalleryItemsLoaded ( . after , item : mostRecentLoadedItem , amount : self . kMediaTileViewLoadBatchSize ) { addedSections , addedItems in
Logger . debug ( " \( self . logTag ) in \( #function ) insertingSections: \( addedSections ) , items: \( addedItems ) " )
collectionView . insertSections ( addedSections )
collectionView . insertItems ( at : addedItems )
}
} , completion : { finished in
Logger . debug ( " \( self . logTag ) in \( #function ) performBatchUpdates finished: \( finished ) " )
self . isFetchingMoreData = false
CATransaction . commit ( )
} )
}
}
}
}
// MARK: - P r i v a t e H e l p e r C l a s s e s
// A c c o m o d a t e s r e m a i n i n g s c r o l l e d t o t h e s a m e " a p p a r e n t " p o s i t i o n w h e n n e w c o n t e n t i s i n s t e r t e d
// i n t o t h e t o p o f a c o l l e c t i o n V i e w . T h e r e a r e m u l t i p l e w a y s t o s o l v e t h i s p r o b l e m , b u t t h i s
// i s t h e o n l y o n e w h i c h a v o i d e d a p e r c e p t i b l e f l i c k e r .
private class MediaTileViewLayout : UICollectionViewFlowLayout {
fileprivate var isInsertingCellsToTop : Bool = false
fileprivate var contentSizeBeforeInsertingToTop : CGSize ?
override public func prepare ( ) {
super . prepare ( )
if isInsertingCellsToTop {
if let collectionView = collectionView , let oldContentSize = contentSizeBeforeInsertingToTop {
let newContentSize = collectionViewContentSize
let contentOffsetY = collectionView . contentOffset . y + ( newContentSize . height - oldContentSize . height )
let newOffset = CGPoint ( x : collectionView . contentOffset . x , y : contentOffsetY )
collectionView . setContentOffset ( newOffset , animated : false )
}
contentSizeBeforeInsertingToTop = nil
isInsertingCellsToTop = false
}
}
}
private class MediaGallerySectionHeader : UICollectionReusableView {
static let reuseIdentifier = " MediaGallerySectionHeader "
// H A C K : s c r o l l b a r i n c o r r e c t l y a p p e a r s * b e h i n d * s e c t i o n h e a d e r s
// i n c o l l e c t i o n v i e w o n i O S 1 1 = (
private class AlwaysOnTopLayer : CALayer {
override var zPosition : CGFloat {
get { return 0 }
set { }
}
}
let label : UILabel
override class var layerClass : AnyClass {
get {
// H A C K : s c r o l l b a r i n c o r r e c t l y a p p e a r s * b e h i n d * s e c t i o n h e a d e r s
// i n c o l l e c t i o n v i e w o n i O S 1 1 = (
if #available ( iOS 11 , * ) {
return AlwaysOnTopLayer . self
} else {
return super . layerClass
}
}
}
override init ( frame : CGRect ) {
label = UILabel ( )
let blurEffect = UIBlurEffect ( style : . extraLight )
let blurEffectView = UIVisualEffectView ( effect : blurEffect )
blurEffectView . autoresizingMask = [ . flexibleWidth , . flexibleHeight ]
super . init ( frame : frame )
self . addSubview ( blurEffectView )
self . addSubview ( label )
blurEffectView . autoPinEdgesToSuperviewEdges ( )
label . autoPinEdge ( toSuperviewEdge : . trailing )
label . autoPinEdge ( toSuperviewEdge : . leading , withInset : 10 )
label . autoVCenterInSuperview ( )
}
@ available ( * , unavailable , message : " Unimplemented " )
required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
public func configure ( title : String ) {
self . label . text = title
}
override public func prepareForReuse ( ) {
super . prepareForReuse ( )
self . label . text = nil
}
}
private class MediaGalleryStaticHeader : UICollectionViewCell {
static let reuseIdentifier = " MediaGalleryStaticHeader "
let label = UILabel ( )
override init ( frame : CGRect ) {
super . init ( frame : frame )
addSubview ( label )
label . textAlignment = . center
label . numberOfLines = 0
label . autoPinEdgesToSuperviewMargins ( )
}
@ available ( * , unavailable , message : " Unimplemented " )
required public init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
public func configure ( title : String ) {
self . label . text = title
}
public override func prepareForReuse ( ) {
self . label . text = nil
}
}
private class MediaGalleryCell : UICollectionViewCell {
static let reuseIdentifier = " MediaGalleryCell "
public let imageView : UIImageView
private let contentTypeBadgeView : UIImageView
private let selectedBadgeView : UIImageView
private let highlightedView : UIView
private let selectedView : UIView
fileprivate var item : MediaGalleryItem ?
static let videoBadgeImage = # imageLiteral ( resourceName : " ic_gallery_badge_video " )
static let animatedBadgeImage = # imageLiteral ( resourceName : " ic_gallery_badge_gif " )
static let selectedBadgeImage = # imageLiteral ( resourceName : " selected_blue_circle " )
override var isSelected : Bool {
didSet {
self . selectedBadgeView . isHidden = ! self . isSelected
self . selectedView . isHidden = ! self . isSelected
}
}
override var isHighlighted : Bool {
didSet {
self . highlightedView . isHidden = ! self . isHighlighted
}
}
override init ( frame : CGRect ) {
self . imageView = UIImageView ( )
imageView . contentMode = . scaleAspectFill
self . contentTypeBadgeView = UIImageView ( )
contentTypeBadgeView . isHidden = true
self . selectedBadgeView = UIImageView ( )
selectedBadgeView . image = MediaGalleryCell . selectedBadgeImage
selectedBadgeView . isHidden = true
self . highlightedView = UIView ( )
highlightedView . alpha = 0.2
highlightedView . backgroundColor = . black
highlightedView . isHidden = true
self . selectedView = UIView ( )
selectedView . alpha = 0.3
selectedView . backgroundColor = . white
selectedView . isHidden = true
super . init ( frame : frame )
self . clipsToBounds = true
self . contentView . addSubview ( imageView )
self . contentView . addSubview ( contentTypeBadgeView )
self . contentView . addSubview ( highlightedView )
self . contentView . addSubview ( selectedView )
self . contentView . addSubview ( selectedBadgeView )
imageView . autoPinEdgesToSuperviewEdges ( )
highlightedView . autoPinEdgesToSuperviewEdges ( )
selectedView . autoPinEdgesToSuperviewEdges ( )
// N o t e a s s e t s w e r e r e n d e r e d t o m a t c h e x a c t l y . W e d o n ' t w a n t t o r e - s i z e w i t h
// c o n t e n t m o d e l e s t t h e y b e c o m e l e s s l e g i b l e .
let kContentTypeBadgeSize = CGSize ( width : 18 , height : 12 )
contentTypeBadgeView . autoPinEdge ( toSuperviewEdge : . leading , withInset : 3 )
contentTypeBadgeView . autoPinEdge ( toSuperviewEdge : . bottom , withInset : 3 )
contentTypeBadgeView . autoSetDimensions ( to : kContentTypeBadgeSize )
let kSelectedBadgeSize = CGSize ( width : 31 , height : 31 )
selectedBadgeView . autoPinEdge ( toSuperviewEdge : . trailing , withInset : 0 )
selectedBadgeView . autoPinEdge ( toSuperviewEdge : . bottom , withInset : 0 )
selectedBadgeView . autoSetDimensions ( to : kSelectedBadgeSize )
}
@ available ( * , unavailable , message : " Unimplemented " )
required public init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
public func configure ( item : MediaGalleryItem ) {
self . item = item
self . imageView . image = item . thumbnailImage
if item . isVideo {
self . contentTypeBadgeView . isHidden = false
self . contentTypeBadgeView . image = MediaGalleryCell . videoBadgeImage
} else if item . isAnimated {
self . contentTypeBadgeView . isHidden = false
self . contentTypeBadgeView . image = MediaGalleryCell . animatedBadgeImage
} else {
assert ( item . isImage )
self . contentTypeBadgeView . isHidden = true
}
}
override public func prepareForReuse ( ) {
super . prepareForReuse ( )
self . item = nil
self . imageView . image = nil
self . contentTypeBadgeView . isHidden = true
self . highlightedView . isHidden = true
self . selectedView . isHidden = true
self . selectedBadgeView . isHidden = true
}
}