Delete attachment swipe action
This commit is contained in:
parent
ec50dd6bb6
commit
5f6699749c
|
@ -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,20 +58,12 @@ struct AttachmentsListView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 16.0, *) {
|
List {
|
||||||
WrappedCollectionView(attachments: draft.draftAttachments, hasPoll: draft.poll != nil, callbacks: Callbacks(draft: draft, presentAssetPicker: presentAssetPicker))
|
content
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.frame(height: totalHeight)
|
||||||
|
.scrollDisabledIfAvailable(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue