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 {
@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,13 +58,6 @@ 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
}
@ -49,7 +65,6 @@ struct AttachmentsListView: View {
.frame(height: totalHeight)
.scrollDisabledIfAvailable(true)
}
}
@ViewBuilder
private var content: some View {
@ -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<Section, Item>(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<WrappedCollectionView.Section, WrappedCollectionView.Item>!
init(callbacks: AttachmentsListCallbacks) {
init(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()
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 {