session-ios/Signal/src/ViewControllers/MediaTileViewController.swift

961 lines
36 KiB
Swift
Raw Normal View History

//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
public protocol MediaTileViewControllerDelegate: class {
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem)
}
2018-03-23 15:00:07 +01:00
public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate {
2018-03-19 18:44:59 +01:00
private weak var mediaGalleryDataSource: MediaGalleryDataSource?
private var galleryItems: [GalleryDate: [MediaGalleryItem]] {
2018-03-19 18:44:59 +01:00
guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
owsFail("\(logTag) in \(#function) mediaGalleryDataSource was unexpectedly nil")
return [:]
}
return mediaGalleryDataSource.sections
}
private var galleryDates: [GalleryDate] {
2018-03-19 18:44:59 +01:00
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?
2018-03-19 18:44:59 +01:00
deinit {
Logger.debug("\(logTag) deinit")
}
fileprivate let mediaTileViewLayout: MediaTileViewLayout
init(mediaGalleryDataSource: MediaGalleryDataSource, uiDatabaseConnection: YapDatabaseConnection) {
self.mediaGalleryDataSource = mediaGalleryDataSource
assert(uiDatabaseConnection.isInLongLivedReadTransaction())
self.uiDatabaseConnection = uiDatabaseConnection
// Layout Setup
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: View Lifecycle Overrides
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)
2018-03-20 23:12:23 +01:00
collectionView.register(MediaGalleryStaticHeader.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier)
collectionView.delegate = self
// feels a bit weird to have content smashed all the way to the bottom edge.
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0)
2018-03-23 15:00:07 +01:00
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()
2018-03-23 20:59:56 +01:00
footerBar.autoSetDimension(.height, toSize: kFooterBarHeight)
self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -kFooterBarHeight)
2018-03-23 15:00:07 +01:00
updateSelectButton()
self.view.layoutIfNeeded()
scrollToBottom(animated: false)
}
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.collectionView?.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
}
2018-03-23 15:00:07 +01:00
// MARK: UICollectionViewDelegate
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
}
2018-03-23 15:00:07 +01:00
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
}
}
2018-03-23 15:00:07 +01:00
override public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool {
2018-03-23 15:00:07 +01:00
Logger.debug("\(self.logTag) in \(#function)")
guard galleryDates.count > 0 else {
return false
}
2018-03-23 15:00:07 +01:00
switch indexPath.section {
case kLoadOlderSectionIdx, loadNewerSectionIdx:
return false
default:
return true
}
}
2018-03-23 15:00:07 +01:00
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
}
2018-03-23 15:00:07 +01:00
guard let galleryItem = galleryCell.item else {
owsFail("\(logTag) in \(#function) galleryItem was unexpectedly nil")
return
}
2018-03-23 15:00:07 +01:00
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: UICollectionViewDataSource
override public func numberOfSections(in collectionView: UICollectionView) -> Int {
2018-03-20 23:12:23 +01:00
guard galleryDates.count > 0 else {
// empty gallery
return 1
}
// One for each galleryDate plus a "loading older" and "loading newer" section
return galleryItems.keys.count + 2
}
override public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int {
2018-03-20 23:12:23 +01:00
guard galleryDates.count > 0 else {
// empty gallery
return 0
}
if sectionIdx == kLoadOlderSectionIdx {
// load older
return 0
}
if sectionIdx == loadNewerSectionIdx {
// load more recent
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()
2018-03-20 23:12:23 +01:00
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:
2018-03-20 23:12:23 +01:00
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
}
2018-03-19 19:05:43 +01:00
let title = NSLocalizedString("GALLERY_TILES_LOADING_OLDER_LABEL", comment: "Label indicating loading is in progress")
sectionHeader.configure(title: title)
return sectionHeader
case loadNewerSectionIdx:
2018-03-20 23:12:23 +01:00
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
}
2018-03-19 19:05:43 +01:00
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()
2018-03-20 23:12:23 +01:00
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:
2018-03-23 15:00:07 +01:00
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
}
2018-03-23 15:00:07 +01:00
cell.configure(item: galleryItem)
return cell
}
}
2018-03-23 15:00:07 +01:00
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: UICollectionViewDelegateFlowLayout
public func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize {
2018-03-20 23:12:23 +01:00
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:
// Show "loading older..." iff there is still older data to be fetched
2018-03-20 23:12:23 +01:00
return mediaGalleryDataSource.hasFetchedOldest ? CGSize.zero : kStaticHeaderSize
case loadNewerSectionIdx:
// Show "loading newer..." iff there is still more recent data to be fetched
2018-03-20 23:12:23 +01:00
return mediaGalleryDataSource.hasFetchedMostRecent ? CGSize.zero : kStaticHeaderSize
default:
2018-03-20 23:12:23 +01:00
return kMonthHeaderSize
}
}
2018-03-23 15:00:07 +01:00
// MARK: Batch Selection
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
2018-03-23 20:59:56 +01:00
guard let collectionView = self.collectionView else {
owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil")
return
}
2018-03-23 15:00:07 +01:00
// show toolbar
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: {
2018-03-23 20:59:56 +01:00
NSLayoutConstraint.deactivate([self.footerBarBottomConstraint])
self.footerBarBottomConstraint = self.footerBar.autoPin(toBottomLayoutGuideOf: self, withInset: 0)
2018-03-23 15:00:07 +01:00
self.footerBar.superview?.layoutIfNeeded()
2018-03-23 20:59:56 +01:00
// ensure toolbar doesn't cover bottom row.
collectionView.contentInset.bottom += self.kFooterBarHeight
2018-03-23 15:00:07 +01:00
}, completion: nil)
// disabled until at least one item is selected
self.deleteButton.isEnabled = false
// Don't allow the user to leave mid-selection, so they realized they have
// to cancel (lose) their selection if they leave.
self.navigationItem.hidesBackButton = true
}
@objc
func didCancelSelect(_ sender: Any) {
endSelectMode()
}
func endSelectMode() {
2018-03-23 15:00:07 +01:00
isInBatchSelectMode = false
2018-03-23 20:59:56 +01:00
guard let collectionView = self.collectionView else {
owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil")
return
}
2018-03-23 15:00:07 +01:00
// hide toolbar
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: {
2018-03-23 20:59:56 +01:00
NSLayoutConstraint.deactivate([self.footerBarBottomConstraint])
self.footerBarBottomConstraint = self.footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -self.kFooterBarHeight)
2018-03-23 15:00:07 +01:00
self.footerBar.superview?.layoutIfNeeded()
2018-03-23 20:59:56 +01:00
// undo "ensure toolbar doesn't cover bottom row."
collectionView.contentInset.bottom -= self.kFooterBarHeight
2018-03-23 15:00:07 +01:00
}, completion: nil)
self.navigationItem.hidesBackButton = false
// deselect any selected
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)
self.endSelectMode()
2018-03-23 15:00:07 +01:00
}
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!
2018-03-23 20:59:56 +01:00
let kFooterBarHeight: CGFloat = 40
2018-03-23 15:00:07 +01:00
// MARK: MediaGalleryDataSourceDelegate
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem]) {
Logger.debug("\(self.logTag) in \(#function)")
guard let collectionView = self.collectionView else {
owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil")
return
}
// We've got to lay out the collectionView before any changes are made to the date source
// otherwise we'll fail when we try to remove the deleted sections/rows
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 {
// Show Empty
self.collectionView?.reloadData()
return
}
collectionView.performBatchUpdates({
collectionView.deleteSections(deletedSections)
collectionView.deleteItems(at: deletedItems)
})
}
// MARK: Lazy Loading
// This should be substantially larger than one screen size so we don't have to call it
// multiple times in a rapid succession, but not so large that loading get's really chopping
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
}
2018-03-19 18:44:59 +01:00
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 {
// Near the top, load older content
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)
// mediaTileViewLayout will adjust content offset to compensate for the change in content height so that
// the same content is visible after the update. I considered doing something like setContentOffset in the
// batchUpdate completion block, but it caused a distinct flicker, which I was able to avoid with the
// `CollectionViewLayout.prepare` based approach.
mediaTileViewLayout.isInsertingCellsToTop = true
mediaTileViewLayout.contentSizeBeforeInsertingToTop = collectionView.contentSize
collectionView.performBatchUpdates({
2018-03-19 18:44:59 +01:00
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 {
// Near the bottom, load newer content
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: Util
private func scrollToBottom(animated isAnimated: Bool) {
guard let collectionView = self.collectionView else {
owsFail("\(self.logTag) in \(#function) collectionView was unexpectedly nil")
return
}
let yOffset: CGFloat = collectionView.contentSize.height - collectionView.bounds.size.height + collectionView.contentInset.bottom
let offset: CGPoint = CGPoint(x: 0, y: yOffset)
collectionView.setContentOffset(offset, animated: isAnimated)
}
}
// MARK: - Private Helper Classes
// Accomodates remaining scrolled to the same "apparent" position when new content is insterted
// into the top of a collectionView. There are multiple ways to solve this problem, but this
// is the only one which avoided a perceptible flicker.
fileprivate 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
}
}
}
fileprivate class MediaGallerySectionHeader: UICollectionReusableView {
static let reuseIdentifier = "MediaGallerySectionHeader"
// HACK: scrollbar incorrectly appears *behind* section headers
// in collection view on iOS11 =(
private class AlwaysOnTopLayer: CALayer {
override var zPosition: CGFloat {
get { return 0 }
set {}
}
}
let label: UILabel
override class var layerClass: AnyClass {
get {
// HACK: scrollbar incorrectly appears *behind* section headers
// in collection view on iOS11 =(
if #available(iOS 11, *) {
return AlwaysOnTopLayer.self
} else {
return super.layerClass
}
}
}
override init(frame: CGRect) {
label = UILabel()
2018-03-19 20:43:36 +01:00
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
}
}
2018-03-20 23:12:23 +01:00
fileprivate class MediaGalleryStaticHeader: UICollectionViewCell {
2018-03-20 23:12:23 +01:00
static let reuseIdentifier = "MediaGalleryStaticHeader"
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(label)
2018-03-20 23:12:23 +01:00
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
}
}
fileprivate class MediaGalleryCell: UICollectionViewCell {
static let reuseIdentifier = "MediaGalleryCell"
public let imageView: UIImageView
2018-03-23 15:00:07 +01:00
private let contentTypeBadgeView: UIImageView
private let selectedBadgeView: UIImageView
2018-03-20 17:05:23 +01:00
2018-03-23 21:06:39 +01:00
private let highlightedView: UIView
private let selectedView: UIView
2018-03-23 15:00:07 +01:00
fileprivate var item: MediaGalleryItem?
2018-03-20 22:26:55 +01:00
static let videoBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_video")
static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif")
2018-03-23 15:00:07 +01:00
static let selectedBadgeImage = #imageLiteral(resourceName: "selected_blue_circle")
override var isSelected: Bool {
didSet {
self.selectedBadgeView.isHidden = !self.isSelected
2018-03-23 21:06:39 +01:00
self.selectedView.isHidden = !self.isSelected
2018-03-23 15:00:07 +01:00
}
}
override var isHighlighted: Bool {
didSet {
2018-03-23 21:06:39 +01:00
self.highlightedView.isHidden = !self.isHighlighted
2018-03-23 15:00:07 +01:00
}
}
2018-03-20 17:05:23 +01:00
override init(frame: CGRect) {
self.imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
2018-03-23 15:00:07 +01:00
self.contentTypeBadgeView = UIImageView()
contentTypeBadgeView.isHidden = true
2018-03-20 17:05:23 +01:00
2018-03-23 15:00:07 +01:00
self.selectedBadgeView = UIImageView()
selectedBadgeView.image = MediaGalleryCell.selectedBadgeImage
selectedBadgeView.isHidden = true
2018-03-23 21:06:39 +01:00
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
2018-03-23 15:00:07 +01:00
super.init(frame: frame)
self.clipsToBounds = true
2018-03-23 15:00:07 +01:00
2018-03-20 17:05:23 +01:00
self.contentView.addSubview(imageView)
2018-03-23 15:00:07 +01:00
self.contentView.addSubview(contentTypeBadgeView)
2018-03-23 21:06:39 +01:00
self.contentView.addSubview(highlightedView)
self.contentView.addSubview(selectedView)
2018-03-23 15:00:07 +01:00
self.contentView.addSubview(selectedBadgeView)
2018-03-20 22:26:55 +01:00
imageView.autoPinEdgesToSuperviewEdges()
2018-03-23 21:06:39 +01:00
highlightedView.autoPinEdgesToSuperviewEdges()
selectedView.autoPinEdgesToSuperviewEdges()
2018-03-20 22:26:55 +01:00
// Note assets were rendered to match exactly. We don't want to re-size with
// content mode lest they become less legible.
2018-03-23 15:00:07 +01:00
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")
}
2018-03-23 15:00:07 +01:00
public func configure(item: MediaGalleryItem) {
self.item = item
2018-03-19 02:57:05 +01:00
self.imageView.image = item.thumbnailImage
2018-03-20 17:05:23 +01:00
if item.isVideo {
2018-03-23 15:00:07 +01:00
self.contentTypeBadgeView.isHidden = false
self.contentTypeBadgeView.image = MediaGalleryCell.videoBadgeImage
2018-03-20 17:05:23 +01:00
} else if item.isAnimated {
2018-03-23 15:00:07 +01:00
self.contentTypeBadgeView.isHidden = false
self.contentTypeBadgeView.image = MediaGalleryCell.animatedBadgeImage
2018-03-20 17:05:23 +01:00
} else {
assert(item.isImage)
2018-03-23 15:00:07 +01:00
self.contentTypeBadgeView.isHidden = true
2018-03-20 17:05:23 +01:00
}
}
override public func prepareForReuse() {
super.prepareForReuse()
self.item = nil
self.imageView.image = nil
2018-03-23 15:00:07 +01:00
self.contentTypeBadgeView.isHidden = true
2018-03-23 21:06:39 +01:00
self.highlightedView.isHidden = true
self.selectedView.isHidden = true
2018-03-23 15:00:07 +01:00
self.selectedBadgeView.isHidden = true
}
}