Go back to using one List for everything in compose
This commit is contained in:
parent
381f3ee737
commit
57990f8339
|
@ -131,7 +131,6 @@ class AttachmentsListController: ViewController {
|
||||||
@EnvironmentObject private var controller: AttachmentsListController
|
@EnvironmentObject private var controller: AttachmentsListController
|
||||||
@EnvironmentObject private var draft: Draft
|
@EnvironmentObject private var draft: Draft
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
attachmentsList
|
attachmentsList
|
||||||
|
@ -217,7 +216,7 @@ fileprivate extension View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(visionOS 1.0, *)
|
@available(visionOS 1.0, *)
|
||||||
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
struct AttachmentButtonLabelStyle: LabelStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
DefaultLabelStyle().makeBody(configuration: configuration)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<DraftAttachment>) {
|
|
||||||
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<Section, Item>(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<DraftAttachment>.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<DraftAttachment>.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<Section, Item>()
|
|
||||||
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<WrappedCollectionView.Section, WrappedCollectionView.Item>!
|
|
||||||
|
|
||||||
init(callbacks: Callbacks) {
|
|
||||||
self.callbacks = callbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in
|
|
||||||
cell.contentConfiguration = UIHostingConfiguration {
|
|
||||||
AttachmentRowView(attachment: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let buttonCell = UICollectionView.CellRegistration<UICollectionViewListCell, (WrappedCollectionView.Button, Bool)> { 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()
|
|
||||||
//}
|
|
|
@ -22,9 +22,10 @@ struct ComposeView: View {
|
||||||
|
|
||||||
private var navigationRoot: some View {
|
private var navigationRoot: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ScrollView(.vertical) {
|
List {
|
||||||
scrollContent
|
listContent
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
||||||
.modifier(ToolbarSafeAreaInsetModifier())
|
.modifier(ToolbarSafeAreaInsetModifier())
|
||||||
|
@ -46,6 +47,9 @@ struct ComposeView: View {
|
||||||
.ignoresSafeArea(.keyboard)
|
.ignoresSafeArea(.keyboard)
|
||||||
})
|
})
|
||||||
#endif
|
#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))
|
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
@ -63,19 +67,28 @@ struct ComposeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var scrollContent: some View {
|
private var listContent: some View {
|
||||||
VStack(spacing: 4) {
|
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
|
||||||
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
NewMainTextView(value: $draft.text, focusedField: $focusedField)
|
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
AttachmentsListView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
}
|
}
|
||||||
.padding(8)
|
|
||||||
|
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 {
|
private var postButton: some View {
|
||||||
|
// TODO: don't use the controller for this
|
||||||
Button(action: controller.postStatus) {
|
Button(action: controller.postStatus) {
|
||||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,10 +12,11 @@ struct NewMainTextView: View {
|
||||||
|
|
||||||
@Binding var value: String
|
@Binding var value: String
|
||||||
@FocusState.Binding var focusedField: FocusableField?
|
@FocusState.Binding var focusedField: FocusableField?
|
||||||
|
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||||
@State private var becomeFirstResponder = true
|
@State private var becomeFirstResponder = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder)
|
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
|
||||||
.focused($focusedField, equals: .body)
|
.focused($focusedField, equals: .body)
|
||||||
.modifier(FocusedInputModifier())
|
.modifier(FocusedInputModifier())
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
|
@ -29,6 +30,7 @@ struct NewMainTextView: View {
|
||||||
private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
@Binding var value: String
|
@Binding var value: String
|
||||||
@Binding var becomeFirstResponder: Bool
|
@Binding var becomeFirstResponder: Bool
|
||||||
|
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||||
@Environment(\.composeInputBox) private var inputBox
|
@Environment(\.composeInputBox) private var inputBox
|
||||||
@Environment(\.isEnabled) private var isEnabled
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@ -40,6 +42,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
|
||||||
let view = WrappedTextView(usingTextLayoutManager: true)
|
let view = WrappedTextView(usingTextLayoutManager: true)
|
||||||
|
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
|
||||||
view.delegate = context.coordinator
|
view.delegate = context.coordinator
|
||||||
view.adjustsFontForContentSizeCategory = true
|
view.adjustsFontForContentSizeCategory = true
|
||||||
view.textContainer.lineBreakMode = .byWordWrapping
|
view.textContainer.lineBreakMode = .byWordWrapping
|
||||||
|
@ -67,6 +70,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||||
context.coordinator.value = $value
|
context.coordinator.value = $value
|
||||||
|
context.coordinator.handleAttachmentDrop = handleAttachmentDrop
|
||||||
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
|
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
|
||||||
|
|
||||||
uiView.isEditable = isEnabled
|
uiView.isEditable = isEnabled
|
||||||
|
@ -86,7 +90,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> WrappedTextViewCoordinator {
|
func makeCoordinator() -> WrappedTextViewCoordinator {
|
||||||
let coordinator = WrappedTextViewCoordinator(value: $value)
|
let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop)
|
||||||
// DispatchQueue.main.async {
|
// DispatchQueue.main.async {
|
||||||
// inputBox.wrappedValue = coordinator
|
// inputBox.wrappedValue = coordinator
|
||||||
// }
|
// }
|
||||||
|
@ -120,9 +124,11 @@ private final class WrappedTextViewCoordinator: NSObject {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var value: Binding<String>
|
var value: Binding<String>
|
||||||
|
var handleAttachmentDrop: ([NSItemProvider]) -> Void
|
||||||
|
|
||||||
init(value: Binding<String>) {
|
init(value: Binding<String>, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) {
|
||||||
self.value = value
|
self.value = value
|
||||||
|
self.handleAttachmentDrop = handleAttachmentDrop
|
||||||
}
|
}
|
||||||
|
|
||||||
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
|
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 {
|
private final class WrappedTextView: UITextView {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue