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
This commit is contained in:
Shadowfacts 2020-03-14 20:05:29 -04:00
parent 7117ce6320
commit 1ccb450477
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
5 changed files with 194 additions and 17 deletions

View File

@ -2,6 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array/>
<key>UTTypeIdentifier</key>
<string>space.vaccor.Tusker.composition-attachment</string>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
@ -49,7 +60,7 @@
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Post videos from the camera.</string> <string>Post videos from the camera.</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos directly from other people's posts.</string> <string>Save photos directly from other people&apos;s posts.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Post photos from the photo library.</string> <string>Post photos from the photo library.</string>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>

View File

@ -23,6 +23,7 @@ class ComposeAttachmentTableViewCell: UITableViewCell {
@IBOutlet weak var assetImageView: UIImageView! @IBOutlet weak var assetImageView: UIImageView!
@IBOutlet weak var descriptionTextView: UITextView! @IBOutlet weak var descriptionTextView: UITextView!
@IBOutlet weak var descriptionPlaceholderLabel: UILabel! @IBOutlet weak var descriptionPlaceholderLabel: UILabel!
@IBOutlet weak var removeButton: UIButton!
var attachment: CompositionAttachment! var attachment: CompositionAttachment!
@ -38,7 +39,7 @@ class ComposeAttachmentTableViewCell: UITableViewCell {
func updateUI(for attachment: CompositionAttachment) { func updateUI(for attachment: CompositionAttachment) {
self.attachment = attachment self.attachment = attachment
descriptionTextView.text = attachment.description descriptionTextView.text = attachment.attachmentDescription
updateDescriptionPlaceholderLabel() updateDescriptionPlaceholderLabel()
switch attachment.data { switch attachment.data {
@ -62,6 +63,16 @@ class ComposeAttachmentTableViewCell: UITableViewCell {
func updateDescriptionPlaceholderLabel() { func updateDescriptionPlaceholderLabel() {
descriptionPlaceholderLabel.isHidden = !descriptionTextView.text.isEmpty 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) { @IBAction func removeButtonPressed(_ sender: Any) {
delegate?.removeAttachment(self) delegate?.removeAttachment(self)
@ -72,7 +83,7 @@ class ComposeAttachmentTableViewCell: UITableViewCell {
extension ComposeAttachmentTableViewCell: UITextViewDelegate { extension ComposeAttachmentTableViewCell: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
delegate?.attachmentDescriptionChanged(self) delegate?.attachmentDescriptionChanged(self)
attachment.description = textView.text attachment.attachmentDescription = textView.text
updateDescriptionPlaceholderLabel() updateDescriptionPlaceholderLabel()
} }
} }

View File

@ -27,6 +27,7 @@
<subviews> <subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="GLY-o8-47z"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="GLY-o8-47z">
<rect key="frame" x="0.0" y="0.0" width="80" height="80"/> <rect key="frame" x="0.0" y="0.0" width="80" height="80"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstAttribute="width" constant="80" id="X6q-g9-dPN"/> <constraint firstAttribute="width" constant="80" id="X6q-g9-dPN"/>
<constraint firstAttribute="height" constant="80" id="xgQ-E3-0QI"/> <constraint firstAttribute="height" constant="80" id="xgQ-E3-0QI"/>
@ -72,6 +73,7 @@
<outlet property="assetImageView" destination="GLY-o8-47z" id="hZH-ur-m4z"/> <outlet property="assetImageView" destination="GLY-o8-47z" id="hZH-ur-m4z"/>
<outlet property="descriptionPlaceholderLabel" destination="h6T-x4-yzl" id="jBe-R0-Sfn"/> <outlet property="descriptionPlaceholderLabel" destination="h6T-x4-yzl" id="jBe-R0-Sfn"/>
<outlet property="descriptionTextView" destination="cwP-Eh-5dJ" id="pxJ-zF-GKC"/> <outlet property="descriptionTextView" destination="cwP-Eh-5dJ" id="pxJ-zF-GKC"/>
<outlet property="removeButton" destination="Lvf-I9-aV3" id="3qk-Zr-je1"/>
</connections> </connections>
<point key="canvasLocation" x="107" y="181"/> <point key="canvasLocation" x="107" y="181"/>
</tableViewCell> </tableViewCell>

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import MobileCoreServices
protocol ComposeAttachmentsViewControllerDelegate: class { protocol ComposeAttachmentsViewControllerDelegate: class {
func composeSelectedAttachmentsDidChange() func composeSelectedAttachmentsDidChange()
@ -55,10 +56,20 @@ class ComposeAttachmentsViewController: UITableViewController {
tableView.register(UINib(nibName: "AddAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "addAttachment") tableView.register(UINib(nibName: "AddAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "addAttachment")
tableView.register(UINib(nibName: "ComposeAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "composeAttachment") 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 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) { override func viewWillAppear(_ animated: Bool) {
@ -77,7 +88,8 @@ class ComposeAttachmentsViewController: UITableViewController {
} }
private func updateHeightConstraint() { 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 { private func isAddAttachmentsButtonEnabled() -> Bool {
@ -90,7 +102,7 @@ class ComposeAttachmentsViewController: UITableViewController {
} }
private func updateAddAttachmentsButtonEnabled() { 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()) cell.setEnabled(isAddAttachmentsButtonEnabled())
} }
@ -105,16 +117,15 @@ class ComposeAttachmentsViewController: UITableViewController {
override func paste(itemProviders: [NSItemProvider]) { override func paste(itemProviders: [NSItemProvider]) {
for provider in itemProviders { for provider in itemProviders {
provider.loadObject(ofClass: UIImage.self) { (object, error) in provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
if let error = error { if let error = error {
fatalError("Couldn't load image from NSItemProvider: \(error)") fatalError("Couldn't load image from NSItemProvider: \(error)")
} }
guard let image = object as? UIImage else { guard let attachment = object as? CompositionAttachment else {
fatalError("Couldn't convert object from NSItemProvider to UIImage") fatalError("Couldn't convert object from NSItemProvider to CompositionAttachment")
} }
DispatchQueue.main.async { DispatchQueue.main.async {
let attachment = CompositionAttachment(data: .image(image))
self.attachments.append(attachment) self.attachments.append(attachment)
self.tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic) self.tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
self.updateHeightConstraint() self.updateHeightConstraint()
@ -219,6 +230,7 @@ class ComposeAttachmentsViewController: UITableViewController {
let cell = tableView.dequeueReusableCell(withIdentifier: "composeAttachment", for: indexPath) as! ComposeAttachmentTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: "composeAttachment", for: indexPath) as! ComposeAttachmentTableViewCell
cell.delegate = self cell.delegate = self
cell.updateUI(for: attachment) cell.updateUI(for: attachment)
cell.setEnabled(true)
return cell return cell
case 1: case 1:
let cell = tableView.dequeueReusableCell(withIdentifier: "addAttachment", for: indexPath) as! AddAttachmentTableViewCell 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 // MARK: Table view delegate
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { 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 { extension ComposeAttachmentsViewController: AssetPickerViewControllerDelegate {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool { func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
switch mastodonController.instance.instanceType { switch mastodonController.instance.instanceType {

View File

@ -7,19 +7,72 @@
// //
import Foundation 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 let data: CompositionAttachmentData
var description: String var attachmentDescription: String
init(data: CompositionAttachmentData, description: String = "") { init(data: CompositionAttachmentData, description: String = "") {
self.data = data self.data = data
self.description = description self.attachmentDescription = description
} }
}
extension CompositionAttachment: Equatable {
static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool { static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool {
return lhs.data == rhs.data 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
}
}
}