Delete attachment swipe action

This commit is contained in:
Shadowfacts 2024-09-11 22:39:16 -04:00
parent ec50dd6bb6
commit 5f6699749c
1 changed files with 102 additions and 45 deletions

View File

@ -13,7 +13,6 @@ import PhotosUI
struct AttachmentsListView: View { struct AttachmentsListView: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures @ObservedObject var instanceFeatures: InstanceFeatures
@State private var attachmentHeights = [NSManagedObjectID: CGFloat]()
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
private var canAddAttachment: Bool { 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 { private var totalHeight: CGFloat {
let buttonsHeight = 3 * (40 + AttachmentsListPaddingModifier.cellPadding) let buttonsHeight = 3 * (40 + AttachmentsListPaddingModifier.cellPadding)
let rowHeights = draft.attachments.compactMap { let rowHeights = draft.attachments.compactMap {
@ -35,13 +58,6 @@ struct AttachmentsListView: View {
} }
var body: some 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 { List {
content content
} }
@ -49,7 +65,6 @@ struct AttachmentsListView: View {
.frame(height: totalHeight) .frame(height: totalHeight)
.scrollDisabledIfAvailable(true) .scrollDisabledIfAvailable(true)
} }
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
@ -67,6 +82,7 @@ struct AttachmentsListView: View {
TogglePollButton(poll: draft.poll) TogglePollButton(poll: draft.poll)
} }
// TODO: move this to Callbacks
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in 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) { private func moveAttachments(from source: IndexSet, to destination: Int) {
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet // 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 // 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) { private func removeAttachments(at indices: IndexSet) {
var array = draft.draftAttachments for index in indices {
array.remove(atOffsets: indices) callbacks.removeAttachment(at: index)
draft.attachments = NSMutableOrderedSet(array: array)
} }
} }
private struct Callbacks: AttachmentsListCallbacks { }
private struct Callbacks {
let draft: Draft let draft: Draft
let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)? 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() { func addPhoto() {
presentAssetPicker?() { presentAssetPicker?() {
@ -236,12 +259,19 @@ private struct TogglePollButton: View {
private struct WrappedCollectionView: UIViewRepresentable { private struct WrappedCollectionView: UIViewRepresentable {
let attachments: [DraftAttachment] let attachments: [DraftAttachment]
let hasPoll: Bool let hasPoll: Bool
let callbacks: AttachmentsListCallbacks let callbacks: Callbacks
let canAddAttachment: Bool
func makeUIView(context: Context) -> UICollectionView { 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 layout = UICollectionViewCompositionalLayout.list(using: config)
let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout) let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
context.coordinator.setHeightOfCellBeingDeleted = {
view.heightOfCellBeingDeleted = $0
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: view) { collectionView, indexPath, itemIdentifier in let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: view) { collectionView, indexPath, itemIdentifier in
context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier) context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier)
} }
@ -259,9 +289,9 @@ private struct WrappedCollectionView: UIViewRepresentable {
.attachment($0) .attachment($0)
}, toSection: .attachments) }, toSection: .attachments)
snapshot.appendItems([ snapshot.appendItems([
.button(.addPhoto), .button(.addPhoto, enabled: canAddAttachment),
.button(.addDrawing), .button(.addDrawing, enabled: canAddAttachment),
.button(.togglePoll(adding: !hasPoll)) .button(.togglePoll(adding: !hasPoll), enabled: true)
], toSection: .buttons) ], toSection: .buttons)
context.coordinator.dataSource.apply(snapshot) context.coordinator.dataSource.apply(snapshot)
context.coordinator.callbacks = callbacks context.coordinator.callbacks = callbacks
@ -277,14 +307,14 @@ private struct WrappedCollectionView: UIViewRepresentable {
enum Item: Hashable { enum Item: Hashable {
case attachment(DraftAttachment) case attachment(DraftAttachment)
case button(Button) case button(Button, enabled: Bool)
static func ==(lhs: Self, rhs: Self) -> Bool { static func ==(lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.attachment(a), .attachment(b)): case let (.attachment(a), .attachment(b)):
return a.objectID == b.objectID return a.objectID == b.objectID
case let (.button(a), .button(b)): case let (.button(a, aEnabled), .button(b, bEnabled)):
return a == b return a == b && aEnabled == bEnabled
default: default:
return false return false
} }
@ -295,9 +325,10 @@ private struct WrappedCollectionView: UIViewRepresentable {
case .attachment(let draftAttachment): case .attachment(let draftAttachment):
hasher.combine(0) hasher.combine(0)
hasher.combine(draftAttachment.objectID) hasher.combine(draftAttachment.objectID)
case .button(let button): case .button(let button, let enabled):
hasher.combine(1) hasher.combine(1)
hasher.combine(button) hasher.combine(button)
hasher.combine(enabled)
} }
} }
} }
@ -311,9 +342,18 @@ private struct WrappedCollectionView: UIViewRepresentable {
private final class IntrinsicContentSizeCollectionView: UICollectionView { private final class IntrinsicContentSizeCollectionView: UICollectionView {
private var _intrinsicContentSize = CGSize.zero 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 { override var intrinsicContentSize: CGSize {
_intrinsicContentSize var size = _intrinsicContentSize
size.height += heightOfCellBeingDeleted
return size
} }
override func layoutSubviews() { override func layoutSubviews() {
@ -326,21 +366,14 @@ private final class IntrinsicContentSizeCollectionView: UICollectionView {
} }
} }
protocol AttachmentsListCallbacks {
func addPhoto()
func addDrawing()
func togglePoll()
}
@available(iOS 16.0, *) @available(iOS 16.0, *)
private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate { private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate {
var callbacks: AttachmentsListCallbacks var callbacks: Callbacks
var setHeightOfCellBeingDeleted: ((CGFloat) -> Void)?
var dataSource: UICollectionViewDiffableDataSource<WrappedCollectionView.Section, WrappedCollectionView.Item>! var dataSource: UICollectionViewDiffableDataSource<WrappedCollectionView.Section, WrappedCollectionView.Item>!
init(callbacks: AttachmentsListCallbacks) { init(callbacks: Callbacks) {
self.callbacks = callbacks self.callbacks = callbacks
} }
@ -350,9 +383,9 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView
} }
} }
private let buttonCell = UICollectionView.CellRegistration<UICollectionViewListCell, WrappedCollectionView.Button> { cell, indexPath, item in private let buttonCell = UICollectionView.CellRegistration<UICollectionViewListCell, (WrappedCollectionView.Button, Bool)> { cell, indexPath, item in
var config = cell.defaultContentConfiguration() var config = cell.defaultContentConfiguration()
switch item { switch item.0 {
case .addPhoto: case .addPhoto:
config.image = UIImage(systemName: "photo") config.image = UIImage(systemName: "photo")
config.text = "Add photo or video" 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.text = adding ? "Add a poll" : "Remove poll"
} }
config.textProperties.color = .tintColor config.textProperties.color = .tintColor
if !item.1 {
config.textProperties.colorTransformer = .monochromeTint
config.imageProperties.tintColorTransformer = .monochromeTint
}
cell.contentConfiguration = config cell.contentConfiguration = config
} }
@ -371,23 +408,43 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView
switch item { switch item {
case .attachment(let attachment): case .attachment(let attachment):
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment) return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
case .button(let button): case .button(let button, let enabled):
return collectionView.dequeueConfiguredReusableCell(using: buttonCell, for: indexPath, item: button) 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 { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath)! { switch dataSource.itemIdentifier(for: indexPath)! {
case .attachment: case .attachment:
return false return false
case .button: case .button(_, let enabled):
return true return enabled
} }
} }
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: false) 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 return
} }
switch button { switch button {