//
// C o p y r i g h t ( c ) 2 0 1 9 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
import SessionUIKit
import UIKit
public protocol MediaTileViewControllerDelegate : class {
func mediaTileViewController ( _ viewController : MediaTileViewController , didTapView tappedView : UIView , mediaGalleryItem : MediaGalleryItem )
}
public class MediaTileViewController : UICollectionViewController , MediaGalleryDataSourceDelegate , UICollectionViewDelegateFlowLayout {
private weak var mediaGalleryDataSource : MediaGalleryDataSource ?
private var galleryItems : [ GalleryDate : [ MediaGalleryItem ] ] {
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFailDebug ( " mediaGalleryDataSource was unexpectedly nil " )
return [ : ]
}
return mediaGalleryDataSource . sections
}
private var galleryDates : [ GalleryDate ] {
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFailDebug ( " mediaGalleryDataSource was unexpectedly nil " )
return [ ]
}
return mediaGalleryDataSource . sectionDates
}
public var focusedItem : MediaGalleryItem ?
private let uiDatabaseConnection : YapDatabaseConnection
public weak var delegate : MediaTileViewControllerDelegate ?
deinit {
Logger . debug ( " deinit " )
}
fileprivate let mediaTileViewLayout : MediaTileViewLayout
init ( mediaGalleryDataSource : MediaGalleryDataSource , uiDatabaseConnection : YapDatabaseConnection ) {
self . mediaGalleryDataSource = mediaGalleryDataSource
assert ( uiDatabaseConnection . isInLongLivedReadTransaction ( ) )
self . uiDatabaseConnection = uiDatabaseConnection
let layout : MediaTileViewLayout = type ( of : self ) . buildLayout ( )
self . mediaTileViewLayout = layout
super . init ( collectionViewLayout : layout )
}
required public init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
// MARK: S u b v i e w s
lazy var footerBar : UIToolbar = {
let footerBar = UIToolbar ( )
let footerItems = [
UIBarButtonItem ( barButtonSystemItem : . flexibleSpace , target : nil , action : nil ) ,
deleteButton ,
UIBarButtonItem ( barButtonSystemItem : . flexibleSpace , target : nil , action : nil )
]
footerBar . setItems ( footerItems , animated : false )
footerBar . barTintColor = Colors . navigationBarBackground
footerBar . tintColor = Colors . text
return footerBar
} ( )
lazy var deleteButton : UIBarButtonItem = {
let deleteButton = UIBarButtonItem ( barButtonSystemItem : . trash ,
target : self ,
action : #selector ( didPressDelete ) )
deleteButton . tintColor = Colors . text
return deleteButton
} ( )
// 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 ( )
ViewControllerUtilities . setUpDefaultSessionStyle ( for : self , title : MediaStrings . allMedia , hasCustomBackButton : false )
guard let collectionView = self . collectionView else {
owsFailDebug ( " collectionView was unexpectedly nil " )
return
}
collectionView . backgroundColor = Colors . navigationBarBackground
collectionView . register ( PhotoGridViewCell . self , forCellWithReuseIdentifier : PhotoGridViewCell . reuseIdentifier )
collectionView . register ( MediaGallerySectionHeader . self , forSupplementaryViewOfKind : UICollectionView . elementKindSectionHeader , withReuseIdentifier : MediaGallerySectionHeader . reuseIdentifier )
collectionView . register ( MediaGalleryStaticHeader . self , forSupplementaryViewOfKind : UICollectionView . elementKindSectionHeader , 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 )
self . view . addSubview ( self . footerBar )
footerBar . autoPinWidthToSuperview ( )
footerBar . autoSetDimension ( . height , toSize : kFooterBarHeight )
self . footerBarBottomConstraint = footerBar . autoPinEdge ( toSuperviewEdge : . bottom , withInset : - kFooterBarHeight )
updateSelectButton ( )
self . mediaTileViewLayout . invalidateLayout ( )
}
private func indexPath ( galleryItem : MediaGalleryItem ) -> IndexPath ? {
guard let sectionIdx = galleryDates . firstIndex ( of : galleryItem . galleryDate ) else {
return nil
}
guard let rowIdx = galleryItems [ galleryItem . galleryDate ] ! . firstIndex ( 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 {
owsFailDebug ( " unexpectedly unable to find indexPath for focusedItem: \( focusedItem ) " )
return
}
Logger . debug ( " scrolling to focused item at indexPath: \( indexPath ) " )
self . view . layoutIfNeeded ( )
self . collectionView ? . scrollToItem ( at : indexPath , at : . centeredVertically , animated : false )
self . autoLoadMoreIfNecessary ( )
}
override public func viewWillTransition ( to size : CGSize ,
with coordinator : UIViewControllerTransitionCoordinator ) {
self . mediaTileViewLayout . invalidateLayout ( )
}
public override func viewWillLayoutSubviews ( ) {
super . viewWillLayoutSubviews ( )
self . updateLayout ( )
}
// MARK: O r i e n t a t i o n
override public var supportedInterfaceOrientations : UIInterfaceOrientationMask {
return . allButUpsideDown
}
// 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 ( " " )
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 ( " " )
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 ( " " )
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 ( " " )
guard let gridCell = self . collectionView ( collectionView , cellForItemAt : indexPath ) as ? PhotoGridViewCell else {
owsFailDebug ( " galleryCell was unexpectedly nil " )
return
}
guard let galleryItem = ( gridCell . item as ? GalleryGridCellItem ) ? . galleryItem else {
owsFailDebug ( " galleryItem was unexpectedly nil " )
return
}
if isInBatchSelectMode {
updateDeleteButton ( )
} else {
collectionView . deselectItem ( at : indexPath , animated : true )
self . delegate ? . mediaTileViewController ( self , didTapView : gridCell . imageView , mediaGalleryItem : galleryItem )
}
}
public override func collectionView ( _ collectionView : UICollectionView , didDeselectItemAt indexPath : IndexPath ) {
Logger . debug ( " " )
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 {
owsFailDebug ( " unknown section: \( sectionIdx ) " )
return 0
}
guard let section = self . galleryItems [ sectionDate ] else {
owsFailDebug ( " 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 {
owsFailDebug ( " 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 = = UICollectionView . elementKindSectionHeader ) {
switch indexPath . section {
case kLoadOlderSectionIdx :
guard let sectionHeader = collectionView . dequeueReusableSupplementaryView ( ofKind : kind , withReuseIdentifier : MediaGalleryStaticHeader . reuseIdentifier , for : indexPath ) as ? MediaGalleryStaticHeader else {
owsFailDebug ( " 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 {
owsFailDebug ( " 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 {
owsFailDebug ( " unable to build section header for indexPath: \( indexPath ) " )
return defaultView
}
guard let date = self . galleryDates [ safe : indexPath . section - 1 ] else {
owsFailDebug ( " 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 ( " indexPath: \( indexPath ) " )
let defaultCell = UICollectionViewCell ( )
guard galleryDates . count > 0 else {
owsFailDebug ( " unexpected cell for loadNewerSectionIdx " )
return defaultCell
}
switch indexPath . section {
case kLoadOlderSectionIdx :
owsFailDebug ( " unexpected cell for kLoadOlderSectionIdx " )
return defaultCell
case loadNewerSectionIdx :
owsFailDebug ( " unexpected cell for loadNewerSectionIdx " )
return defaultCell
default :
guard let galleryItem = galleryItem ( at : indexPath ) else {
owsFailDebug ( " no message for path: \( indexPath ) " )
return defaultCell
}
guard let cell = self . collectionView ? . dequeueReusableCell ( withReuseIdentifier : PhotoGridViewCell . reuseIdentifier , for : indexPath ) as ? PhotoGridViewCell else {
owsFailDebug ( " unexpected cell for indexPath: \( indexPath ) " )
return defaultCell
}
let gridCellItem = GalleryGridCellItem ( galleryItem : galleryItem )
cell . configure ( item : gridCellItem )
return cell
}
}
func galleryItem ( at indexPath : IndexPath ) -> MediaGalleryItem ? {
guard let sectionDate = self . galleryDates [ safe : indexPath . section - 1 ] else {
owsFailDebug ( " unknown section: \( indexPath . section ) " )
return nil
}
guard let sectionItems = self . galleryItems [ sectionDate ] else {
owsFailDebug ( " no section for date: \( sectionDate ) " )
return nil
}
guard let galleryItem = sectionItems [ safe : indexPath . row ] else {
owsFailDebug ( " 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
static let kInterItemSpacing : CGFloat = 2
private class func buildLayout ( ) -> MediaTileViewLayout {
let layout = MediaTileViewLayout ( )
if #available ( iOS 11 , * ) {
layout . sectionInsetReference = . fromSafeArea
}
layout . minimumInteritemSpacing = kInterItemSpacing
layout . minimumLineSpacing = kInterItemSpacing
layout . sectionHeadersPinToVisibleBounds = true
return layout
}
func updateLayout ( ) {
let containerWidth : CGFloat
if #available ( iOS 11.0 , * ) {
containerWidth = self . view . safeAreaLayoutGuide . layoutFrame . size . width
} else {
containerWidth = self . view . frame . size . width
}
let kItemsPerPortraitRow = 4
let screenWidth = min ( UIScreen . main . bounds . width , UIScreen . main . bounds . height )
let approxItemWidth = screenWidth / CGFloat ( kItemsPerPortraitRow )
let itemCount = round ( containerWidth / approxItemWidth )
let spaceWidth = ( itemCount + 1 ) * type ( of : self ) . kInterItemSpacing
let availableWidth = containerWidth - spaceWidth
let itemWidth = floor ( availableWidth / CGFloat ( itemCount ) )
let newItemSize = CGSize ( width : itemWidth , height : itemWidth )
if ( newItemSize != mediaTileViewLayout . itemSize ) {
mediaTileViewLayout . itemSize = newItemSize
mediaTileViewLayout . invalidateLayout ( )
}
}
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 {
owsFailDebug ( " 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 {
owsFailDebug ( " 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 {
owsFailDebug ( " 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 . autoPinEdge ( toSuperviewSafeArea : . bottom )
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 {
owsFailDebug ( " 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 ( " " )
guard let collectionView = self . collectionView else {
owsFailDebug ( " collectionView was unexpectedly nil " )
return
}
guard let indexPaths = collectionView . indexPathsForSelectedItems else {
owsFailDebug ( " indexPaths was unexpectedly nil " )
return
}
let items : [ MediaGalleryItem ] = indexPaths . compactMap { return self . galleryItem ( at : $0 ) }
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFailDebug ( " 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 )
presentAlert ( actionSheet )
}
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 : AnyObject ) {
Logger . debug ( " " )
guard let collectionView = self . collectionView else {
owsFailDebug ( " 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 ( " with deletedSections: \( deletedSections ) deletedItems: \( deletedItems ) " )
guard let collectionView = self . collectionView else {
owsFailDebug ( " collectionView was unexpectedly 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 {
owsFailDebug ( " collectionView was unexpectedly nil " )
return
}
guard let mediaGalleryDataSource = self . mediaGalleryDataSource else {
owsFailDebug ( " 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 ( " no oldest item " )
return
}
guard ! mediaGalleryDataSource . hasFetchedOldest else {
return
}
guard ! isFetchingMoreData else {
Logger . debug ( " 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 ( " insertingSections: \( addedSections ) items: \( addedItems ) " )
collectionView . insertSections ( addedSections )
collectionView . insertItems ( at : addedItems )
}
} , completion : { finished in
Logger . debug ( " 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 ( " no mostRecent item " )
return
}
guard ! mediaGalleryDataSource . hasFetchedMostRecent else {
return
}
guard ! isFetchingMoreData else {
Logger . debug ( " 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 ( " insertingSections: \( addedSections ) , items: \( addedItems ) " )
collectionView . insertSections ( addedSections )
collectionView . insertItems ( at : addedItems )
}
} , completion : { finished in
Logger . debug ( " 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 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 ( )
label . textColor = Colors . text
let blurEffect = UIBlurEffect ( style : . dark )
let blurEffectView = UIVisualEffectView ( effect : blurEffect )
blurEffectView . autoresizingMask = [ . flexibleWidth , . flexibleHeight ]
super . init ( frame : frame )
self . backgroundColor = isLightMode ? Colors . cellBackground : UIColor . ows_black . withAlphaComponent ( OWSNavigationBar . backgroundBlurMutingFactor )
self . addSubview ( blurEffectView )
self . addSubview ( label )
blurEffectView . autoPinEdgesToSuperviewEdges ( )
blurEffectView . isHidden = isLightMode
label . autoPinEdge ( toSuperviewMargin : . trailing )
label . autoPinEdge ( toSuperviewMargin : . leading )
label . autoVCenterInSuperview ( )
}
@ available ( * , unavailable , message : " Unimplemented " )
required init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
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 . textColor = Colors . text
label . textAlignment = . center
label . numberOfLines = 0
label . autoPinEdgesToSuperviewMargins ( with : UIEdgeInsets ( top : 0 , leading : Values . largeSpacing , bottom : 0 , trailing : Values . largeSpacing ) )
}
@ available ( * , unavailable , message : " Unimplemented " )
required public init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
public func configure ( title : String ) {
self . label . text = title
}
public override func prepareForReuse ( ) {
self . label . text = nil
}
}
class GalleryGridCellItem : PhotoGridItem {
let galleryItem : MediaGalleryItem
init ( galleryItem : MediaGalleryItem ) {
self . galleryItem = galleryItem
}
var type : PhotoGridItemType {
if galleryItem . isVideo {
return . video
} else if galleryItem . isAnimated {
return . animated
} else {
return . photo
}
}
func asyncThumbnail ( completion : @ escaping ( UIImage ? ) -> Void ) -> UIImage ? {
return galleryItem . thumbnailImage ( async : completion )
}
}