session-ios/Session/Media Viewing & Editing/MediaTileViewController.swift

954 lines
39 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import QuartzCore
import GRDB
import DifferenceKit
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
import SessionUtilitiesKit
public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
/// This should be larger than one screen size so we don't have to call it multiple times in rapid succession, but not
/// so large that loading get's really chopping
static let itemPageSize: Int = Int(11 * itemsPerPortraitRow)
static let itemsPerPortraitRow: CGFloat = 4
static let interItemSpacing: CGFloat = 2
static let footerBarHeight: CGFloat = 40
static let loadMoreHeaderHeight: CGFloat = 100
public let viewModel: MediaGalleryViewModel
private var hasLoadedInitialData: Bool = false
private var didFinishInitialLayout: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var currentTargetOffset: CGPoint?
public weak var delegate: MediaTileViewControllerDelegate?
var isInBatchSelectMode = false {
didSet {
collectionView.allowsMultipleSelection = isInBatchSelectMode
updateSelectButton(updatedData: self.viewModel.galleryData, inBatchSelectMode: isInBatchSelectMode)
updateDeleteButton()
}
}
// MARK: - Initialization
init(viewModel: MediaGalleryViewModel) {
self.viewModel = viewModel
Storage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UI
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.isIPad {
return .all
}
return .allButUpsideDown
}
var footerBarBottomConstraint: NSLayoutConstraint?
fileprivate lazy var mediaTileViewLayout: MediaTileViewLayout = {
let result: MediaTileViewLayout = MediaTileViewLayout()
result.sectionInsetReference = .fromSafeArea
result.minimumInteritemSpacing = MediaTileViewController.interItemSpacing
result.minimumLineSpacing = MediaTileViewController.interItemSpacing
result.sectionHeadersPinToVisibleBounds = true
return result
}()
lazy var collectionView: UICollectionView = {
let result: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: mediaTileViewLayout)
result.translatesAutoresizingMaskIntoConstraints = false
result.themeBackgroundColor = .newConversation_background
result.delegate = self
result.dataSource = self
result.register(view: PhotoGridViewCell.self)
result.register(view: MediaGallerySectionHeader.self, ofKind: UICollectionView.elementKindSectionHeader)
result.register(view: MediaGalleryStaticHeader.self, ofKind: UICollectionView.elementKindSectionHeader)
// Feels a bit weird to have content smashed all the way to the bottom edge.
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0)
return result
}()
lazy var footerBar: UIToolbar = {
let result: UIToolbar = UIToolbar()
result.setItems(
[
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
deleteButton,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
],
animated: false
)
result.themeBarTintColor = .backgroundPrimary
result.themeTintColor = .textPrimary
return result
}()
lazy var deleteButton: UIBarButtonItem = {
let result: UIBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .trash,
target: self,
action: #selector(didPressDelete)
)
result.themeTintColor = .textPrimary
return result
}()
// MARK: - Lifecycle
override public func viewDidLoad() {
super.viewDidLoad()
view.themeBackgroundColor = .newConversation_background
// Add a custom back button if this is the only view controller
if self.navigationController?.viewControllers.first == self {
let backButton = UIViewController.createOWSBackButton(target: self, selector: #selector(didPressDismissButton))
self.navigationItem.leftBarButtonItem = backButton
}
ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: MediaStrings.allMedia,
hasCustomBackButton: false
)
view.addSubview(self.collectionView)
collectionView.autoPin(toEdgesOf: view)
view.addSubview(self.footerBar)
footerBar.autoPinWidthToSuperview()
footerBar.autoSetDimension(.height, toSize: MediaTileViewController.footerBarHeight)
self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -MediaTileViewController.footerBarHeight)
self.updateSelectButton(updatedData: self.viewModel.galleryData, inBatchSelectMode: false)
self.mediaTileViewLayout.invalidateLayout()
// Notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidResignActive(_:)),
name: UIApplication.didEnterBackgroundNotification, object: nil
)
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startObservingChanges()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.didFinishInitialLayout = true
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopObservingChanges()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
DispatchQueue.main.async { [weak self] in
self?.startObservingChanges(didReturnFromBackground: true)
}
}
@objc func applicationDidResignActive(_ notification: Notification) {
stopObservingChanges()
}
override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
self.mediaTileViewLayout.invalidateLayout()
}
public override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
self.updateLayout()
}
// MARK: - Updating
private func performInitialScrollIfNeeded() {
// Ensure this hasn't run before and that we have data (The 'galleryData' will always
// contain something as the 'empty' state is a section within 'galleryData')
guard !self.didFinishInitialLayout && self.hasLoadedInitialData else { return }
// If we have a focused item then we want to scroll to it
guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return }
Logger.debug("scrolling to focused item at indexPath: \(focusedIndexPath)")
// Note: For some reason 'scrollToItem' doesn't always work properly so we need to manually
// calculate what the offset should be to do the initial scroll
self.view.layoutIfNeeded()
let availableHeight: CGFloat = {
// Note: This height will be set before we have properly performed a layout and fitted
// this screen within it's parent UIPagedViewController so we need to try to calculate
// the "actual" height of the collection view
var finalHeight: CGFloat = self.collectionView.frame.height
if let navController: UINavigationController = self.parent?.navigationController {
finalHeight -= navController.navigationBar.frame.height
finalHeight -= (UIApplication.shared.keyWindow?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0)
}
if let tabBar: TabBar = self.parent?.parent?.view.subviews.first as? TabBar {
finalHeight -= tabBar.frame.height
}
return finalHeight
}()
let focusedRect: CGRect = (self.collectionView.layoutAttributesForItem(at: focusedIndexPath)?.frame)
.defaulting(to: .zero)
self.collectionView.contentOffset = CGPoint(
x: 0,
y: (focusedRect.origin.y - (availableHeight / 2) + (focusedRect.height / 2))
)
self.collectionView.collectionViewLayout.invalidateLayout()
// Now that the data has loaded we need to check if either of the "load more" sections are
// visible and trigger them if so
//
// Note: We do it this way as we want to trigger the load behaviour for the first section
// if it has one before trying to trigger the load behaviour for the last section
self.autoLoadNextPageIfNeeded()
}
private func autoLoadNextPageIfNeeded() {
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
self.isAutoLoadingNextPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
self?.isAutoLoadingNextPage = false
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
let sortedVisibleIndexPaths: [IndexPath] = (self?.collectionView
.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader))
.defaulting(to: [])
.sorted()
for headerIndexPath in sortedVisibleIndexPaths {
let section: MediaGalleryViewModel.SectionModel? = self?.viewModel.galleryData[safe: headerIndexPath.section]
switch section?.model {
case .loadNewer, .loadOlder:
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
.pageAfter :
.pageBefore
)
}
return
default: continue
}
}
}
}
private func startObservingChanges(didReturnFromBackground: Bool = false) {
// Start observing for data changes (will callback on the main thread)
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, changeset in
self?.handleUpdates(updatedGalleryData, changeset: changeset)
}
// Note: When returning from the background we could have received notifications but the
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
// data to ensure everything is up to date
if didReturnFromBackground {
self.viewModel.pagedDataObserver?.reload()
}
}
private func stopObservingChanges() {
// Note: The 'pagedDataObserver' will continue to get changes but
// we don't want to trigger any UI updates
self.viewModel.onGalleryChange = nil
}
private func handleUpdates(
_ updatedGalleryData: [MediaGalleryViewModel.SectionModel],
changeset: StagedChangeset<[MediaGalleryViewModel.SectionModel]>
) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialData else {
self.viewModel.updateGalleryData(updatedGalleryData)
self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode)
UIView.performWithoutAnimation {
self.collectionView.reloadData()
self.hasLoadedInitialData = true
self.performInitialScrollIfNeeded()
}
return
}
// Determine if we are inserting content at the top of the collectionView
let isInsertingAtTop: Bool = {
let oldFirstSectionIsLoadMore: Bool = (
self.viewModel.galleryData.first?.model == .loadNewer ||
self.viewModel.galleryData.first?.model == .loadOlder
)
let oldTargetSectionIndex: Int = (oldFirstSectionIsLoadMore ? 1 : 0)
guard
let newTargetSectionIndex = updatedGalleryData
.firstIndex(where: { $0.model == self.viewModel.galleryData[safe: oldTargetSectionIndex]?.model }),
let oldFirstItem: MediaGalleryViewModel.Item = self.viewModel.galleryData[safe: oldTargetSectionIndex]?.elements.first,
let newFirstItemIndex = updatedGalleryData[safe: newTargetSectionIndex]?.elements.firstIndex(of: oldFirstItem)
else { return false }
return (newTargetSectionIndex > oldTargetSectionIndex || newFirstItemIndex > 0)
}()
// We want to maintain the same content offset between the updates if content was added to
// the top, the mediaTileViewLayout will adjust content offset to compensate for the change
// in content height so that the same content is visible after the update
//
// Using the `CollectionViewLayout.prepare` approach (rather than calling setContentOffset
// in the batchUpdate completion block) avoids a distinct flicker (we also have to
// disable animations for this to avoid buggy animations)
CATransaction.begin()
if isInsertingAtTop { CATransaction.setDisableActions(true) }
self.mediaTileViewLayout.isInsertingCellsToTop = isInsertingAtTop
self.mediaTileViewLayout.contentSizeBeforeInsertingToTop = self.collectionView.contentSize
self.collectionView.reload(
using: changeset,
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
) { [weak self] updatedData in
self?.viewModel.updateGalleryData(updatedData)
}
CATransaction.setCompletionBlock { [weak self] in
// Need to manually reset these here as the 'reload' method above can actually trigger
// multiple updates (eg. inserting sections and then items)
self?.mediaTileViewLayout.isInsertingCellsToTop = false
self?.mediaTileViewLayout.contentSizeBeforeInsertingToTop = nil
// If one of the "load more" sections is still visible once the animation completes then
// trigger another "load more" (after a small delay to minimize animation bugginess)
self?.autoLoadNextPageIfNeeded()
}
CATransaction.commit()
// Update the select button (should be hidden if there is no data)
self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode)
}
// MARK: - Interactions
@objc public func didPressDismissButton() {
let presentedNavController: UINavigationController? = (self.presentingViewController as? UINavigationController)
let mediaPageViewController: MediaPageViewController? = (
(presentedNavController?.viewControllers.last as? MediaPageViewController) ??
(self.presentingViewController as? MediaPageViewController)
)
// If the album was presented from a 'MediaPageViewController' and it has no more data (ie.
// all album items had been deleted) then dismiss to the screen before that one
guard mediaPageViewController?.viewModel.albumData.isEmpty != true else {
presentedNavController?.presentingViewController?.dismiss(animated: true, completion: nil)
return
}
dismiss(animated: true, completion: nil)
}
// MARK: - UIScrollViewDelegate
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.currentTargetOffset = nil
}
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
self.currentTargetOffset = targetContentOffset.pointee
}
// MARK: - UICollectionViewDataSource
public func numberOfSections(in collectionView: UICollectionView) -> Int {
return self.viewModel.galleryData.count
}
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section]
return section.elements.count
}
public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section]
switch section.model {
case .emptyGallery, .loadOlder, .loadNewer:
let sectionHeader: MediaGalleryStaticHeader = collectionView.dequeue(type: MediaGalleryStaticHeader.self, ofKind: kind, for: indexPath)
sectionHeader.configure(
title: {
switch section.model {
case .emptyGallery: return "GALLERY_TILES_EMPTY_GALLERY".localized()
case .loadOlder: return "GALLERY_TILES_LOADING_OLDER_LABEL".localized()
case .loadNewer: return "GALLERY_TILES_LOADING_MORE_RECENT_LABEL".localized()
case .galleryMonth: return "" // Impossible case
}
}()
)
return sectionHeader
case .galleryMonth(let date):
let sectionHeader: MediaGallerySectionHeader = collectionView.dequeue(type: MediaGallerySectionHeader.self, ofKind: kind, for: indexPath)
sectionHeader.configure(
title: date.localizedString
)
return sectionHeader
}
}
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section]
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
cell.configure(
item: GalleryGridCellItem(
galleryItem: section.elements[indexPath.row]
)
)
return cell
}
public func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) {
// Want to ensure the initial content load has completed before we try to load any more data
guard self.didFinishInitialLayout else { return }
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section]
switch section.model {
case .loadOlder, .loadNewer:
UIScrollView.fastEndScrollingThen(collectionView, self.currentTargetOffset) { [weak self] in
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
// 'pageAfter' in this case
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ?
.pageAfter :
.pageBefore
)
}
}
case .emptyGallery, .galleryMonth: break
}
}
// MARK: - UICollectionViewDelegate
public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
let section: MediaGalleryViewModel.Section = self.viewModel.galleryData[indexPath.section].model
switch section {
case .emptyGallery, .loadOlder, .loadNewer: return false
case .galleryMonth: return true
}
}
public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool {
let section: MediaGalleryViewModel.Section = self.viewModel.galleryData[indexPath.section].model
switch section {
case .emptyGallery, .loadOlder, .loadNewer: return false
case .galleryMonth: return true
}
}
public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
let section: MediaGalleryViewModel.Section = self.viewModel.galleryData[indexPath.section].model
switch section {
case .emptyGallery, .loadOlder, .loadNewer: return false
case .galleryMonth: return true
}
}
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section]
switch section.model {
case .emptyGallery, .loadOlder, .loadNewer: return
case .galleryMonth: break
}
guard !isInBatchSelectMode else {
updateDeleteButton()
return
}
collectionView.deselectItem(at: indexPath, animated: true)
let galleryItem: MediaGalleryViewModel.Item = section.elements[indexPath.row]
// First check if this screen was presented
guard let presentingViewController: UIViewController = self.presentingViewController else {
// If we got to the gallery via conversation settings, present the detail view
// on top of the tile view
//
// == ViewController Schematic ==
//
// [DetailView] <--,
// [TileView] -----'
// [ConversationSettingsView]
// [ConversationView]
//
let detailViewController: UIViewController? = MediaGalleryViewModel.createDetailViewController(
for: self.viewModel.threadId,
threadVariant: self.viewModel.threadVariant,
interactionId: galleryItem.interactionId,
selectedAttachmentId: galleryItem.attachment.id,
options: [ .sliderEnabled ]
)
guard let detailViewController: UIViewController = detailViewController else { return }
delegate?.presentdetailViewController(detailViewController, animated: true)
return
}
// Check if we were presented via the 'MediaPageViewController'
guard let existingDetailPageView: MediaPageViewController = (presentingViewController as? UINavigationController)?.viewControllers.first as? MediaPageViewController else {
self.navigationController?.dismiss(animated: true)
return
}
// If we got to the gallery via the conversation view, pop the tile view
// to return to the detail view
//
// == ViewController Schematic ==
//
// [TileView] -----,
// [DetailView] <--'
// [ConversationView]
//
existingDetailPageView.setCurrentItem(galleryItem, direction: .forward, animated: false)
existingDetailPageView.willBePresentedAgain()
self.viewModel.updateFocusedItem(attachmentId: galleryItem.attachment.id, indexPath: indexPath)
self.navigationController?.dismiss(animated: true)
}
public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
if isInBatchSelectMode {
updateDeleteButton()
}
}
// MARK: - UICollectionViewDelegateFlowLayout
func updateLayout() {
let screenWidth: CGFloat = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let approxItemWidth: CGFloat = (screenWidth / MediaTileViewController.itemsPerPortraitRow)
let itemSectionInsets: UIEdgeInsets = self.collectionView(
collectionView,
layout: mediaTileViewLayout,
insetForSectionAt: 1
)
let widthInset: CGFloat = (itemSectionInsets.left + itemSectionInsets.right)
let containerWidth: CGFloat = (collectionView.frame.width > CGFloat.leastNonzeroMagnitude ?
collectionView.frame.width :
view.bounds.width
)
let collectionViewWidth: CGFloat = (containerWidth - widthInset)
let itemCount: CGFloat = round(collectionViewWidth / approxItemWidth)
let spaceWidth: CGFloat = ((itemCount - 1) * MediaTileViewController.interItemSpacing)
let availableWidth: CGFloat = (collectionViewWidth - 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, insetForSectionAt section: Int) -> UIEdgeInsets {
return .zero
}
public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section]
switch section.model {
case .emptyGallery, .loadOlder, .loadNewer:
return CGSize(width: 0, height: MediaTileViewController.loadMoreHeaderHeight)
case .galleryMonth: return CGSize(width: 0, height: 50)
}
}
// MARK: Batch Selection
func updateDeleteButton() {
self.deleteButton.isEnabled = ((collectionView.indexPathsForSelectedItems?.count ?? 0) > 0)
}
func updateSelectButton(updatedData: [MediaGalleryViewModel.SectionModel], inBatchSelectMode: Bool) {
delegate?.updateSelectButton(updatedData: updatedData, inBatchSelectMode: inBatchSelectMode)
}
@objc func didTapSelect(_ sender: Any) {
isInBatchSelectMode = true
// show toolbar
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { [weak self] in
self?.footerBarBottomConstraint?.isActive = false
self?.footerBarBottomConstraint = self?.footerBar.autoPinEdge(toSuperviewSafeArea: .bottom)
self?.footerBar.superview?.layoutIfNeeded()
// Ensure toolbar doesn't cover bottom row.
self?.collectionView.contentInset.bottom += MediaTileViewController.footerBarHeight
}, completion: nil)
}
@objc func didCancelSelect(_ sender: Any) {
endSelectMode()
}
func endSelectMode() {
isInBatchSelectMode = false
// hide toolbar
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: { [weak self] in
self?.footerBarBottomConstraint?.isActive = false
self?.footerBarBottomConstraint = self?.footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -MediaTileViewController.footerBarHeight)
self?.footerBar.superview?.layoutIfNeeded()
// Undo "Ensure toolbar doesn't cover bottom row."
self?.collectionView.contentInset.bottom -= MediaTileViewController.footerBarHeight
}, completion: nil)
// Deselect any selected
collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false)}
}
@objc func didPressDelete(_ sender: Any) {
guard let indexPaths = collectionView.indexPathsForSelectedItems else {
owsFailDebug("indexPaths was unexpectedly nil")
return
}
let items: [MediaGalleryViewModel.Item] = indexPaths.map {
self.viewModel.galleryData[$0.section].elements[$0.item]
}
let confirmationTitle: String = {
if indexPaths.count == 1 {
return "MEDIA_GALLERY_DELETE_SINGLE_MESSAGE".localized()
}
return String(
format: "MEDIA_GALLERY_DELETE_MULTIPLE_MESSAGES_FORMAT".localized(),
indexPaths.count
)
}()
let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { [weak self] _ in
Storage.shared.writeAsync { db in
let interactionIds: Set<Int64> = items
.map { $0.interactionId }
.asSet()
_ = try Attachment
.filter(ids: items.map { $0.attachment.id })
.deleteAll(db)
// Add the garbage collection job to delete orphaned attachment files
JobRunner.add(
db,
job: Job(
variant: .garbageCollection,
behaviour: .runOnce,
details: GarbageCollectionJob.Details(
typesToCollect: [.orphanedAttachmentFiles]
)
)
)
// Delete any interactions which had all of their attachments removed
_ = try Interaction
.filter(ids: interactionIds)
.having(Interaction.interactionAttachments.isEmpty)
.deleteAll(db)
}
self?.endSelectMode()
}
let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
actionSheet.addAction(deleteAction)
actionSheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel))
Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
self.present(actionSheet, animated: true)
}
}
// MARK: - Private Helper Classes
// Accomodates 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 {
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)
// Update the content size in case there is a subsequent update
contentSizeBeforeInsertingToTop = newContentSize
}
}
}
}
private 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 =(
return AlwaysOnTopLayer.self
}
}
override init(frame: CGRect) {
label = UILabel()
label.themeTextColor = .textPrimary
super.init(frame: frame)
self.themeBackgroundColor = .clear
let backgroundView: UIView = UIView()
backgroundView.themeBackgroundColor = .newConversation_background
addSubview(backgroundView)
backgroundView.pin(to: self)
self.addSubview(label)
label.pin(.leading, to: .leading, of: self, withInset: Values.largeSpacing)
label.pin(.trailing, to: .trailing, of: self, withInset: -Values.largeSpacing)
label.center(.vertical, in: self)
}
@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.themeTextColor = .textPrimary
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: MediaGalleryViewModel.Item
init(galleryItem: MediaGalleryViewModel.Item) {
self.galleryItem = galleryItem
}
var type: PhotoGridItemType {
if galleryItem.isVideo {
return .video
}
if galleryItem.isAnimated {
return .animated
}
return .photo
}
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) {
galleryItem.thumbnailImage(async: completion)
}
}
// MARK: - UIViewControllerTransitioningDelegate
extension MediaTileViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard
self == presented ||
self.navigationController == presented ||
self.parent == presented ||
self.parent?.navigationController == presented
else { return nil }
guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return nil }
return MediaDismissAnimationController(
galleryItem: self.viewModel.galleryData[focusedIndexPath.section].elements[focusedIndexPath.item]
)
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard
self == dismissed ||
self.navigationController == dismissed ||
self.parent == dismissed ||
self.parent?.navigationController == dismissed
else { return nil }
guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return nil }
return MediaZoomAnimationController(
galleryItem: self.viewModel.galleryData[focusedIndexPath.section].elements[focusedIndexPath.item],
shouldBounce: false
)
}
}
// MARK: - MediaPresentationContextProvider
extension MediaTileViewController: MediaPresentationContextProvider {
func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
guard case let .gallery(galleryItem) = mediaItem else { return nil }
// Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an
// unsorted array which means we can't use it to determine the desired 'visibleCell'
// we are after, due to this we will need to iterate all of the visible cells to find
// the one we want
let maybeGridCell: PhotoGridViewCell? = collectionView.visibleCells
.first { cell -> Bool in
guard
let cell: PhotoGridViewCell = cell as? PhotoGridViewCell,
let item: GalleryGridCellItem = cell.item as? GalleryGridCellItem,
item.galleryItem.attachment.id == galleryItem.attachment.id
else { return false }
return true
}
.map { $0 as? PhotoGridViewCell }
guard
let gridCell: PhotoGridViewCell = maybeGridCell,
let mediaSuperview: UIView = gridCell.imageView.superview
else { return nil }
let presentationFrame: CGRect = coordinateSpace.convert(gridCell.imageView.frame, from: mediaSuperview)
return MediaPresentationContext(
mediaView: gridCell.imageView,
presentationFrame: presentationFrame,
cornerRadius: 0,
cornerMask: CACornerMask()
)
}
func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace)
}
}
// MARK: - MediaTileViewControllerDelegate
public protocol MediaTileViewControllerDelegate: AnyObject {
func presentdetailViewController(_ detailViewController: UIViewController, animated: Bool)
func updateSelectButton(updatedData: [MediaGalleryViewModel.SectionModel], inBatchSelectMode: Bool)
}