Signal-iOS/Signal/src/ViewControllers/Photos/ImagePickerController.swift
2024-05-14 11:46:52 -05:00

661 lines
24 KiB
Swift

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Photos
import SignalServiceKit
import SignalUI
protocol ImagePickerGridControllerDelegate: AnyObject {
func imagePickerDidComplete(_ imagePicker: ImagePickerGridController)
func imagePickerDidCancel(_ imagePicker: ImagePickerGridController)
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise<SignalAttachment>)
func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset)
func imagePickerDidTryToSelectTooMany(_ imagePicker: ImagePickerGridController)
}
protocol ImagePickerGridControllerDataSource: AnyObject {
func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool
func imagePickerCanSelectMoreItems(_ imagePicker: ImagePickerGridController) -> Bool
var numberOfMediaItems: Int { get }
}
class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate, OWSNavigationChildController {
weak var delegate: ImagePickerGridControllerDelegate?
weak var dataSource: ImagePickerGridControllerDataSource?
private let library: PhotoLibrary = PhotoLibrary()
private var photoAlbum: PhotoAlbum
private var photoAlbumContents: PhotoAlbumContents
private let photoMediaSize = PhotoMediaSize()
private var collectionViewFlowLayout: UICollectionViewFlowLayout
private lazy var titleView: TitleView = {
let titleView = TitleView()
titleView.delegate = self
titleView.tintColor = .ows_gray05
titleView.text = photoAlbum.localizedTitle()
return titleView
}()
private lazy var doneButton: MediaDoneButton = {
let button = MediaDoneButton()
button.overrideUserInterfaceStyle = .light
button.addTarget(self, action: #selector(didTapDoneButton), for: .touchUpInside)
return button
}()
init() {
collectionViewFlowLayout = type(of: self).buildLayout()
photoAlbum = library.defaultPhotoAlbum()
photoAlbumContents = photoAlbum.contents()
super.init(collectionViewLayout: collectionViewFlowLayout)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - View Lifecycle
override var prefersStatusBarHidden: Bool {
!UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad && !CurrentAppContext().hasActiveOrPendingCall
}
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
var preferredNavigationBarStyle: OWSNavigationBarStyle { .alwaysDark }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Theme.darkThemeBackgroundColor
library.add(delegate: self)
collectionView.allowsMultipleSelection = true
collectionView.backgroundColor = Theme.darkThemeBackgroundColor
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
navigationItem.titleView = titleView
let cancelButton: UIBarButtonItem = .cancelButton { [weak self] in
guard let self else { return }
self.delegate?.imagePickerDidCancel(self)
}
cancelButton.tintColor = Theme.darkThemePrimaryColor
navigationItem.leftBarButtonItem = cancelButton
view.addSubview(doneButton)
doneButton.autoPinBottomToSuperviewMargin(withInset: UIDevice.current.hasIPhoneXNotch ? 8 : 16)
doneButton.autoPinTrailingToSuperviewMargin()
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
selectionPanGesture.delegate = self
self.selectionPanGesture = selectionPanGesture
collectionView.addGestureRecognizer(selectionPanGesture)
}
private var selectionPanGesture: UIPanGestureRecognizer?
private enum BatchSelectionGestureMode {
case select, deselect
}
private var selectionPanGestureMode: BatchSelectionGestureMode = .select
@objc
private func didPanSelection(_ selectionPanGesture: UIPanGestureRecognizer) {
guard let collectionView = collectionView else {
owsFailDebug("collectionView was unexpectedly nil")
return
}
guard let dataSource = dataSource else {
owsFailDebug("dataSource was unexpectedly nil")
return
}
switch selectionPanGesture.state {
case .possible:
break
case .began:
collectionView.isUserInteractionEnabled = false
collectionView.isScrollEnabled = false
let location = selectionPanGesture.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location) else {
return
}
let asset = photoAlbumContents.asset(at: indexPath.item)
if dataSource.imagePicker(self, isAssetSelected: asset) {
selectionPanGestureMode = .deselect
} else {
selectionPanGestureMode = .select
}
case .changed:
let velocity = selectionPanGesture.velocity(in: view)
// Bulk selection is a horizontal pan, while scrolling content is a vertical pan.
// There will be some ambiguity since users gestures are not perfectly cardinal.
//
// We try to account for that here.
//
// If the `alpha` is too low, the user will inadvertently select items while trying to scroll.
// If the `alpha` is too high, the user will not be able to easily horizontally select items.
let alpha: CGFloat = 4.0
let isDecidedlyHorizontal = abs(velocity.x) > abs(velocity.y) * alpha
guard isDecidedlyHorizontal else {
return
}
let location = selectionPanGesture.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location) else {
return
}
tryToToggleBatchSelect(at: indexPath)
case .cancelled, .ended, .failed:
collectionView.isUserInteractionEnabled = true
collectionView.isScrollEnabled = true
@unknown default:
owsFailDebug("unexpected selectionPanGesture.state: \(selectionPanGesture.state)")
}
}
private func tryToToggleBatchSelect(at indexPath: IndexPath) {
guard let collectionView = collectionView else {
owsFailDebug("collectionView was unexpectedly nil")
return
}
guard let delegate = delegate, let dataSource = dataSource else {
owsFailDebug("delegate or dataSource was unexpectedly nil")
return
}
let asset = photoAlbumContents.asset(at: indexPath.item)
switch selectionPanGestureMode {
case .select:
guard !isSelected(indexPath: indexPath) else {
return
}
guard dataSource.imagePickerCanSelectMoreItems(self) else {
delegate.imagePickerDidTryToSelectTooMany(self)
return
}
let attachmentPromise: Promise<SignalAttachment> = photoAlbumContents.outgoingAttachment(for: asset)
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
updateDoneButtonAppearance()
case .deselect:
guard isSelected(indexPath: indexPath) else {
return
}
delegate.imagePicker(self, didDeselectAsset: asset)
collectionView.deselectItem(at: indexPath, animated: true)
updateDoneButtonAppearance()
}
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
updateLayout()
}
private var hasEverAppeared: Bool = false
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Determine the size of the thumbnails to request
let scale = UIScreen.main.scale
let cellSize: CGSize
if hasEverAppeared {
cellSize = collectionViewFlowLayout.itemSize
} else {
cellSize = getLayout().itemSize
}
photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
reloadData()
if !hasEverAppeared {
scrollToBottom(animated: false)
}
updateDoneButtonAppearance()
}
override func viewSafeAreaInsetsDidChange() {
if !hasEverAppeared {
// To scroll precisely to the bottom of the content, we have to account for the space
// taken up by the navbar and any notch.
//
// Before iOS11 the system accounts for this by assigning contentInset to the scrollView
// which is available by the time `viewWillAppear` is called.
//
// On iOS11+, contentInsets are not assigned to the scrollView in `viewWillAppear`, but
// this method, `viewSafeAreaInsetsDidChange` is called *between* `viewWillAppear` and
// `viewDidAppear` and indicates `safeAreaInsets` have been assigned.
scrollToBottom(animated: false)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
hasEverAppeared = true
DispatchQueue.main.async {
// pre-layout collectionPicker for snappier response
self.collectionPickerController.view.layoutIfNeeded()
}
}
private func updateDoneButtonAppearance() {
guard let dataSource = dataSource else { return }
doneButton.badgeNumber = dataSource.numberOfMediaItems
doneButton.isHidden = doneButton.badgeNumber == 0
}
// MARK: - Scrolling
private var lastPageYOffset: CGFloat {
let yOffset = collectionView.contentSize.height - collectionView.frame.height + collectionView.contentInset.bottom + view.safeAreaInsets.bottom
return yOffset
}
private func scrollToBottom(animated: Bool) {
self.view.layoutIfNeeded()
guard let collectionView = collectionView else {
owsFailDebug("collectionView was unexpectedly nil")
return
}
let yOffset = lastPageYOffset
guard yOffset > 0 else {
// less than 1 page of content. Do not offset.
return
}
collectionView.setContentOffset(CGPoint(x: 0, y: yOffset), animated: animated)
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !hasEverAppeared, collectionView.contentOffset.y != lastPageYOffset {
// We initially want the user to be scrolled to the bottom of the media library content.
// However, at least on iOS12, we were finding that when the view finally presented,
// the content was not *quite* to the bottom (~20px above it).
//
// Debugging shows that initially we have the correct offset, but that *something* is
// causing the content to adjust *after* viewWillAppear and viewSafeAreaInsetsDidChange.
// Because that something results in `scrollViewDidScroll` we re-adjust the content
// insets to the bottom.
Logger.debug("adjusting scroll offset back to bottom")
scrollToBottom(animated: false)
}
}
private func reloadData() {
guard let collectionView = collectionView else {
owsFailDebug("Missing collectionView.")
return
}
collectionView.reloadData()
collectionView.layoutIfNeeded()
}
// MARK: - Actions
@objc
private func didTapDoneButton() {
delegate?.imagePickerDidComplete(self)
}
// MARK: - Layout
private static let kInterItemSpacing: CGFloat = 2
private class func buildLayout() -> UICollectionViewFlowLayout {
let layout = UICollectionViewFlowLayout()
layout.sectionInsetReference = .fromSafeArea
layout.minimumInteritemSpacing = kInterItemSpacing
layout.minimumLineSpacing = kInterItemSpacing
layout.sectionHeadersPinToVisibleBounds = true
return layout
}
private typealias CellLayout = (itemSize: CGSize, remainingSpace: CGFloat)
private func getLayout() -> CellLayout {
let containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width
let minItemWidth: CGFloat = 100
let itemCount = floor(containerWidth / minItemWidth)
let interSpaceWidth = (itemCount - 1) * type(of: self).kInterItemSpacing
let availableWidth = max(0, containerWidth - interSpaceWidth)
let itemWidth = floor(availableWidth / CGFloat(itemCount))
let itemSize = CGSize(square: itemWidth)
let remainingSpace = availableWidth - (itemCount * itemWidth)
return (itemSize, remainingSpace)
}
private func updateLayout() {
let (newItemSize, remainingSpace) = getLayout()
if newItemSize != collectionViewFlowLayout.itemSize {
collectionViewFlowLayout.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
collectionViewFlowLayout.sectionInset = UIEdgeInsets(top: 0, leading: remainingSpace / 2, bottom: 0, trailing: remainingSpace / 2)
collectionViewFlowLayout.invalidateLayout()
}
}
// MARK: - Batch Selection
private func isSelected(indexPath: IndexPath) -> Bool {
guard let selectedIndexPaths = collectionView.indexPathsForSelectedItems else {
return false
}
return selectedIndexPaths.contains(indexPath)
}
// MARK: - PhotoLibraryDelegate
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
photoAlbumContents = photoAlbum.contents()
reloadData()
}
// MARK: - PhotoCollectionPicker Presentation
private var isShowingCollectionPickerController: Bool = false
private lazy var collectionPickerController: UINavigationController = {
let viewController = PhotoAlbumPickerViewController(library: library, collectionDelegate: self)
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.isNavigationBarHidden = true
return navigationController
}()
private func showCollectionPicker() {
guard let collectionPickerView = collectionPickerController.view else {
owsFailDebug("collectionView was unexpectedly nil")
return
}
assert(!isShowingCollectionPickerController)
isShowingCollectionPickerController = true
addChild(collectionPickerController)
view.addSubview(collectionPickerView)
collectionPickerView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top)
collectionPickerView.autoPinEdge(toSuperviewSafeArea: .top)
collectionPickerView.layoutIfNeeded()
// Initially position offscreen, we'll animate it in.
collectionPickerView.frame = collectionPickerView.frame.offsetBy(dx: 0, dy: collectionPickerView.frame.height)
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) {
collectionPickerView.superview?.layoutIfNeeded()
self.titleView.rotateIcon(.up)
}
}
private func hideCollectionPicker() {
assert(isShowingCollectionPickerController)
isShowingCollectionPickerController = false
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) { [self] in
collectionPickerController.view.frame = collectionPickerController.view.frame.offsetBy(
dx: 0,
dy: collectionPickerController.view.height
)
titleView.rotateIcon(.down)
}.done { [self] _ in
collectionPickerController.popToRootViewController(animated: false)
collectionPickerController.view.removeFromSuperview()
collectionPickerController.removeFromParent()
}
}
// MARK: - UICollectionView
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
guard let delegate = delegate, let dataSource = dataSource else { return false }
if dataSource.imagePickerCanSelectMoreItems(self) {
return true
}
delegate.imagePickerDidTryToSelectTooMany(self)
return false
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let delegate = delegate else {
owsFailDebug("delegate was unexpectedly nil")
return
}
let asset: PHAsset = photoAlbumContents.asset(at: indexPath.item)
let attachmentPromise: Promise<SignalAttachment> = photoAlbumContents.outgoingAttachment(for: asset)
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
updateDoneButtonAppearance()
}
override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
guard let delegate = delegate else {
owsFailDebug("delegate was unexpectedly nil")
return
}
let asset = photoAlbumContents.asset(at: indexPath.item)
delegate.imagePicker(self, didDeselectAsset: asset)
updateDoneButtonAppearance()
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return photoAlbumContents.assetCount
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
owsFail("cell was unexpectedly nil")
}
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
let assetItem = photoAlbumContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
cell.configure(item: assetItem)
return cell
}
override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard let dataSource = dataSource else { return }
guard let photoGridViewCell = cell as? PhotoGridViewCell else {
owsFailDebug("unexpected cell: \(cell)")
return
}
let assetItem = photoAlbumContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
let isSelected = dataSource.imagePicker(self, isAssetSelected: assetItem.asset)
if isSelected {
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
} else {
collectionView.deselectItem(at: indexPath, animated: false)
}
photoGridViewCell.isSelected = isSelected
photoGridViewCell.allowsMultipleSelection = collectionView.allowsMultipleSelection
}
private func updateVisibleCells() {
guard let dataSource = dataSource else { return }
for cell in collectionView.visibleCells {
guard let photoGridViewCell = cell as? PhotoGridViewCell else {
owsFailDebug("unexpected cell: \(cell)")
continue
}
guard let assetItem = photoGridViewCell.photoGridItem as? PhotoPickerAssetItem else {
owsFailDebug("unexpected photoGridViewCell.item: \(String(describing: photoGridViewCell.photoGridItem))")
continue
}
photoGridViewCell.isSelected = dataSource.imagePicker(self, isAssetSelected: assetItem.asset)
photoGridViewCell.allowsMultipleSelection = collectionView.allowsMultipleSelection
}
}
}
extension ImagePickerGridController: PhotoAlbumPickerDelegate {
func photoAlbumPicker(_ picker: PhotoAlbumPickerViewController, didSelectAlbum album: PhotoAlbum) {
guard photoAlbum != album else {
hideCollectionPicker()
return
}
photoAlbum = album
photoAlbumContents = photoAlbum.contents()
// Any selections are invalid as they refer to indices in a different collection
reloadData()
titleView.text = photoAlbum.localizedTitle()
scrollToBottom(animated: false)
hideCollectionPicker()
}
}
extension ImagePickerGridController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == selectionPanGesture else {
return true
}
return ![.changed, .began].contains(collectionView.panGestureRecognizer.state)
}
}
private protocol TitleViewDelegate: AnyObject {
func titleViewWasTapped(_ titleView: TitleView)
}
private class TitleView: UIView {
private let label = UILabel()
private let iconView = UIImageView()
private let stackView: UIStackView
// Returns same font as UIBarButtonItem uses.
private func titleLabelFont() -> UIFont {
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
return UIFont(descriptor: fontDescriptor, size: fontDescriptor.pointSize.clamp(17, 21))
}
// MARK: - Initializers
override init(frame: CGRect) {
stackView = UIStackView(arrangedSubviews: [label, iconView])
super.init(frame: frame)
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 5
stackView.isUserInteractionEnabled = true
addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges()
label.textColor = tintColor
label.font = titleLabelFont()
iconView.tintColor = tintColor
iconView.image = UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(font: label.font))
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))
}
@available(*, unavailable, message: "Use init(frame:) instead")
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func tintColorDidChange() {
super.tintColorDidChange()
label.textColor = tintColor
iconView.tintColor = tintColor
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
label.font = titleLabelFont()
iconView.image = UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(font: label.font))
}
}
// MARK: - Public
weak var delegate: TitleViewDelegate?
var text: String? {
get {
return label.text
}
set {
label.text = newValue
}
}
enum TitleViewRotationDirection {
case up, down
}
func rotateIcon(_ direction: TitleViewRotationDirection) {
switch direction {
case .up:
// *slightly* more than `pi` to ensure the chevron animates counter-clockwise
let chevronRotationAngle = CGFloat.pi + 0.001
iconView.transform = CGAffineTransform(rotationAngle: chevronRotationAngle)
case .down:
iconView.transform = .identity
}
}
// MARK: - Events
@objc
private func titleTapped(_ tapGesture: UITapGestureRecognizer) {
self.delegate?.titleViewWasTapped(self)
}
}
extension ImagePickerGridController: TitleViewDelegate {
fileprivate func titleViewWasTapped(_ titleView: TitleView) {
if isShowingCollectionPickerController {
hideCollectionPicker()
} else {
showCollectionPicker()
}
}
}