Delete attachment swipe action
This commit is contained in:
parent
ec50dd6bb6
commit
5f6699749c
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue