// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation import Photos import PromiseKit @objc(OWSImagePickerControllerDelegate) protocol ImagePickerControllerDelegate { func imagePicker(_ imagePicker: ImagePickerGridController, didPickImageAttachments attachments: [SignalAttachment], messageText: String?) } @objc(OWSImagePickerGridController) class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate, PhotoCollectionPickerDelegate, AttachmentApprovalViewControllerDelegate { @objc weak var delegate: ImagePickerControllerDelegate? private let library: PhotoLibrary = PhotoLibrary() private var photoCollection: PhotoCollection private var photoCollectionContents: PhotoCollectionContents private let photoMediaSize = PhotoMediaSize() var collectionViewFlowLayout: UICollectionViewFlowLayout private let titleLabel = UILabel() private let titleIconView = UIImageView() private var selectedIds = Set() // This variable should only be accessed on the main thread. private var assetIdToCommentMap = [String: String]() init() { collectionViewFlowLayout = type(of: self).buildLayout() photoCollection = library.defaultPhotoCollection() photoCollectionContents = photoCollection.contents() super.init(collectionViewLayout: collectionViewFlowLayout) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - View Lifecycle override func viewDidLoad() { super.viewDidLoad() library.add(delegate: self) guard let collectionView = collectionView else { owsFailDebug("collectionView was unexpectedly nil") return } collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) view.backgroundColor = .ows_gray95 let cancelButton = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(didPressCancel)) cancelButton.tintColor = .ows_gray05 navigationItem.leftBarButtonItem = cancelButton if #available(iOS 11, *) { titleLabel.text = photoCollection.localizedTitle() titleLabel.textColor = .ows_gray05 titleLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight() titleIconView.tintColor = .ows_gray05 titleIconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate) let titleView = UIStackView(arrangedSubviews: [titleLabel, titleIconView]) titleView.axis = .horizontal titleView.alignment = .center titleView.spacing = 5 titleView.isUserInteractionEnabled = true titleView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped))) navigationItem.titleView = titleView } else { navigationItem.title = photoCollection.localizedTitle() } let featureFlag_isMultiselectEnabled = true if featureFlag_isMultiselectEnabled { updateSelectButton() } collectionView.backgroundColor = .ows_gray95 } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() updateLayout() } var hasEverAppeared: Bool = false override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if let navBar = self.navigationController?.navigationBar as? OWSNavigationBar { navBar.overrideTheme(type: .alwaysDark) } else { owsFailDebug("Invalid nav bar.") } // Determine the size of the thumbnails to request let scale = UIScreen.main.scale let cellSize = collectionViewFlowLayout.itemSize photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale) reloadDataAndRestoreSelection() if !hasEverAppeared { hasEverAppeared = true scrollToBottom(animated: false) } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // done button may have been disable from the last time we hit "Done" // make sure to re-enable it if appropriate upon returning to the view hasPressedDoneSinceAppeared = false updateDoneButton() // Since we're presenting *over* the ConversationVC, we need to `becomeFirstResponder`. // // Otherwise, the `ConversationVC.inputAccessoryView` will appear over top of us whenever // OWSWindowManager window juggling executes `[rootWindow makeKeyAndVisible]`. // // We don't need to do this when pushing VCs onto the SignalsNavigationController - only when // presenting directly from ConversationVC. _ = self.becomeFirstResponder() } // HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does. // If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible // the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder. override public var canBecomeFirstResponder: Bool { Logger.debug("") return true } // MARK: func scrollToBottom(animated: Bool) { self.view.layoutIfNeeded() guard let collectionView = collectionView else { owsFailDebug("collectionView was unexpectedly nil") return } // We could try to be more precise by doing something like // // let botomOffset = collectionView.contentSize + collectionView.contentInset.top - collectionView.bounds.height // // But `collectionView.contentInset` is based on `safeAreaInsets`, which isn't accurate // until `viewDidAppear` at the earliest. // // from https://developer.apple.com/documentation/uikit/uiview/positioning_content_relative_to_the_safe_area // > Make your modifications in [viewDidAppear] because the safe area insets for a view are // > not accurate until the view is added to a view hierarchy. // // Overshooting like this works without visible animation glitch. let bottomOffset = CGFloat.greatestFiniteMagnitude collectionView.setContentOffset(CGPoint(x: 0, y: bottomOffset), animated: animated) } private func reloadDataAndRestoreSelection() { guard let collectionView = collectionView else { owsFailDebug("Missing collectionView.") return } collectionView.reloadData() collectionView.layoutIfNeeded() let count = photoCollectionContents.assetCount for index in 0.. UICollectionViewFlowLayout { let layout = UICollectionViewFlowLayout() if #available(iOS 11, *) { layout.sectionInsetReference = .fromSafeArea } layout.minimumInteritemSpacing = kInterItemSpacing layout.minimumLineSpacing = kInterItemSpacing layout.sectionHeadersPinToVisibleBounds = true return layout } func updateLayout() { let containerWidth: CGFloat if #available(iOS 11.0, *) { containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width } else { containerWidth = self.view.frame.size.width } let kItemsPerPortraitRow = 4 let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow) let itemCount = round(containerWidth / approxItemWidth) let spaceWidth = (itemCount + 1) * type(of: self).kInterItemSpacing let availableWidth = containerWidth - spaceWidth let itemWidth = floor(availableWidth / CGFloat(itemCount)) let newItemSize = CGSize(width: itemWidth, height: itemWidth) if (newItemSize != collectionViewFlowLayout.itemSize) { collectionViewFlowLayout.itemSize = newItemSize collectionViewFlowLayout.invalidateLayout() } } // MARK: - Batch Selection lazy var doneButton: UIBarButtonItem = { return UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(didPressDone)) }() lazy var selectButton: UIBarButtonItem = { return UIBarButtonItem(title: NSLocalizedString("BUTTON_SELECT", comment: "Button text to enable batch selection mode"), style: .plain, target: self, action: #selector(didTapSelect)) }() var isInBatchSelectMode = false { didSet { collectionView!.allowsMultipleSelection = isInBatchSelectMode updateSelectButton() updateDoneButton() } } @objc func didPressDone(_ 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 } hasPressedDoneSinceAppeared = true updateDoneButton() let assets: [PHAsset] = indexPaths.compactMap { return photoCollectionContents.asset(at: $0.row) } complete(withAssets: assets) } func complete(withAssets assets: [PHAsset]) { let attachmentPromises: [Promise] = assets.map({ return photoCollectionContents.outgoingAttachment(for: $0) }) when(fulfilled: attachmentPromises) .map { attachments in self.didComplete(withAttachments: attachments) }.retainUntilComplete() } private func didComplete(withAttachments attachments: [SignalAttachment]) { AssertIsOnMainThread() // If we re-enter image picking, do so in batch mode. isInBatchSelectMode = true for attachment in attachments { guard let assetId = attachment.assetId else { owsFailDebug("Attachment is missing asset id.") continue } // Link the attachment with its asset to ensure caption continuity. attachment.assetId = assetId // Restore any existing caption for this attachment. attachment.captionText = assetIdToCommentMap[assetId] } let vc = AttachmentApprovalViewController(mode: .sharedNavigation, attachments: attachments) vc.approvalDelegate = self navigationController?.pushViewController(vc, animated: true) } var hasPressedDoneSinceAppeared: Bool = false func updateDoneButton() { guard let collectionView = self.collectionView else { owsFailDebug("collectionView was unexpectedly nil") return } guard !hasPressedDoneSinceAppeared else { doneButton.isEnabled = false return } if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 { doneButton.isEnabled = true } else { doneButton.isEnabled = false } } func updateSelectButton() { guard !isShowingCollectionPickerController else { navigationItem.rightBarButtonItem = nil return } let button = isInBatchSelectMode ? doneButton : selectButton button.tintColor = .ows_gray05 navigationItem.rightBarButtonItem = button } @objc func didTapSelect(_ sender: Any) { isInBatchSelectMode = true // disabled until at least one item is selected self.doneButton.isEnabled = false } @objc func didCancelSelect(_ sender: Any) { endSelectMode() } func endSelectMode() { isInBatchSelectMode = false guard let collectionView = self.collectionView else { owsFailDebug("collectionView was unexpectedly nil") return } // deselect any selected collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false)} } // MARK: - PhotoLibraryDelegate func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { photoCollectionContents = photoCollection.contents() reloadDataAndRestoreSelection() } // MARK: - PhotoCollectionPicker Presentation var isShowingCollectionPickerController: Bool { return collectionPickerController != nil } var collectionPickerController: PhotoCollectionPickerController? func showCollectionPicker() { Logger.debug("") let collectionPickerController = PhotoCollectionPickerController(library: library, previousPhotoCollection: photoCollection, collectionDelegate: self) guard let collectionPickerView = collectionPickerController.view else { owsFailDebug("collectionView was unexpectedly nil") return } assert(self.collectionPickerController == nil) self.collectionPickerController = collectionPickerController addChildViewController(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.3, delay: 0, options: .curveEaseInOut) { collectionPickerView.superview?.layoutIfNeeded() self.updateSelectButton() // *slightly* more than `pi` to ensure the chevron animates counter-clockwise let chevronRotationAngle = CGFloat.pi.nextUp self.titleIconView.transform = CGAffineTransform(rotationAngle: chevronRotationAngle) }.retainUntilComplete() } func hideCollectionPicker() { Logger.debug("") guard let collectionPickerController = collectionPickerController else { owsFailDebug("collectionPickerController was unexpectedly nil") return } self.collectionPickerController = nil UIView.animate(.promise, duration: 0.3, delay: 0, options: .curveEaseInOut) { collectionPickerController.view.frame = self.view.frame.offsetBy(dx: 0, dy: self.view.frame.height) self.updateSelectButton() self.titleIconView.transform = .identity }.done { _ in collectionPickerController.view.removeFromSuperview() collectionPickerController.removeFromParentViewController() }.retainUntilComplete() } // MARK: - PhotoCollectionPickerDelegate func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection) { guard photoCollection != collection else { hideCollectionPicker() return } // Iff we switched albums, discard any selection and make sure the "Select" button shows, // not the "Done" button endSelectMode() photoCollection = collection photoCollectionContents = photoCollection.contents() if #available(iOS 11, *) { titleLabel.text = photoCollection.localizedTitle() } else { navigationItem.title = photoCollection.localizedTitle() } collectionView?.reloadData() hideCollectionPicker() } // MARK: - Event Handlers @objc func titleTapped(sender: UIGestureRecognizer) { guard sender.state == .recognized else { return } if isShowingCollectionPickerController { hideCollectionPicker() } else { showCollectionPicker() } } // MARK: - UICollectionView override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { guard let indexPathsForSelectedItems = collectionView.indexPathsForSelectedItems else { return true } return indexPathsForSelectedItems.count < SignalAttachment.maxAttachmentsAllowed } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let asset = photoCollectionContents.asset(at: indexPath.item) let assetId = asset.localIdentifier selectedIds.insert(assetId) if isInBatchSelectMode { updateDoneButton() } else { // Don't show "selected" badge unless we're in batch mode collectionView.deselectItem(at: indexPath, animated: false) complete(withAssets: [asset]) } } public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { Logger.debug("") let asset = photoCollectionContents.asset(at: indexPath.item) let assetId = asset.localIdentifier selectedIds.remove(assetId) if isInBatchSelectMode { updateDoneButton() } } override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return photoCollectionContents.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 = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) cell.configure(item: assetItem) let assetId = assetItem.asset.localIdentifier let isSelected = selectedIds.contains(assetId) cell.isSelected = isSelected return cell } // MARK: - AttachmentApprovalViewControllerDelegate func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { self.dismiss(animated: true) { self.delegate?.imagePicker(self, didPickImageAttachments: attachments, messageText: messageText) } } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didCancelAttachments attachments: [SignalAttachment]) { navigationController?.popToViewController(self, animated: true) } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, addMoreToAttachments attachments: [SignalAttachment]) { navigationController?.popToViewController(self, animated: true) } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, changedCaptionOfAttachment attachment: SignalAttachment) { AssertIsOnMainThread() guard let assetId = attachment.assetId else { owsFailDebug("Attachment missing source id.") return } guard let captionText = attachment.captionText, captionText.count > 0 else { assetIdToCommentMap.removeValue(forKey: assetId) return } assetIdToCommentMap[assetId] = captionText } }