mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Created a generic PagedDatabaseObserver (common logic for conversation & gallery paged database queries and observation) Updated the MediaGallery to use the PagedDatabaseObserver Split the interaction and thread data queries for the conversationVC
935 lines
37 KiB
Swift
935 lines
37 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import QuartzCore
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SessionUIKit
|
|
import SignalUtilitiesKit
|
|
|
|
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
|
|
static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400)
|
|
|
|
private let viewModel: MediaGalleryViewModel
|
|
private var hasLoadedInitialData: Bool = false
|
|
private var currentTargetOffset: CGPoint?
|
|
|
|
var isInBatchSelectMode = false {
|
|
didSet {
|
|
collectionView.allowsMultipleSelection = isInBatchSelectMode
|
|
updateSelectButton(updatedData: self.viewModel.galleryData, inBatchSelectMode: isInBatchSelectMode)
|
|
updateDeleteButton()
|
|
}
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(viewModel: MediaGalleryViewModel) {
|
|
self.viewModel = viewModel
|
|
GRDBStorage.shared.addObserver(viewModel.pagedDatabaseObserver)
|
|
|
|
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 {
|
|
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.backgroundColor = Colors.navigationBarBackground
|
|
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.barTintColor = Colors.navigationBarBackground
|
|
result.tintColor = Colors.text
|
|
|
|
return result
|
|
}()
|
|
|
|
lazy var deleteButton: UIBarButtonItem = {
|
|
let result: UIBarButtonItem = UIBarButtonItem(
|
|
barButtonSystemItem: .trash,
|
|
target: self,
|
|
action: #selector(didPressDelete)
|
|
)
|
|
result.tintColor = Colors.text
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
override public func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
// Add a custom back button if this is the only view controller
|
|
if self.navigationController?.viewControllers.first == self {
|
|
let backButton = OWSViewController.createOWSBackButton(withTarget: 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()
|
|
triggerInitialDataLoadIfNeeded()
|
|
}
|
|
|
|
public override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
stopObservingChanges()
|
|
}
|
|
|
|
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
|
startObservingChanges()
|
|
}
|
|
|
|
@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 triggerInitialDataLoadIfNeeded() {
|
|
// 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.hasLoadedInitialData && !self.viewModel.galleryData.isEmpty else { return }
|
|
|
|
// If we have a focused item then we want to scroll to it
|
|
guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else {
|
|
self.hasLoadedInitialData = true
|
|
return
|
|
}
|
|
|
|
Logger.debug("scrolling to focused item at indexPath: \(focusedIndexPath)")
|
|
self.view.layoutIfNeeded()
|
|
self.collectionView.scrollToItem(at: focusedIndexPath, at: .centeredVertically, animated: false)
|
|
|
|
// Note: If we have a 'focusedIndexPath' then we want to leave this until last so we can avoid
|
|
// triggering page loads due to default content offsets
|
|
self.hasLoadedInitialData = true
|
|
|
|
// 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() {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + MediaTileViewController.autoLoadNextPageDelay) { [weak self] in
|
|
let sortedVisibleIndexPaths: [IndexPath] = (self?.collectionView
|
|
.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader))
|
|
.defaulting(to: [])
|
|
.sorted()
|
|
|
|
for headerIndexPath in sortedVisibleIndexPaths {
|
|
switch self?.viewModel.galleryData[safe: headerIndexPath.section]?.model {
|
|
case .loadNewer:
|
|
self?.viewModel.loadNewerGalleryItems()
|
|
return
|
|
|
|
case .loadOlder:
|
|
self?.viewModel.loadOlderGalleryItems()
|
|
return
|
|
|
|
default: continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startObservingChanges() {
|
|
// Start observing for data changes (will callback on the main thread)
|
|
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in
|
|
self?.handleUpdates(updatedGalleryData)
|
|
}
|
|
}
|
|
|
|
private func stopObservingChanges() {
|
|
// Note: The 'PagedDatabaseObserver' 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]) {
|
|
// 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 {
|
|
UIView.performWithoutAnimation {
|
|
handleUpdates(updatedGalleryData)
|
|
triggerInitialDataLoadIfNeeded()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Determine if we are inserting content at the top of the collectionView
|
|
let isInsertingAtTop: Bool = {
|
|
let oldFirstSectionIsLoadMore: Bool = (
|
|
self.viewModel.galleryData[safe: 0]?.model == .loadNewer ||
|
|
self.viewModel.galleryData[safe: 0]?.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: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData),
|
|
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.hasLoadedInitialData else { return }
|
|
|
|
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section]
|
|
let fastEndScrollingThen: ((@escaping () -> ()) -> ()) = { callback in
|
|
let endOffset: CGPoint
|
|
|
|
if let currentTargetOffset: CGPoint = self.currentTargetOffset {
|
|
endOffset = currentTargetOffset
|
|
}
|
|
else {
|
|
let currentVelocity: CGPoint = collectionView.panGestureRecognizer.velocity(in: collectionView)
|
|
|
|
endOffset = CGPoint(
|
|
x: collectionView.contentOffset.x,
|
|
y: collectionView.contentOffset.y - (currentVelocity.y / 100)
|
|
)
|
|
}
|
|
|
|
guard endOffset != collectionView.contentOffset else {
|
|
return callback()
|
|
}
|
|
|
|
UIView.animate(
|
|
withDuration: 0.1,
|
|
delay: 0,
|
|
options: .curveEaseOut,
|
|
animations: {
|
|
collectionView.setContentOffset(endOffset, animated: false)
|
|
},
|
|
completion: { _ in
|
|
callback()
|
|
}
|
|
)
|
|
}
|
|
|
|
switch section.model {
|
|
case .loadOlder: fastEndScrollingThen { self.viewModel.loadOlderGalleryItems() }
|
|
case .loadNewer: fastEndScrollingThen { self.viewModel.loadNewerGalleryItems() }
|
|
|
|
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]
|
|
//
|
|
guard
|
|
let viewControllers: [UIViewController] = self.navigationController?.viewControllers,
|
|
viewControllers.count > 1,
|
|
viewControllers[viewControllers.count - 2] is OWSConversationSettingsViewController
|
|
else { return }
|
|
|
|
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 }
|
|
|
|
self.present(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) {
|
|
guard !updatedData.isEmpty else {
|
|
self.navigationItem.rightBarButtonItem = nil
|
|
return
|
|
}
|
|
|
|
if inBatchSelectMode {
|
|
self.navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
barButtonSystemItem: .cancel,
|
|
target: self,
|
|
action: #selector(didCancelSelect)
|
|
)
|
|
}
|
|
else {
|
|
self.navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: "BUTTON_SELECT".localized(),
|
|
style: .plain,
|
|
target: self,
|
|
action: #selector(didTapSelect)
|
|
)
|
|
}
|
|
}
|
|
|
|
@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)
|
|
|
|
// 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() {
|
|
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)
|
|
|
|
self.navigationItem.hidesBackButton = false
|
|
|
|
// 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
|
|
GRDBStorage.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(OWSAlerts.cancelAction)
|
|
|
|
presentAlert(actionSheet)
|
|
}
|
|
}
|
|
|
|
// 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 =(
|
|
if #available(iOS 11, *) {
|
|
return AlwaysOnTopLayer.self
|
|
} else {
|
|
return super.layerClass
|
|
}
|
|
}
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
label = UILabel()
|
|
label.textColor = Colors.text
|
|
|
|
let blurEffect = UIBlurEffect(style: .dark)
|
|
let blurEffectView = UIVisualEffectView(effect: blurEffect)
|
|
|
|
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.backgroundColor = isLightMode ? Colors.cellBackground : UIColor.ows_black.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
|
|
|
self.addSubview(blurEffectView)
|
|
self.addSubview(label)
|
|
|
|
blurEffectView.autoPinEdgesToSuperviewEdges()
|
|
blurEffectView.isHidden = isLightMode
|
|
label.autoPinEdge(toSuperviewMargin: .trailing)
|
|
label.autoPinEdge(toSuperviewMargin: .leading)
|
|
label.autoVCenterInSuperview()
|
|
}
|
|
|
|
@available(*, unavailable, message: "Unimplemented")
|
|
required init?(coder aDecoder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
|
|
public func configure(title: String) {
|
|
self.label.text = title
|
|
}
|
|
|
|
override public func prepareForReuse() {
|
|
super.prepareForReuse()
|
|
|
|
self.label.text = nil
|
|
}
|
|
}
|
|
|
|
private class MediaGalleryStaticHeader: UICollectionViewCell {
|
|
|
|
static let reuseIdentifier = "MediaGalleryStaticHeader"
|
|
|
|
let label = UILabel()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
addSubview(label)
|
|
|
|
label.textColor = Colors.text
|
|
label.textAlignment = .center
|
|
label.numberOfLines = 0
|
|
label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing))
|
|
}
|
|
|
|
@available(*, unavailable, message: "Unimplemented")
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
|
|
public func configure(title: String) {
|
|
self.label.text = title
|
|
}
|
|
|
|
public override func prepareForReuse() {
|
|
self.label.text = nil
|
|
}
|
|
}
|
|
|
|
class GalleryGridCellItem: PhotoGridItem {
|
|
let galleryItem: 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 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 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)
|
|
}
|
|
}
|