1003 lines
39 KiB
Swift
1003 lines
39 KiB
Swift
//
|
|
// Copyright 2018 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import SignalMessaging
|
|
|
|
fileprivate extension IndexSet {
|
|
func shifted(startingAt index: Int? = nil, by amount: Int) -> IndexSet {
|
|
var result = self
|
|
result.shift(startingAt: index ?? self.first ?? 0, by: amount)
|
|
return result
|
|
}
|
|
}
|
|
|
|
private extension IndexPath {
|
|
func shiftingSection(by delta: Int) -> IndexPath {
|
|
return IndexPath(item: item, section: section + delta)
|
|
}
|
|
}
|
|
|
|
extension MediaTileViewController: MediaGalleryCollectionViewUpdaterDelegate {
|
|
func updaterDeleteSections(_ sections: IndexSet) {
|
|
collectionView?.deleteSections(sections.shifted(by: 1))
|
|
}
|
|
|
|
func updaterDeleteItems(at indexPaths: [IndexPath]) {
|
|
collectionView?.deleteItems(at: indexPaths.map {
|
|
$0.shiftingSection(by: 1)
|
|
})
|
|
}
|
|
|
|
func updaterInsertSections(_ sections: IndexSet) {
|
|
collectionView?.insertSections(sections.shifted(by: 1))
|
|
}
|
|
|
|
func updaterReloadItems(at indexPaths: [IndexPath]) {
|
|
collectionView?.reloadItems(at: indexPaths.map {
|
|
$0.shiftingSection(by: 1)
|
|
})
|
|
}
|
|
|
|
func updaterReloadSections(_ sections: IndexSet) {
|
|
collectionView?.reloadSections(sections.shifted(by: 1))
|
|
}
|
|
|
|
func updaterDidFinish(numberOfSectionsBefore: Int, numberOfSectionsAfter: Int) {
|
|
Logger.debug("\(numberOfSectionsBefore) -> \(numberOfSectionsAfter)")
|
|
owsAssert(numberOfSectionsAfter == mediaGallery.galleryDates.count)
|
|
if numberOfSectionsBefore == 0 && numberOfSectionsAfter > 0 {
|
|
// Adding a "load newer" section. It goes at the end.
|
|
collectionView?.insertSections(IndexSet(integer: numberOfSectionsAfter + 1))
|
|
} else if numberOfSectionsBefore > 0 && numberOfSectionsAfter == 0 {
|
|
// Remove "load newer" section from the end.
|
|
collectionView?.deleteSections(IndexSet(integer: 1))
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
@objc
|
|
public class MediaTileViewController: UICollectionViewController, MediaGalleryDelegate, UICollectionViewDelegateFlowLayout {
|
|
private let thread: TSThread
|
|
private lazy var mediaGallery: MediaGallery = {
|
|
let mediaGallery = MediaGallery(thread: thread)
|
|
mediaGallery.addDelegate(self)
|
|
return mediaGallery
|
|
}()
|
|
fileprivate let mediaTileViewLayout: MediaTileViewLayout
|
|
|
|
/// This is used to avoid running two animations concurrently. It doesn't look good on iOS 16 (and probably all other versions).
|
|
private var activeAnimationCount = 0
|
|
|
|
@objc
|
|
public init(thread: TSThread) {
|
|
self.thread = thread
|
|
let layout: MediaTileViewLayout = type(of: self).buildLayout()
|
|
self.mediaTileViewLayout = layout
|
|
super.init(collectionViewLayout: layout)
|
|
}
|
|
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: Subviews
|
|
|
|
lazy var footerBar: UIToolbar = {
|
|
let footerBar = UIToolbar()
|
|
let footerItems = [
|
|
shareButton,
|
|
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
|
deleteButton
|
|
]
|
|
footerBar.setItems(footerItems, animated: false)
|
|
|
|
return footerBar
|
|
}()
|
|
|
|
lazy var deleteButton: UIBarButtonItem = {
|
|
let deleteButton = UIBarButtonItem(barButtonSystemItem: .trash,
|
|
target: self,
|
|
action: #selector(didPressDelete),
|
|
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "delete_button"))
|
|
|
|
return deleteButton
|
|
}()
|
|
|
|
lazy var shareButton: UIBarButtonItem = {
|
|
let shareButton = UIBarButtonItem(barButtonSystemItem: .action,
|
|
target: self,
|
|
action: #selector(didPressShare),
|
|
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "share_button"))
|
|
return shareButton
|
|
}()
|
|
|
|
// MARK: View Lifecycle Overrides
|
|
|
|
override public func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
self.title = MediaStrings.allMedia
|
|
|
|
guard let collectionView = self.collectionView else {
|
|
owsFailDebug("collectionView was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
|
|
collectionView.register(ThemeCollectionViewSectionHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ThemeCollectionViewSectionHeader.reuseIdentifier)
|
|
collectionView.register(MediaGalleryStaticHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, 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)
|
|
|
|
self.view.addSubview(self.footerBar)
|
|
footerBar.autoPinWidthToSuperview()
|
|
footerBar.autoSetDimension(.height, toSize: kFooterBarHeight)
|
|
self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -kFooterBarHeight)
|
|
|
|
updateSelectButton()
|
|
|
|
self.mediaTileViewLayout.invalidateLayout()
|
|
|
|
applyTheme()
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(applyTheme), name: .ThemeDidChange, object: nil)
|
|
}
|
|
|
|
override public func viewWillAppear(_ animated: Bool) {
|
|
defer { super.viewWillAppear(animated) }
|
|
|
|
// You must layout before mutating mediaGallery. Otherwise, MediaTileViewLayout.prepare() could be
|
|
// called while processing an update. That will violate the exclusive access rules since it calls
|
|
// (indirectly) into MediaGallerySections.
|
|
self.view.layoutIfNeeded()
|
|
|
|
if mediaGallery.galleryDates.isEmpty {
|
|
_ = self.mediaGallery.loadEarlierSections(batchSize: kLoadBatchSize)
|
|
if mediaGallery.galleryDates.isEmpty {
|
|
// There must be no media.
|
|
return
|
|
}
|
|
eagerlyLoadMoreIfPossible()
|
|
}
|
|
|
|
let lastSectionItemCount = mediaGallery.numberOfItemsInSection(mediaGallery.galleryDates.count - 1)
|
|
self.collectionView.scrollToItem(at: IndexPath(item: lastSectionItemCount - 1,
|
|
section: mediaGallery.galleryDates.count),
|
|
at: .bottom,
|
|
animated: false)
|
|
}
|
|
|
|
override public func viewWillTransition(to size: CGSize,
|
|
with coordinator: UIViewControllerTransitionCoordinator) {
|
|
self.mediaTileViewLayout.invalidateLayout()
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
}
|
|
|
|
public override func viewWillLayoutSubviews() {
|
|
super.viewWillLayoutSubviews()
|
|
self.updateLayout()
|
|
}
|
|
|
|
// MARK: Theme
|
|
|
|
@objc
|
|
func applyTheme() {
|
|
footerBar.barTintColor = Theme.navbarBackgroundColor
|
|
footerBar.tintColor = Theme.primaryIconColor
|
|
|
|
deleteButton.tintColor = Theme.primaryIconColor
|
|
shareButton.tintColor = Theme.primaryIconColor
|
|
|
|
collectionView.backgroundColor = Theme.backgroundColor
|
|
}
|
|
|
|
// MARK: Orientation
|
|
|
|
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
return UIDevice.current.defaultSupportedOrientations
|
|
}
|
|
|
|
// MARK: UICollectionViewDelegate
|
|
|
|
override public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
self.autoLoadMoreIfNecessary()
|
|
}
|
|
|
|
var previousAdjustedContentInset: UIEdgeInsets = UIEdgeInsets()
|
|
|
|
override public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
|
|
defer { previousAdjustedContentInset = scrollView.adjustedContentInset }
|
|
guard !mediaGallery.galleryDates.isEmpty else {
|
|
return
|
|
}
|
|
|
|
if scrollView.contentSize.height > scrollView.bounds.height - scrollView.adjustedContentInset.totalHeight {
|
|
// Were we pinned to the bottom before? If so, scroll back down.
|
|
let dy = scrollView.adjustedContentInset.totalHeight - previousAdjustedContentInset.totalHeight
|
|
if scrollView.contentOffset.y + dy + scrollView.bounds.height >= scrollView.contentSize.height {
|
|
scrollView.contentOffset.y =
|
|
scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
|
|
|
Logger.debug("")
|
|
|
|
guard !mediaGallery.galleryDates.isEmpty 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 !mediaGallery.galleryDates.isEmpty 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 !mediaGallery.galleryDates.isEmpty else {
|
|
return false
|
|
}
|
|
|
|
switch indexPath.section {
|
|
case kLoadOlderSectionIdx, loadNewerSectionIdx:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
override public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
Logger.info("")
|
|
|
|
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()
|
|
updateShareButton()
|
|
} else {
|
|
collectionView.deselectItem(at: indexPath, animated: true)
|
|
|
|
let pageVC = MediaPageViewController(
|
|
initialMediaAttachment: galleryItem.attachmentStream,
|
|
mediaGallery: mediaGallery
|
|
)
|
|
present(pageVC, animated: true)
|
|
}
|
|
}
|
|
|
|
public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
|
|
Logger.debug("")
|
|
|
|
if isInBatchSelectMode {
|
|
updateDeleteButton()
|
|
updateShareButton()
|
|
}
|
|
}
|
|
|
|
// MARK: UICollectionViewDataSource
|
|
|
|
override public func numberOfSections(in collectionView: UICollectionView) -> Int {
|
|
Logger.debug("")
|
|
|
|
let dates = mediaGallery.galleryDates
|
|
guard !dates.isEmpty else {
|
|
// empty gallery
|
|
Logger.debug("No gallery dates - return 1")
|
|
return 1
|
|
}
|
|
|
|
// One for each galleryDate plus a "loading older" and "loading newer" section
|
|
let count = dates.count
|
|
Logger.debug("\(count) gallery dates - return \(count + 2)")
|
|
return count + 2
|
|
}
|
|
|
|
override public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int {
|
|
guard !mediaGallery.galleryDates.isEmpty else {
|
|
// empty gallery
|
|
return 0
|
|
}
|
|
|
|
if sectionIdx == kLoadOlderSectionIdx {
|
|
// load older
|
|
return 0
|
|
}
|
|
|
|
if sectionIdx == loadNewerSectionIdx {
|
|
// load more recent
|
|
return 0
|
|
}
|
|
|
|
let count = mediaGallery.numberOfItemsInSection(sectionIdx - 1)
|
|
return count
|
|
}
|
|
|
|
override public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
|
|
|
let defaultView = UICollectionReusableView()
|
|
|
|
guard !mediaGallery.galleryDates.isEmpty 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: ThemeCollectionViewSectionHeader.reuseIdentifier, for: indexPath) as? ThemeCollectionViewSectionHeader else {
|
|
owsFailDebug("unable to build section header for indexPath: \(indexPath)")
|
|
return defaultView
|
|
}
|
|
guard let date = mediaGallery.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)")
|
|
|
|
guard let cell = self.collectionView?.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
|
|
owsFailDebug("unexpected cell for indexPath: \(indexPath)")
|
|
return UICollectionViewCell()
|
|
}
|
|
|
|
guard !mediaGallery.galleryDates.isEmpty else {
|
|
owsFailDebug("unexpected cell for loadNewerSectionIdx")
|
|
cell.makePlaceholder()
|
|
return cell
|
|
}
|
|
|
|
switch indexPath.section {
|
|
case kLoadOlderSectionIdx:
|
|
owsFailDebug("unexpected cell for kLoadOlderSectionIdx")
|
|
cell.makePlaceholder()
|
|
case loadNewerSectionIdx:
|
|
owsFailDebug("unexpected cell for loadNewerSectionIdx")
|
|
cell.makePlaceholder()
|
|
default:
|
|
// Loading must be done asynchronously because this can be called while applying a
|
|
// pending update. Attempting to mutate MediaGallerySections synchronously could cause a
|
|
// deadlock.
|
|
guard let galleryItem = galleryItem(at: indexPath, loadAsync: true) else {
|
|
Logger.debug("Using placeholder for unloaded path \(indexPath)")
|
|
cell.makePlaceholder()
|
|
break
|
|
}
|
|
|
|
let gridCellItem = GalleryGridCellItem(galleryItem: galleryItem)
|
|
cell.configure(item: gridCellItem)
|
|
}
|
|
return cell
|
|
}
|
|
|
|
override public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
|
guard let photoGridViewCell = cell as? PhotoGridViewCell else {
|
|
owsFailDebug("unexpected cell: \(cell)")
|
|
return
|
|
}
|
|
|
|
photoGridViewCell.allowsMultipleSelection = collectionView.allowsMultipleSelection
|
|
}
|
|
|
|
func galleryItem(at indexPath: IndexPath, loadAsync: Bool = false) -> MediaGalleryItem? {
|
|
var underlyingPath = indexPath
|
|
underlyingPath.section -= 1
|
|
if let loadedGalleryItem = mediaGallery.galleryItem(at: underlyingPath) {
|
|
return loadedGalleryItem
|
|
}
|
|
|
|
mediaGallery.ensureGalleryItemsLoaded(.after,
|
|
sectionIndex: underlyingPath.section,
|
|
itemIndex: underlyingPath.item,
|
|
amount: kLoadBatchSize,
|
|
shouldLoadAlbumRemainder: false,
|
|
async: loadAsync,
|
|
userData: MediaGalleryUpdateUserData(disableAnimations: true))
|
|
|
|
return mediaGallery.galleryItem(at: underlyingPath)
|
|
}
|
|
|
|
func updateVisibleCells() {
|
|
for cell in collectionView.visibleCells {
|
|
guard let photoGridViewCell = cell as? PhotoGridViewCell else {
|
|
owsFailDebug("unexpected cell: \(cell)")
|
|
continue
|
|
}
|
|
|
|
photoGridViewCell.allowsMultipleSelection = collectionView.allowsMultipleSelection
|
|
}
|
|
}
|
|
|
|
// MARK: UICollectionViewDelegateFlowLayout
|
|
|
|
static let kInterItemSpacing: CGFloat = 2
|
|
private class func buildLayout() -> MediaTileViewLayout {
|
|
let layout = MediaTileViewLayout()
|
|
|
|
layout.sectionInsetReference = .fromSafeArea
|
|
layout.minimumInteritemSpacing = kInterItemSpacing
|
|
layout.minimumLineSpacing = kInterItemSpacing
|
|
layout.sectionHeadersPinToVisibleBounds = true
|
|
|
|
return layout
|
|
}
|
|
|
|
func updateLayout() {
|
|
let rawSize = view.safeAreaLayoutGuide.layoutFrame.size
|
|
|
|
let containerSize = CGSize(width: floor(rawSize.width), height: floor(rawSize.height))
|
|
|
|
let kItemsPerPortraitRow = 4
|
|
let minimumViewWidth = min(containerSize.width, containerSize.height)
|
|
let approxItemWidth = minimumViewWidth / CGFloat(kItemsPerPortraitRow)
|
|
|
|
let itemCount = round(containerSize.width / approxItemWidth)
|
|
let interSpaceWidth = (itemCount - 1) * type(of: self).kInterItemSpacing
|
|
let availableWidth = max(0, containerSize.width - interSpaceWidth)
|
|
|
|
let itemWidth = floor(availableWidth / CGFloat(itemCount))
|
|
let newItemSize = CGSize(square: itemWidth)
|
|
let remainingSpace = availableWidth - (itemCount * itemWidth)
|
|
let hInset = remainingSpace / 2
|
|
if newItemSize != mediaTileViewLayout.itemSize || hInset != mediaTileViewLayout.sectionInset.left {
|
|
mediaTileViewLayout.itemSize = newItemSize
|
|
// Inset any remaining space around the outside edges to ensure all inter-item spacing is exactly equal, otherwise
|
|
// we may get slightly different gaps between rows vs. columns
|
|
mediaTileViewLayout.sectionInset = UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)
|
|
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 !mediaGallery.galleryDates.isEmpty else {
|
|
return kStaticHeaderSize
|
|
}
|
|
|
|
switch section {
|
|
case kLoadOlderSectionIdx:
|
|
// Show "loading older..." iff there is still older data to be fetched
|
|
return mediaGallery.hasFetchedOldest ? CGSize.zero : kStaticHeaderSize
|
|
case loadNewerSectionIdx:
|
|
// Show "loading newer..." iff there is still more recent data to be fetched
|
|
return mediaGallery.hasFetchedMostRecent ? CGSize.zero : kStaticHeaderSize
|
|
default:
|
|
return kMonthHeaderSize
|
|
}
|
|
}
|
|
|
|
// MARK: Batch Selection
|
|
|
|
var isInBatchSelectMode = false {
|
|
didSet {
|
|
let didChange = isInBatchSelectMode != oldValue
|
|
if didChange {
|
|
collectionView!.allowsMultipleSelection = isInBatchSelectMode
|
|
updateVisibleCells()
|
|
updateSelectButton()
|
|
updateDeleteButton()
|
|
updateShareButton()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 updateShareButton() {
|
|
guard let collectionView = self.collectionView else {
|
|
owsFailDebug("collectionView was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 {
|
|
self.shareButton.isEnabled = true
|
|
} else {
|
|
self.shareButton.isEnabled = false
|
|
}
|
|
}
|
|
|
|
func updateSelectButton() {
|
|
if isInBatchSelectMode {
|
|
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didCancelSelect),
|
|
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "cancel_select_button"))
|
|
} else {
|
|
self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("BUTTON_SELECT", comment: "Button text to enable batch selection mode"),
|
|
style: .plain,
|
|
target: self,
|
|
action: #selector(didTapSelect),
|
|
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "select_button"))
|
|
}
|
|
}
|
|
|
|
@objc
|
|
func didTapSelect(_ sender: Any) {
|
|
isInBatchSelectMode = true
|
|
|
|
guard let collectionView = self.collectionView else {
|
|
owsFailDebug("collectionView was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
// show toolbar
|
|
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()
|
|
|
|
// ensure toolbar doesn't cover bottom row.
|
|
collectionView.contentInset.bottom += self.kFooterBarHeight
|
|
}, completion: nil)
|
|
|
|
// disabled until at least one item is selected
|
|
self.deleteButton.isEnabled = false
|
|
self.shareButton.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() {
|
|
isInBatchSelectMode = false
|
|
|
|
guard let collectionView = self.collectionView else {
|
|
owsFailDebug("collectionView was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
// hide toolbar
|
|
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()
|
|
|
|
// undo "ensure toolbar doesn't cover bottom row."
|
|
collectionView.contentInset.bottom -= self.kFooterBarHeight
|
|
}, 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("")
|
|
|
|
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 items.count == indexPaths.count else {
|
|
owsFailDebug("trying to delete an item that never loaded")
|
|
return
|
|
}
|
|
|
|
let format = NSLocalizedString("MEDIA_GALLERY_DELETE_MESSAGES_%d", tableName: "PluralAware",
|
|
comment: "Confirmation button text to delete selected media message(s) from the gallery")
|
|
let confirmationTitle = String.localizedStringWithFormat(format, indexPaths.count)
|
|
|
|
let deleteAction = ActionSheetAction(title: confirmationTitle, style: .destructive) { _ in
|
|
let galleryIndexPaths = indexPaths.map { IndexPath(item: $0.item, section: $0.section - 1) }
|
|
self.mediaGallery.delete(items: items, atIndexPaths: galleryIndexPaths, initiatedBy: self)
|
|
self.endSelectMode()
|
|
}
|
|
|
|
let actionSheet = ActionSheetController(title: nil, message: nil)
|
|
actionSheet.addAction(deleteAction)
|
|
actionSheet.addAction(OWSActionSheets.cancelAction)
|
|
|
|
presentActionSheet(actionSheet)
|
|
}
|
|
|
|
@objc
|
|
func didPressShare(_ 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: [TSAttachmentStream] = indexPaths.compactMap { return self.galleryItem(at: $0)?.attachmentStream }
|
|
guard items.count == indexPaths.count else {
|
|
owsFailDebug("trying to delete an item that never loaded")
|
|
return
|
|
}
|
|
|
|
AttachmentSharing.showShareUI(forAttachments: items, sender: sender)
|
|
}
|
|
|
|
var footerBarBottomConstraint: NSLayoutConstraint!
|
|
let kFooterBarHeight: CGFloat = 40
|
|
|
|
// MARK: Update
|
|
|
|
// MARK: MediaGalleryDelegate
|
|
|
|
func mediaGallery(_ mediaGallery: MediaGallery,
|
|
applyUpdate update: MediaGallery.Update) {
|
|
Logger.debug("Will begin batch update. animating=\(activeAnimationCount)")
|
|
let shouldAnimate = activeAnimationCount == 0 && !update.userData.contains(where: { $0.disableAnimations })
|
|
|
|
let saved = UIView.areAnimationsEnabled
|
|
UIView.setAnimationsEnabled(shouldAnimate && UIView.areAnimationsEnabled && saved)
|
|
|
|
if update.userData.contains(where: { $0.shouldRecordContentSizeBeforeInsertingToTop }) {
|
|
mediaTileViewLayout.recordContentSizeBeforeInsertingToTop()
|
|
}
|
|
// Within `performBatchUpdates` and before our closure runs, `UICollectionView` may call `numberOfItemsInSection`
|
|
// and it will expect us to give it "old" values (before any changes are applied).
|
|
self.collectionView.performBatchUpdates {
|
|
Logger.debug("Did begin batch update")
|
|
|
|
let oldItemCounts = (0..<self.mediaGallery.galleryDates.count).map {
|
|
self.mediaGallery.numberOfItemsInSection($0)
|
|
}
|
|
|
|
// This causes "new" values to become visible.
|
|
let journal = update.commit()
|
|
|
|
var updater = MediaGalleryCollectionViewUpdater(
|
|
itemCounts: oldItemCounts)
|
|
updater.delegate = self
|
|
updater.update(journal)
|
|
|
|
Logger.debug("Finished batch update")
|
|
} completion: { [weak self] _ in
|
|
if shouldAnimate {
|
|
self?.activeAnimationCount -= 1
|
|
}
|
|
}
|
|
|
|
UIView.setAnimationsEnabled(saved)
|
|
if shouldAnimate {
|
|
activeAnimationCount += 1
|
|
}
|
|
}
|
|
|
|
func mediaGallery(_ mediaGallery: MediaGallery, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) {
|
|
Logger.debug("")
|
|
}
|
|
|
|
func mediaGallery(_ mediaGallery: MediaGallery, deletedSections: IndexSet, deletedItems: [IndexPath]) {
|
|
Logger.debug("with deletedSections: \(deletedSections) deletedItems: \(deletedItems)")
|
|
}
|
|
|
|
func mediaGallery(_ mediaGallery: MediaGallery, didReloadItemsInSections sections: IndexSet) {
|
|
}
|
|
|
|
func didAddSectionInMediaGallery(_ mediaGallery: MediaGallery) {
|
|
_ = mediaGallery.loadLaterSections(batchSize: kLoadBatchSize)
|
|
}
|
|
|
|
func didReloadAllSectionsInMediaGallery(_ mediaGallery: MediaGallery) {
|
|
// If you receive a new attachment for an earlier month, MediaGallerySections resets itself and throws out a bunch of data. It resets hasFetched{Oldest,MostRecent} to false. This causes "Loading older…" and "Loading newer…" to become visible, even if there are no older or newer months in the db. Those only get updated on scroll. If the collection view's content size is less than its visible size then scrolling is impossible and they are stuck forever. Load sections until we either have everything or there's enough room for the user to scroll.
|
|
while collectionView.contentSize.height < collectionView.visibleSize.height && (!mediaGallery.hasFetchedOldest || !mediaGallery.hasFetchedMostRecent) {
|
|
if !mediaGallery.hasFetchedOldest {
|
|
_ = mediaGallery.loadEarlierSections(batchSize: kLoadBatchSize)
|
|
}
|
|
if !mediaGallery.hasFetchedMostRecent {
|
|
_ = mediaGallery.loadLaterSections(batchSize: kLoadBatchSize)
|
|
}
|
|
}
|
|
if eagerLoadingDidComplete {
|
|
// There might be more to load so restart eager loading.
|
|
eagerLoadingDidComplete = false
|
|
eagerlyLoadMoreIfPossible()
|
|
}
|
|
}
|
|
|
|
// MARK: Lazy Loading
|
|
|
|
var isFetchingMoreData = false
|
|
let kLoadBatchSize: Int = 100
|
|
|
|
let kLoadOlderSectionIdx: Int = 0
|
|
var loadNewerSectionIdx: Int {
|
|
return mediaGallery.galleryDates.count + 1
|
|
}
|
|
private var eagerLoadingDidComplete = false
|
|
|
|
private func eagerlyLoadMoreIfPossible() {
|
|
Logger.debug("")
|
|
guard !mediaGallery.hasFetchedOldest else {
|
|
Logger.debug("done")
|
|
eagerLoadingDidComplete = true
|
|
return
|
|
}
|
|
let userData = MediaGalleryUpdateUserData(disableAnimations: true,
|
|
shouldRecordContentSizeBeforeInsertingToTop: true)
|
|
// This is a low priority update because we never want eager loads to starve user-initiated
|
|
// loads (such as loading more sections because of scrolling or loading items to display).
|
|
mediaGallery.asyncLoadEarlierSections(batchSize: kLoadBatchSize,
|
|
highPriority: false,
|
|
userData: userData) { [weak self] newSections in
|
|
Logger.debug("Eagerly loaded \(newSections)")
|
|
self?.eagerlyLoadMoreIfPossible()
|
|
}
|
|
}
|
|
|
|
public func autoLoadMoreIfNecessary() {
|
|
let kEdgeThreshold: CGFloat = 800
|
|
|
|
guard let collectionView = self.collectionView else {
|
|
owsFailDebug("collectionView was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
let contentOffsetY = collectionView.contentOffset.y
|
|
let oldContentHeight = collectionView.contentSize.height
|
|
let direction: GalleryDirection
|
|
|
|
var shouldRecordContentSizeBeforeInsertingToTop = false
|
|
if contentOffsetY < kEdgeThreshold && !mediaGallery.hasFetchedOldest {
|
|
// Near the top, load older content
|
|
shouldRecordContentSizeBeforeInsertingToTop = true
|
|
direction = .before
|
|
|
|
} else if oldContentHeight - contentOffsetY < kEdgeThreshold && !mediaGallery.hasFetchedMostRecent {
|
|
// Near the bottom, load newer content
|
|
direction = .after
|
|
|
|
} else {
|
|
return
|
|
}
|
|
|
|
guard !isFetchingMoreData else {
|
|
Logger.debug("already fetching more data")
|
|
return
|
|
}
|
|
|
|
let userData = MediaGalleryUpdateUserData(disableAnimations: true,
|
|
shouldRecordContentSizeBeforeInsertingToTop: shouldRecordContentSizeBeforeInsertingToTop)
|
|
|
|
isFetchingMoreData = true
|
|
switch direction {
|
|
case .before:
|
|
mediaGallery.asyncLoadEarlierSections(batchSize: kLoadBatchSize,
|
|
highPriority: true,
|
|
userData: userData) { [weak self] newSections in
|
|
Logger.debug("found new sections: \(newSections)")
|
|
self?.isFetchingMoreData = false
|
|
}
|
|
case .after:
|
|
mediaGallery.asyncLoadLaterSections(batchSize: kLoadBatchSize, userData: userData) { [weak self] newSections in
|
|
Logger.debug("found new sections: \(newSections)")
|
|
self?.isFetchingMoreData = false
|
|
}
|
|
case .around:
|
|
preconditionFailure() // unused
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MediaTileViewController: MediaPresentationContextProvider {
|
|
func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
|
|
// First time presentation can occur before layout.
|
|
view.layoutIfNeeded()
|
|
|
|
guard case let .gallery(galleryItem) = item else {
|
|
owsFailDebug("Unexpected media type")
|
|
return nil
|
|
}
|
|
|
|
guard let underlyingPath = mediaGallery.indexPath(for: galleryItem) else {
|
|
owsFailDebug("galleryItemIndexPath was unexpectedly nil")
|
|
return nil
|
|
}
|
|
var indexPath = underlyingPath
|
|
indexPath.section += 1
|
|
|
|
guard let visibleIndex = collectionView.indexPathsForVisibleItems.firstIndex(of: indexPath) else {
|
|
Logger.debug("visibleIndex was nil, swiped to offscreen gallery item")
|
|
return nil
|
|
}
|
|
|
|
guard let cell = collectionView.visibleCells[safe: visibleIndex] as? PhotoGridViewCell else {
|
|
owsFailDebug("cell was unexpectedly nil")
|
|
return nil
|
|
}
|
|
|
|
let mediaView = cell.imageView
|
|
|
|
guard let mediaSuperview = mediaView.superview else {
|
|
owsFailDebug("mediaSuperview was unexpectedly nil")
|
|
return nil
|
|
}
|
|
|
|
let presentationFrame = coordinateSpace.convert(mediaView.frame, from: mediaSuperview)
|
|
|
|
return MediaPresentationContext(mediaView: mediaView, presentationFrame: presentationFrame, cornerRadius: 0)
|
|
}
|
|
|
|
func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Helper Classes
|
|
|
|
// Accommodates remaining scrolled to the same "apparent" position when new content is inserted
|
|
// 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.
|
|
private class MediaTileViewLayout: UICollectionViewFlowLayout {
|
|
private var contentSizeBeforeInsertingToTop: CGSize?
|
|
|
|
func recordContentSizeBeforeInsertingToTop() {
|
|
contentSizeBeforeInsertingToTop = collectionViewContentSize
|
|
}
|
|
|
|
override public func prepare() {
|
|
super.prepare()
|
|
|
|
if let collectionView = collectionView, let oldContentSize = contentSizeBeforeInsertingToTop {
|
|
let newContentSize = collectionViewContentSize
|
|
collectionView.contentOffset.y += newContentSize.height - oldContentSize.height
|
|
contentSizeBeforeInsertingToTop = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private class MediaGalleryStaticHeader: UICollectionViewCell {
|
|
|
|
static let reuseIdentifier = "MediaGalleryStaticHeader"
|
|
|
|
let label = UILabel()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
label.textColor = Theme.primaryTextColor
|
|
label.font = UIFont.ows_dynamicTypeBody
|
|
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) {
|
|
label.text = title
|
|
}
|
|
|
|
public override func prepareForReuse() {
|
|
label.text = nil
|
|
label.textColor = Theme.primaryTextColor
|
|
label.font = UIFont.ows_dynamicTypeBody
|
|
}
|
|
}
|
|
|
|
class GalleryGridCellItem: PhotoGridItem {
|
|
let galleryItem: MediaGalleryItem
|
|
|
|
init(galleryItem: MediaGalleryItem) {
|
|
self.galleryItem = galleryItem
|
|
}
|
|
|
|
var type: PhotoGridItemType {
|
|
if galleryItem.isVideo {
|
|
return .video(0) // TODO: return video duration
|
|
} else if galleryItem.isAnimated {
|
|
return .animated
|
|
} else {
|
|
return .photo
|
|
}
|
|
}
|
|
|
|
var creationDate: Date? { nil }
|
|
|
|
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? {
|
|
return galleryItem.thumbnailImage(async: completion)
|
|
}
|
|
}
|