diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift index f1002f9b..18093230 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift @@ -13,7 +13,6 @@ import PhotosUI struct AttachmentsListView: View { @ObservedObject var draft: Draft @ObservedObject var instanceFeatures: InstanceFeatures - @State private var attachmentHeights = [NSManagedObjectID: CGFloat]() @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker private var canAddAttachment: Bool { @@ -24,6 +23,30 @@ struct AttachmentsListView: View { } } + private var callbacks: Callbacks { + Callbacks(draft: draft, presentAssetPicker: presentAssetPicker) + } + + var body: some View { + if #available(iOS 16.0, *) { + 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) + } else { + LegacyAttachmentsList(draft: draft, callbacks: callbacks, canAddAttachment: canAddAttachment) + } + } +} + +@available(iOS, obsoleted: 16.0) +private struct LegacyAttachmentsList: View { + @ObservedObject var draft: Draft + let callbacks: Callbacks + let canAddAttachment: Bool + @State private var attachmentHeights = [NSManagedObjectID: CGFloat]() + private var totalHeight: CGFloat { let buttonsHeight = 3 * (40 + AttachmentsListPaddingModifier.cellPadding) let rowHeights = draft.attachments.compactMap { @@ -35,20 +58,12 @@ struct AttachmentsListView: View { } var body: some View { - if #available(iOS 16.0, *) { - WrappedCollectionView(attachments: draft.draftAttachments, hasPoll: draft.poll != nil, callbacks: Callbacks(draft: draft, presentAssetPicker: presentAssetPicker)) - // Impose a minimum height, because otherwise it deafults 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) - } else { - List { - content - } - .listStyle(.plain) - .frame(height: totalHeight) - .scrollDisabledIfAvailable(true) + List { + content } + .listStyle(.plain) + .frame(height: totalHeight) + .scrollDisabledIfAvailable(true) } @ViewBuilder @@ -67,6 +82,7 @@ struct AttachmentsListView: View { TogglePollButton(poll: draft.poll) } + // TODO: move this to Callbacks 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 @@ -83,6 +99,7 @@ struct AttachmentsListView: View { } } + // TODO: move this to Callbacks 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 @@ -93,13 +110,14 @@ struct AttachmentsListView: View { } private func removeAttachments(at indices: IndexSet) { - var array = draft.draftAttachments - array.remove(atOffsets: indices) - draft.attachments = NSMutableOrderedSet(array: array) + for index in indices { + callbacks.removeAttachment(at: index) + } } + } -private struct Callbacks: AttachmentsListCallbacks { +private struct Callbacks { let draft: Draft let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? @@ -119,6 +137,11 @@ private struct Callbacks: AttachmentsListCallbacks { } } + func removeAttachment(at index: Int) { + var array = draft.draftAttachments + array.remove(at: index) + draft.attachments = NSMutableOrderedSet(array: array) + } func addPhoto() { presentAssetPicker?() { @@ -236,12 +259,19 @@ private struct TogglePollButton: View { private struct WrappedCollectionView: UIViewRepresentable { let attachments: [DraftAttachment] let hasPoll: Bool - let callbacks: AttachmentsListCallbacks + let callbacks: Callbacks + let canAddAttachment: Bool func makeUIView(context: Context) -> UICollectionView { - let config = UICollectionLayoutListConfiguration(appearance: .plain) + 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) } @@ -259,9 +289,9 @@ private struct WrappedCollectionView: UIViewRepresentable { .attachment($0) }, toSection: .attachments) snapshot.appendItems([ - .button(.addPhoto), - .button(.addDrawing), - .button(.togglePoll(adding: !hasPoll)) + .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 @@ -277,14 +307,14 @@ private struct WrappedCollectionView: UIViewRepresentable { enum Item: Hashable { case attachment(DraftAttachment) - case button(Button) + 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), .button(b)): - return a == b + case let (.button(a, aEnabled), .button(b, bEnabled)): + return a == b && aEnabled == bEnabled default: return false } @@ -295,9 +325,10 @@ private struct WrappedCollectionView: UIViewRepresentable { case .attachment(let draftAttachment): hasher.combine(0) hasher.combine(draftAttachment.objectID) - case .button(let button): + case .button(let button, let enabled): hasher.combine(1) hasher.combine(button) + hasher.combine(enabled) } } } @@ -311,9 +342,18 @@ private struct WrappedCollectionView: UIViewRepresentable { 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 { - _intrinsicContentSize + var size = _intrinsicContentSize + size.height += heightOfCellBeingDeleted + return size } override func layoutSubviews() { @@ -326,21 +366,14 @@ private final class IntrinsicContentSizeCollectionView: UICollectionView { } } -protocol AttachmentsListCallbacks { - func addPhoto() - - func addDrawing() - - func togglePoll() -} - @available(iOS 16.0, *) private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate { - var callbacks: AttachmentsListCallbacks + var callbacks: Callbacks + var setHeightOfCellBeingDeleted: ((CGFloat) -> Void)? var dataSource: UICollectionViewDiffableDataSource! - init(callbacks: AttachmentsListCallbacks) { + init(callbacks: Callbacks) { self.callbacks = callbacks } @@ -350,9 +383,9 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView } } - private let buttonCell = UICollectionView.CellRegistration { cell, indexPath, item in + private let buttonCell = UICollectionView.CellRegistration { cell, indexPath, item in var config = cell.defaultContentConfiguration() - switch item { + switch item.0 { case .addPhoto: config.image = UIImage(systemName: "photo") config.text = "Add photo or video" @@ -364,6 +397,10 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView 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 } @@ -371,23 +408,43 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView switch item { case .attachment(let attachment): return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment) - case .button(let button): - return collectionView.dequeueConfiguredReusableCell(using: buttonCell, for: indexPath, item: button) + 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: - return true + 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 { + guard case .button(let button, _) = dataSource.itemIdentifier(for: indexPath) else { return } switch button {