From 1ccb45047780f2698e5fa642fbf15cc48d542a66 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 14 Mar 2020 20:05:29 -0400 Subject: [PATCH] Support dragging and dropping attachments in the compose view controller Allos dragging in attachments from other apps and drag/dropping with the compose VC to reorder attachments --- Tusker/Info.plist | 13 +- .../ComposeAttachmentTableViewCell.swift | 15 ++- .../ComposeAttachmentTableViewCell.xib | 2 + .../ComposeAttachmentsViewController.swift | 116 ++++++++++++++++-- .../Attachments/CompositionAttachment.swift | 65 +++++++++- 5 files changed, 194 insertions(+), 17 deletions(-) diff --git a/Tusker/Info.plist b/Tusker/Info.plist index 0e365ddbeb..bb4554bd73 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -2,6 +2,17 @@ + UTExportedTypeDeclarations + + + UTTypeConformsTo + + UTTypeIdentifier + space.vaccor.Tusker.composition-attachment + UTTypeTagSpecification + + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -49,7 +60,7 @@ NSMicrophoneUsageDescription Post videos from the camera. NSPhotoLibraryAddUsageDescription - Save photos directly from other people's posts. + Save photos directly from other people's posts. NSPhotoLibraryUsageDescription Post photos from the photo library. NSUserActivityTypes diff --git a/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.swift b/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.swift index df8ab2e6db..5d95eac20e 100644 --- a/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.swift +++ b/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.swift @@ -23,6 +23,7 @@ class ComposeAttachmentTableViewCell: UITableViewCell { @IBOutlet weak var assetImageView: UIImageView! @IBOutlet weak var descriptionTextView: UITextView! @IBOutlet weak var descriptionPlaceholderLabel: UILabel! + @IBOutlet weak var removeButton: UIButton! var attachment: CompositionAttachment! @@ -38,7 +39,7 @@ class ComposeAttachmentTableViewCell: UITableViewCell { func updateUI(for attachment: CompositionAttachment) { self.attachment = attachment - descriptionTextView.text = attachment.description + descriptionTextView.text = attachment.attachmentDescription updateDescriptionPlaceholderLabel() switch attachment.data { @@ -62,6 +63,16 @@ class ComposeAttachmentTableViewCell: UITableViewCell { func updateDescriptionPlaceholderLabel() { descriptionPlaceholderLabel.isHidden = !descriptionTextView.text.isEmpty } + + func setEnabled(_ enabled: Bool) { + descriptionTextView.isEditable = enabled + removeButton.isEnabled = enabled + } + + override func prepareForReuse() { + super.prepareForReuse() + assetImageView.image = nil + } @IBAction func removeButtonPressed(_ sender: Any) { delegate?.removeAttachment(self) @@ -72,7 +83,7 @@ class ComposeAttachmentTableViewCell: UITableViewCell { extension ComposeAttachmentTableViewCell: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { delegate?.attachmentDescriptionChanged(self) - attachment.description = textView.text + attachment.attachmentDescription = textView.text updateDescriptionPlaceholderLabel() } } diff --git a/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.xib b/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.xib index 65e006db13..acabfc1883 100644 --- a/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.xib +++ b/Tusker/Screens/Compose/Attachments/ComposeAttachmentTableViewCell.xib @@ -27,6 +27,7 @@ + @@ -72,6 +73,7 @@ + diff --git a/Tusker/Screens/Compose/Attachments/ComposeAttachmentsViewController.swift b/Tusker/Screens/Compose/Attachments/ComposeAttachmentsViewController.swift index b3ecab0b64..0a06b9fe2a 100644 --- a/Tusker/Screens/Compose/Attachments/ComposeAttachmentsViewController.swift +++ b/Tusker/Screens/Compose/Attachments/ComposeAttachmentsViewController.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import MobileCoreServices protocol ComposeAttachmentsViewControllerDelegate: class { func composeSelectedAttachmentsDidChange() @@ -55,10 +56,20 @@ class ComposeAttachmentsViewController: UITableViewController { tableView.register(UINib(nibName: "AddAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "addAttachment") tableView.register(UINib(nibName: "ComposeAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "composeAttachment") - heightConstraint = tableView.heightAnchor.constraint(equalToConstant: tableView.contentSize.height) + // you would think the table view could handle this itself, but no, using a constraint on the table view's contentLayoutGuide doesn't work + // add extra space, so when dropping items, the add attachment cell doesn't disappear + heightConstraint = tableView.heightAnchor.constraint(equalToConstant: tableView.contentSize.height + 80) heightConstraint.isActive = true + + // prevents extra separator lines from appearing when the height of the table view is greater than the height of the content + tableView.tableFooterView = UIView() - pasteConfiguration = UIPasteConfiguration(forAccepting: UIImage.self) + pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) + + // enable dragging on iPhone to allow reordering + tableView.dragInteractionEnabled = true + tableView.dragDelegate = self + tableView.dropDelegate = self } override func viewWillAppear(_ animated: Bool) { @@ -77,7 +88,8 @@ class ComposeAttachmentsViewController: UITableViewController { } private func updateHeightConstraint() { - heightConstraint.constant = tableView.contentSize.height + // add extra space, so when dropping items, the add attachment cell doesn't disappear + heightConstraint.constant = tableView.contentSize.height + 80 } private func isAddAttachmentsButtonEnabled() -> Bool { @@ -90,7 +102,7 @@ class ComposeAttachmentsViewController: UITableViewController { } private func updateAddAttachmentsButtonEnabled() { - let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as! AddAttachmentTableViewCell + guard let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? AddAttachmentTableViewCell else { return } cell.setEnabled(isAddAttachmentsButtonEnabled()) } @@ -105,16 +117,15 @@ class ComposeAttachmentsViewController: UITableViewController { override func paste(itemProviders: [NSItemProvider]) { for provider in itemProviders { - provider.loadObject(ofClass: UIImage.self) { (object, error) in + provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in if let error = error { fatalError("Couldn't load image from NSItemProvider: \(error)") } - guard let image = object as? UIImage else { - fatalError("Couldn't convert object from NSItemProvider to UIImage") + guard let attachment = object as? CompositionAttachment else { + fatalError("Couldn't convert object from NSItemProvider to CompositionAttachment") } DispatchQueue.main.async { - let attachment = CompositionAttachment(data: .image(image)) self.attachments.append(attachment) self.tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic) self.updateHeightConstraint() @@ -219,6 +230,7 @@ class ComposeAttachmentsViewController: UITableViewController { let cell = tableView.dequeueReusableCell(withIdentifier: "composeAttachment", for: indexPath) as! ComposeAttachmentTableViewCell cell.delegate = self cell.updateUI(for: attachment) + cell.setEnabled(true) return cell case 1: let cell = tableView.dequeueReusableCell(withIdentifier: "addAttachment", for: indexPath) as! AddAttachmentTableViewCell @@ -229,6 +241,12 @@ class ComposeAttachmentsViewController: UITableViewController { } } + override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + guard sourceIndexPath != destinationIndexPath, sourceIndexPath.section == 0, destinationIndexPath.section == 0 else { return } + + attachments.insert(attachments.remove(at: sourceIndexPath.row), at: destinationIndexPath.row) + } + // MARK: Table view delegate override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { @@ -254,6 +272,88 @@ class ComposeAttachmentsViewController: UITableViewController { } +extension ComposeAttachmentsViewController: UITableViewDragDelegate { + func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + let attachment = attachments[indexPath.row] + let provider = NSItemProvider(object: attachment) + let dragItem = UIDragItem(itemProvider: provider) + dragItem.localObject = attachment + return [dragItem] + } + + func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] { + let attachment = attachments[indexPath.row] + let provider = NSItemProvider(object: attachment) + let dragItem = UIDragItem(itemProvider: provider) + dragItem.localObject = attachment + return [dragItem] + } + + func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? { + let cell = tableView.cellForRow(at: indexPath) as! ComposeAttachmentTableViewCell + let rect = cell.convert(cell.assetImageView.bounds, from: cell.assetImageView) + let path = UIBezierPath(roundedRect: rect, cornerRadius: cell.assetImageView.layer.cornerRadius) + let params = UIDragPreviewParameters() + params.visiblePath = path + return params + } +} + +extension ComposeAttachmentsViewController: UITableViewDropDelegate { + func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool { + return session.canLoadObjects(ofClass: CompositionAttachment.self) + } + + func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { + // if items were dragged out of ourself, then the items are only being moved + if tableView.hasActiveDrag { + // todo: should moving multiple items actually be prohibited? + if session.items.count > 1 { + return UITableViewDropProposal(operation: .cancel) + } else { + return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + } else { + return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath) + } + } + + func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { + let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(row: attachments.count, section: 0) + + // we don't need to handle local items here, when the .move operation is used returned from the tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) method, + // the table view will handle animating and call the normal data source tableView(_:moveRowAt:to:) + + for (index, item) in coordinator.items.enumerated() { + let provider = item.dragItem.itemProvider + + if provider.canLoadObject(ofClass: CompositionAttachment.self) { + let indexPath = IndexPath(row: destinationIndexPath.row + index, section: 0) + let placeholder = UITableViewDropPlaceholder(insertionIndexPath: indexPath, reuseIdentifier: "composeAttachment", rowHeight: 96) + placeholder.cellUpdateHandler = { (cell) in + let cell = cell as! ComposeAttachmentTableViewCell + cell.setEnabled(false) + } + let placeholderContext = coordinator.drop(item.dragItem, to: placeholder) + + provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in + DispatchQueue.main.async { + if let attachment = object as? CompositionAttachment { + placeholderContext.commitInsertion { (insertionIndexPath) in + self.attachments.insert(attachment, at: insertionIndexPath.row) + } + } else { + placeholderContext.deletePlaceholder() + } + } + } + } + } + + updateHeightConstraint() + } +} + extension ComposeAttachmentsViewController: AssetPickerViewControllerDelegate { func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool { switch mastodonController.instance.instanceType { diff --git a/Tusker/Screens/Compose/Attachments/CompositionAttachment.swift b/Tusker/Screens/Compose/Attachments/CompositionAttachment.swift index 477e09c3b8..27d1d585b3 100644 --- a/Tusker/Screens/Compose/Attachments/CompositionAttachment.swift +++ b/Tusker/Screens/Compose/Attachments/CompositionAttachment.swift @@ -7,19 +7,72 @@ // import Foundation +import UIKit +import MobileCoreServices -class CompositionAttachment: Codable { +final class CompositionAttachment: NSObject, Codable { + static let typeIdentifier = "space.vaccor.Tusker.composition-attachment" + let data: CompositionAttachmentData - var description: String + var attachmentDescription: String init(data: CompositionAttachmentData, description: String = "") { self.data = data - self.description = description + self.attachmentDescription = description } -} - -extension CompositionAttachment: Equatable { + static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool { return lhs.data == rhs.data } } + +private let imageType = kUTTypeImage as String +private let dataType = kUTTypeData as String + +extension CompositionAttachment: NSItemProviderWriting { + static var writableTypeIdentifiersForItemProvider: [String] { + [typeIdentifier] + } + + func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? { + if typeIdentifier == CompositionAttachment.typeIdentifier { + do { + completionHandler(try PropertyListEncoder().encode(self), nil) + } catch { + completionHandler(nil, error) + } + } + + completionHandler(nil, ItemProviderError.incompatibleTypeIdentifier) + return nil + } + + enum ItemProviderError: Error { + case incompatibleTypeIdentifier + + var localizedDescription: String { + switch self { + case .incompatibleTypeIdentifier: + return "Cannot provide data for given type" + } + } + } +} + +extension CompositionAttachment: NSItemProviderReading { + static var readableTypeIdentifiersForItemProvider: [String] { + [typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + NSURL.readableTypeIdentifiersForItemProvider + } + + static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self { + if typeIdentifier == CompositionAttachment.typeIdentifier { + return try PropertyListDecoder().decode(Self.self, from: data) + } else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) { + return CompositionAttachment(data: .image(image)) as! Self + } else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL { + return CompositionAttachment(data: .video(url)) as! Self + } else { + throw ItemProviderError.incompatibleTypeIdentifier + } + } +}