From 57990f83393e1662e38e338411e64e61bd8edaa3 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 12 Nov 2024 10:29:48 -0500 Subject: [PATCH] Go back to using one List for everything in compose --- .../AttachmentsListController.swift | 3 +- .../Views/AttachmentsListSection.swift | 182 +++++++++ .../ComposeUI/Views/AttachmentsListView.swift | 351 ------------------ .../Sources/ComposeUI/Views/ComposeView.swift | 44 ++- .../ComposeUI/Views/NewMainTextView.swift | 27 +- 5 files changed, 236 insertions(+), 371 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListSection.swift delete mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift index 98af1926..f502e0a5 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift @@ -131,7 +131,6 @@ class AttachmentsListController: ViewController { @EnvironmentObject private var controller: AttachmentsListController @EnvironmentObject private var draft: Draft @Environment(\.colorScheme) private var colorScheme - @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { attachmentsList @@ -217,7 +216,7 @@ fileprivate extension View { } @available(visionOS 1.0, *) -fileprivate struct AttachmentButtonLabelStyle: LabelStyle { +struct AttachmentButtonLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { DefaultLabelStyle().makeBody(configuration: configuration) .foregroundStyle(.white) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListSection.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListSection.swift new file mode 100644 index 00000000..deb8da46 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListSection.swift @@ -0,0 +1,182 @@ +// +// AttachmentsListSection.swift +// ComposeUI +// +// Created by Shadowfacts on 10/14/24. +// + +import SwiftUI +import InstanceFeatures +import PencilKit + +struct AttachmentsListSection: View { + @ObservedObject var draft: Draft + @ObservedObject var instanceFeatures: InstanceFeatures + @Environment(\.canAddAttachment) private var canAddAttachment + + var body: some View { + attachmentRows + + buttons + .foregroundStyle(.tint) + #if os(visionOS) + .buttonStyle(.bordered) + .labelStyle(AttachmentButtonLabelStyle()) + #endif + } + + private var attachmentRows: some View { + ForEach(draft.draftAttachments) { attachment in + AttachmentRowView(attachment: attachment) + } + .onMove(perform: moveAttachments) + .onDelete(perform: deleteAttachments) + .onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider) { offset, providers in + Self.insertAttachments(in: draft, at: offset, itemProviders: providers) + } + } + + @ViewBuilder + private var buttons: some View { + AddPhotoButton(addAttachments: self.addAttachments) + + AddDrawingButton(draft: draft) + + TogglePollButton(draft: draft) + } + + static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) { + for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { + provider.loadObject(ofClass: DraftAttachment.self) { object, error in + guard let attachment = object as? DraftAttachment else { return } + DispatchQueue.main.async { + DraftsPersistentContainer.shared.viewContext.insert(attachment) + attachment.draft = draft + draft.attachments.add(attachment) + } + } + } + } + + private func addAttachments(itemProviders: [NSItemProvider]) { + Self.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders) + } + + private func moveAttachments(from source: IndexSet, to destination: Int) { + // just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet + // results in the order switching back to the previous order and then to the correct one + // on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem + var array = draft.draftAttachments + array.move(fromOffsets: source, toOffset: destination) + draft.attachments = NSMutableOrderedSet(array: array) + } + + private func deleteAttachments(at indices: IndexSet) { + var array = draft.draftAttachments + array.remove(atOffsets: indices) + draft.attachments = NSMutableOrderedSet(array: array) + } + +} + +private struct AddPhotoButton: View { + let addAttachments: ([NSItemProvider]) -> Void + @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker + + var body: some View { + if let presentAssetPicker { + Button("Add photo or video", systemImage: "photo") { + presentAssetPicker { results in + addAttachments(results.map(\.itemProvider)) + } + } + } + } +} + +private struct AddDrawingButton: View { + let draft: Draft + @Environment(\.composeUIConfig.presentDrawing) private var presentDrawing + + var body: some View { + if let presentDrawing { + Button("Add drawing", systemImage: "hand.draw") { + presentDrawing(PKDrawing()) { drawing in + let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext) + attachment.id = UUID() + attachment.drawing = drawing + attachment.draft = self.draft + self.draft.attachments.add(attachment) + } + } + } + } +} + +private struct TogglePollButton: View { + @ObservedObject var draft: Draft + + var body: some View { + Button(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") { + withAnimation { + draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil + } + } + .disabled(draft.attachments.count > 0) + } +} + +struct AddAttachmentConditionsModifier: ViewModifier { + @ObservedObject var draft: Draft + @ObservedObject var instanceFeatures: InstanceFeatures + + private var canAddAttachment: Bool { + if instanceFeatures.mastodonAttachmentRestrictions { + return draft.attachments.count < 4 + && draft.draftAttachments.allSatisfy { $0.type == .image } + && draft.poll == nil + } else { + return true + } + } + + func body(content: Content) -> some View { + content + .environment(\.canAddAttachment, canAddAttachment) + } +} + +private struct CanAddAttachmentKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var canAddAttachment: Bool { + get { self[CanAddAttachmentKey.self] } + set { self[CanAddAttachmentKey.self] = newValue } + } +} + +struct DropAttachmentModifier: ViewModifier { + let draft: Draft + @Environment(\.canAddAttachment) private var canAddAttachment + + func body(content: Content) -> some View { + content + .onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment)) + } +} + +private struct AttachmentDropDelegate: DropDelegate { + let draft: Draft + let canAddAttachment: Bool + + func validateDrop(info: DropInfo) -> Bool { + canAddAttachment + } + + func performDrop(info: DropInfo) -> Bool { + AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider)) + return true + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift deleted file mode 100644 index c4084461..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift +++ /dev/null @@ -1,351 +0,0 @@ -// -// AttachmentsListView.swift -// ComposeUI -// -// Created by Shadowfacts on 8/16/24. -// - -import SwiftUI -import InstanceFeatures -import CoreData -import PhotosUI - -struct AttachmentsListView: View { - @ObservedObject var draft: Draft - @ObservedObject var instanceFeatures: InstanceFeatures - @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker - - private var canAddAttachment: Bool { - if instanceFeatures.mastodonAttachmentRestrictions { - return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil - } else { - return true - } - } - - private var callbacks: Callbacks { - Callbacks(draft: draft, presentAssetPicker: presentAssetPicker) - } - - var body: some View { - WrappedCollectionView(attachments: draft.draftAttachments, hasPoll: draft.poll != nil, callbacks: callbacks, canAddAttachment: canAddAttachment) - // Impose a minimum height, because otherwise it defaults to zero which prevents the collection - // view from laying out, and leaving the intrinsic content size at zero too. - .frame(minHeight: 50) - .padding(.horizontal, -8) - .environmentObject(instanceFeatures) - } -} - -private struct Callbacks { - let draft: Draft - let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? - - private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { - for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { - provider.loadObject(ofClass: DraftAttachment.self) { object, error in - guard let attachment = object as? DraftAttachment else { return } - DispatchQueue.main.async { -// guard self.canAddAttachment else { -// return -// } - DraftsPersistentContainer.shared.viewContext.insert(attachment) - attachment.draft = self.draft - self.draft.attachments.add(attachment) - } - } - } - } - - func removeAttachment(at index: Int) { - var array = draft.draftAttachments - array.remove(at: index) - draft.attachments = NSMutableOrderedSet(array: array) - } - - func reorderAttachments(with difference: CollectionDifference) { - let array = draft.draftAttachments.applying(difference)! - draft.attachments = NSMutableOrderedSet(array: array) - } - - func addPhoto() { - presentAssetPicker?() { - insertAttachments(at: draft.attachments.count, itemProviders: $0.map(\.itemProvider)) - } - } - - func addDrawing() { - } - - func togglePoll() { - draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil - } -} - -@available(iOS 16.0, *) -private struct WrappedCollectionView: UIViewRepresentable { - let attachments: [DraftAttachment] - let hasPoll: Bool - let callbacks: Callbacks - let canAddAttachment: Bool - - func makeUIView(context: Context) -> UICollectionView { - var config = UICollectionLayoutListConfiguration(appearance: .plain) - config.trailingSwipeActionsConfigurationProvider = { indexPath in - context.coordinator.trailingSwipeActions(for: indexPath) - } - let layout = UICollectionViewCompositionalLayout.list(using: config) - let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout) - context.coordinator.setHeightOfCellBeingDeleted = { - view.heightOfCellBeingDeleted = $0 - } - let dataSource = UICollectionViewDiffableDataSource(collectionView: view) { collectionView, indexPath, itemIdentifier in - context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier) - } - view.dataSource = dataSource - context.coordinator.dataSource = dataSource - view.delegate = context.coordinator - view.isScrollEnabled = false - - dataSource.reorderingHandlers.canReorderItem = { - if case .attachment(_) = $0 { - true - } else { - false - } - } - dataSource.reorderingHandlers.didReorder = { transaction in - let attachmentsChanges = transaction.difference.map { - switch $0 { - case .insert(let offset, let element, let associatedWith): - guard case .attachment(let attachment) = element else { fatalError() } - return CollectionDifference.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith) - case .remove(let offset, let element, let associatedWith): - guard case .attachment(let attachment) = element else { fatalError() } - return CollectionDifference.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith) - } - } - let attachmentsDiff = CollectionDifference(attachmentsChanges)! - callbacks.reorderAttachments(with: attachmentsDiff) - } - let longPressRecognizer = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(WrappedCollectionViewCoordinator.reorderingLongPressRecognized)) - longPressRecognizer.delegate = context.coordinator - view.addGestureRecognizer(longPressRecognizer) - return view - } - - func updateUIView(_ uiView: UICollectionView, context: Context) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.attachments, .buttons]) - snapshot.appendItems(attachments.map { - .attachment($0) - }, toSection: .attachments) - snapshot.appendItems([ - .button(.addPhoto, enabled: canAddAttachment), - .button(.addDrawing, enabled: canAddAttachment), - .button(.togglePoll(adding: !hasPoll), enabled: true) - ], toSection: .buttons) - context.coordinator.dataSource.apply(snapshot) - context.coordinator.callbacks = callbacks - } - - func makeCoordinator() -> WrappedCollectionViewCoordinator { - WrappedCollectionViewCoordinator(callbacks: callbacks) - } - - enum Section: Hashable { - case attachments, buttons - } - - enum Item: Hashable { - case attachment(DraftAttachment) - case button(Button, enabled: Bool) - - static func ==(lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case let (.attachment(a), .attachment(b)): - return a.objectID == b.objectID - case let (.button(a, aEnabled), .button(b, bEnabled)): - return a == b && aEnabled == bEnabled - default: - return false - } - } - - func hash(into hasher: inout Hasher) { - switch self { - case .attachment(let draftAttachment): - hasher.combine(0) - hasher.combine(draftAttachment.objectID) - case .button(let button, let enabled): - hasher.combine(1) - hasher.combine(button) - hasher.combine(enabled) - } - } - } - - enum Button: Hashable { - case addPhoto - case addDrawing - case togglePoll(adding: Bool) - } -} - -private final class IntrinsicContentSizeCollectionView: UICollectionView { - private var _intrinsicContentSize = CGSize.zero - // This hack is necessary because the content size changes at the beginning of the cell delete animation, - // resulting in the bottommost cell being clipped. - var heightOfCellBeingDeleted: CGFloat = 0 { - didSet { - invalidateIntrinsicContentSize() - } - } - - override var intrinsicContentSize: CGSize { - var size = _intrinsicContentSize - size.height += heightOfCellBeingDeleted - return size - } - - override func layoutSubviews() { - super.layoutSubviews() - - if contentSize != _intrinsicContentSize { - _intrinsicContentSize = contentSize - invalidateIntrinsicContentSize() - } - } -} - -@available(iOS 16.0, *) -private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate, UIGestureRecognizerDelegate { - var callbacks: Callbacks - var setHeightOfCellBeingDeleted: ((CGFloat) -> Void)? - - var dataSource: UICollectionViewDiffableDataSource! - - init(callbacks: Callbacks) { - self.callbacks = callbacks - } - - private let attachmentCell = UICollectionView.CellRegistration { cell, indexPath, item in - cell.contentConfiguration = UIHostingConfiguration { - AttachmentRowView(attachment: item) - } - } - - private let buttonCell = UICollectionView.CellRegistration { cell, indexPath, item in - var config = cell.defaultContentConfiguration() - switch item.0 { - case .addPhoto: - config.image = UIImage(systemName: "photo") - config.text = "Add photo or video" - case .addDrawing: - config.image = UIImage(systemName: "hand.draw") - config.text = "Add drawing" - case .togglePoll(let adding): - config.image = UIImage(systemName: "chart.bar.doc.horizontal") - config.text = adding ? "Add a poll" : "Remove poll" - } - config.textProperties.color = .tintColor - if !item.1 { - config.textProperties.colorTransformer = .monochromeTint - config.imageProperties.tintColorTransformer = .monochromeTint - } - cell.contentConfiguration = config - } - - func makeCell(collectionView: UICollectionView, indexPath: IndexPath, item: WrappedCollectionView.Item) -> UICollectionViewCell { - switch item { - case .attachment(let attachment): - return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment) - case .button(let button, let enabled): - return collectionView.dequeueConfiguredReusableCell(using: buttonCell, for: indexPath, item: (button, enabled)) - } - } - - func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard case .attachment(let attachment) = dataSource.itemIdentifier(for: indexPath) else { - return nil - } - return UISwipeActionsConfiguration(actions: [ - UIContextualAction(style: .destructive, title: "Delete", handler: { _, view, completion in - self.setHeightOfCellBeingDeleted?(view.bounds.height) - // Actually remove the attachment immediately, so that (potentially) the buttons enabling animates. - self.callbacks.removeAttachment(at: indexPath.row) - // Also manually apply a snapshot removing the attachment item, otherwise the delete swipe action animation is messed up. - var snapshot = self.dataSource.snapshot() - snapshot.deleteItems([.attachment(attachment)]) - self.dataSource.apply(snapshot) { - self.setHeightOfCellBeingDeleted?(0) - completion(true) - } - }) - ]) - } - - func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { - switch dataSource.itemIdentifier(for: indexPath)! { - case .attachment: - return false - case .button(_, let enabled): - return enabled - } - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - collectionView.deselectItem(at: indexPath, animated: false) - guard case .button(let button, _) = dataSource.itemIdentifier(for: indexPath) else { - return - } - switch button { - case .addPhoto: - callbacks.addPhoto() - case .addDrawing: - callbacks.addDrawing() - case .togglePoll: - callbacks.togglePoll() - } - } - - func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath { - let snapshot = dataSource.snapshot() - let attachmentsSection = snapshot.indexOfSection(.attachments)! - if proposedIndexPath.section != attachmentsSection { - return IndexPath(item: snapshot.itemIdentifiers(inSection: .attachments).count - 1, section: attachmentsSection) - } else { - return proposedIndexPath - } - } - - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - let collectionView = gestureRecognizer.view as! UICollectionView - let location = gestureRecognizer.location(in: collectionView) - guard let indexPath = collectionView.indexPathForItem(at: location) else { - return false - } - return collectionView.beginInteractiveMovementForItem(at: indexPath) - } - - @objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) { - let collectionView = recognizer.view as! UICollectionView - switch recognizer.state { - case .began: - break - case .changed: - collectionView.updateInteractiveMovementTargetPosition(recognizer.location(in: collectionView)) - case .ended: - collectionView.endInteractiveMovement() - case .cancelled: - collectionView.cancelInteractiveMovement() - default: - break - } - } -} - - -//#Preview { -// AttachmentsListView() -//} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index e3ae532c..42d89810 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -22,9 +22,10 @@ struct ComposeView: View { private var navigationRoot: some View { ZStack { - ScrollView(.vertical) { - scrollContent + List { + listContent } + .listStyle(.plain) .scrollDismissesKeyboard(.interactively) #if !os(visionOS) && !targetEnvironment(macCatalyst) .modifier(ToolbarSafeAreaInsetModifier()) @@ -46,6 +47,9 @@ struct ComposeView: View { .ignoresSafeArea(.keyboard) }) #endif + // Have these after the overlays so they barely work instead of not working at all. FB11790805 + .modifier(DropAttachmentModifier(draft: draft)) + .modifier(AddAttachmentConditionsModifier(draft: draft, instanceFeatures: mastodonController.instanceFeatures)) .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -63,19 +67,28 @@ struct ComposeView: View { } @ViewBuilder - private var scrollContent: some View { - VStack(spacing: 4) { - NewReplyStatusView(draft: draft, mastodonController: mastodonController) - - NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures) - - ContentWarningTextField(draft: draft, focusedField: $focusedField) - - NewMainTextView(value: $draft.text, focusedField: $focusedField) - - AttachmentsListView(draft: draft, instanceFeatures: mastodonController.instanceFeatures) - } - .padding(8) + private var listContent: some View { + NewReplyStatusView(draft: draft, mastodonController: mastodonController) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + + NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + + ContentWarningTextField(draft: draft, focusedField: $focusedField) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + + NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + + AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures) + } + + private func addAttachments(_ itemProviders: [NSItemProvider]) { + AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders) } } @@ -133,6 +146,7 @@ private struct ToolbarActions: ToolbarContent { } private var postButton: some View { + // TODO: don't use the controller for this Button(action: controller.postStatus) { Text(draft.editedStatusID == nil ? "Post" : "Edit") } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift index d352026e..0e09e294 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift @@ -12,10 +12,11 @@ struct NewMainTextView: View { @Binding var value: String @FocusState.Binding var focusedField: FocusableField? + var handleAttachmentDrop: ([NSItemProvider]) -> Void @State private var becomeFirstResponder = true var body: some View { - NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder) + NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop) .focused($focusedField, equals: .body) .modifier(FocusedInputModifier()) .overlay(alignment: .topLeading) { @@ -29,6 +30,7 @@ struct NewMainTextView: View { private struct NewMainTextViewRepresentable: UIViewRepresentable { @Binding var value: String @Binding var becomeFirstResponder: Bool + var handleAttachmentDrop: ([NSItemProvider]) -> Void @Environment(\.composeInputBox) private var inputBox @Environment(\.isEnabled) private var isEnabled @Environment(\.colorScheme) private var colorScheme @@ -40,6 +42,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> UITextView { // TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary let view = WrappedTextView(usingTextLayoutManager: true) + view.addInteraction(UIDropInteraction(delegate: context.coordinator)) view.delegate = context.coordinator view.adjustsFontForContentSizeCategory = true view.textContainer.lineBreakMode = .byWordWrapping @@ -67,6 +70,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable { func updateUIView(_ uiView: UIViewType, context: Context) { context.coordinator.value = $value + context.coordinator.handleAttachmentDrop = handleAttachmentDrop context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView) uiView.isEditable = isEnabled @@ -86,7 +90,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable { } func makeCoordinator() -> WrappedTextViewCoordinator { - let coordinator = WrappedTextViewCoordinator(value: $value) + let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop) // DispatchQueue.main.async { // inputBox.wrappedValue = coordinator // } @@ -120,9 +124,11 @@ private final class WrappedTextViewCoordinator: NSObject { }() var value: Binding + var handleAttachmentDrop: ([NSItemProvider]) -> Void - init(value: Binding) { + init(value: Binding, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) { self.value = value + self.handleAttachmentDrop = handleAttachmentDrop } private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String { @@ -230,6 +236,21 @@ extension WrappedTextViewCoordinator: UITextViewDelegate { // //} +// Because of FB11790805, we can't handle drops for the entire screen. +// The onDrop modifier also doesn't work when applied to the NewMainTextView. +// So, manually add the UIInteraction to at least handle that. +extension WrappedTextViewCoordinator: UIDropInteractionDelegate { + func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool { + session.canLoadObjects(ofClass: DraftAttachment.self) + } + func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) -> UIDropProposal { + UIDropProposal(operation: .copy) + } + func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) { + handleAttachmentDrop(session.items.map(\.itemProvider)) + } +} + private final class WrappedTextView: UITextView { }