Go back to using one List for everything in compose

This commit is contained in:
Shadowfacts 2024-11-12 10:29:48 -05:00
parent 381f3ee737
commit 57990f8339
5 changed files with 236 additions and 371 deletions

View File

@ -131,7 +131,6 @@ class AttachmentsListController: ViewController {
@EnvironmentObject private var controller: AttachmentsListController
@EnvironmentObject private var draft: Draft
@Environment(\.colorScheme) private var colorScheme
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
attachmentsList
@ -217,7 +216,7 @@ fileprivate extension View {
}
@available(visionOS 1.0, *)
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
struct AttachmentButtonLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
DefaultLabelStyle().makeBody(configuration: configuration)
.foregroundStyle(.white)

View File

@ -0,0 +1,182 @@
//
// AttachmentsListSection.swift
// ComposeUI
//
// Created by Shadowfacts on 10/14/24.
//
import SwiftUI
import InstanceFeatures
import PencilKit
struct AttachmentsListSection: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.canAddAttachment) private var canAddAttachment
var body: some View {
attachmentRows
buttons
.foregroundStyle(.tint)
#if os(visionOS)
.buttonStyle(.bordered)
.labelStyle(AttachmentButtonLabelStyle())
#endif
}
private var attachmentRows: some View {
ForEach(draft.draftAttachments) { attachment in
AttachmentRowView(attachment: attachment)
}
.onMove(perform: moveAttachments)
.onDelete(perform: deleteAttachments)
.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider) { offset, providers in
Self.insertAttachments(in: draft, at: offset, itemProviders: providers)
}
}
@ViewBuilder
private var buttons: some View {
AddPhotoButton(addAttachments: self.addAttachments)
AddDrawingButton(draft: draft)
TogglePollButton(draft: draft)
}
static func insertAttachments(in draft: Draft, at index: Int, itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = draft
draft.attachments.add(attachment)
}
}
}
}
private func addAttachments(itemProviders: [NSItemProvider]) {
Self.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
}
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
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
var array = draft.draftAttachments
array.move(fromOffsets: source, toOffset: destination)
draft.attachments = NSMutableOrderedSet(array: array)
}
private func deleteAttachments(at indices: IndexSet) {
var array = draft.draftAttachments
array.remove(atOffsets: indices)
draft.attachments = NSMutableOrderedSet(array: array)
}
}
private struct AddPhotoButton: View {
let addAttachments: ([NSItemProvider]) -> Void
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
var body: some View {
if let presentAssetPicker {
Button("Add photo or video", systemImage: "photo") {
presentAssetPicker { results in
addAttachments(results.map(\.itemProvider))
}
}
}
}
}
private struct AddDrawingButton: View {
let draft: Draft
@Environment(\.composeUIConfig.presentDrawing) private var presentDrawing
var body: some View {
if let presentDrawing {
Button("Add drawing", systemImage: "hand.draw") {
presentDrawing(PKDrawing()) { drawing in
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
}
private struct TogglePollButton: View {
@ObservedObject var draft: Draft
var body: some View {
Button(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") {
withAnimation {
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
}
}
.disabled(draft.attachments.count > 0)
}
}
struct AddAttachmentConditionsModifier: ViewModifier {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4
&& draft.draftAttachments.allSatisfy { $0.type == .image }
&& draft.poll == nil
} else {
return true
}
}
func body(content: Content) -> some View {
content
.environment(\.canAddAttachment, canAddAttachment)
}
}
private struct CanAddAttachmentKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var canAddAttachment: Bool {
get { self[CanAddAttachmentKey.self] }
set { self[CanAddAttachmentKey.self] = newValue }
}
}
struct DropAttachmentModifier: ViewModifier {
let draft: Draft
@Environment(\.canAddAttachment) private var canAddAttachment
func body(content: Content) -> some View {
content
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, delegate: AttachmentDropDelegate(draft: draft, canAddAttachment: canAddAttachment))
}
}
private struct AttachmentDropDelegate: DropDelegate {
let draft: Draft
let canAddAttachment: Bool
func validateDrop(info: DropInfo) -> Bool {
canAddAttachment
}
func performDrop(info: DropInfo) -> Bool {
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: info.itemProviders(for: DraftAttachment.readableTypeIdentifiersForItemProvider))
return true
}
}

View File

@ -1,351 +0,0 @@
//
// AttachmentsListView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/16/24.
//
import SwiftUI
import InstanceFeatures
import CoreData
import PhotosUI
struct AttachmentsListView: View {
@ObservedObject var draft: Draft
@ObservedObject var instanceFeatures: InstanceFeatures
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
private var canAddAttachment: Bool {
if instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
} else {
return true
}
}
private var callbacks: Callbacks {
Callbacks(draft: draft, presentAssetPicker: presentAssetPicker)
}
var body: some View {
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)
.environmentObject(instanceFeatures)
}
}
private struct Callbacks {
let draft: Draft
let presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
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
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
// guard self.canAddAttachment else {
// return
// }
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
func removeAttachment(at index: Int) {
var array = draft.draftAttachments
array.remove(at: index)
draft.attachments = NSMutableOrderedSet(array: array)
}
func reorderAttachments(with difference: CollectionDifference<DraftAttachment>) {
let array = draft.draftAttachments.applying(difference)!
draft.attachments = NSMutableOrderedSet(array: array)
}
func addPhoto() {
presentAssetPicker?() {
insertAttachments(at: draft.attachments.count, itemProviders: $0.map(\.itemProvider))
}
}
func addDrawing() {
}
func togglePoll() {
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
}
}
@available(iOS 16.0, *)
private struct WrappedCollectionView: UIViewRepresentable {
let attachments: [DraftAttachment]
let hasPoll: Bool
let callbacks: Callbacks
let canAddAttachment: Bool
func makeUIView(context: Context) -> UICollectionView {
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)
}
view.dataSource = dataSource
context.coordinator.dataSource = dataSource
view.delegate = context.coordinator
view.isScrollEnabled = false
dataSource.reorderingHandlers.canReorderItem = {
if case .attachment(_) = $0 {
true
} else {
false
}
}
dataSource.reorderingHandlers.didReorder = { transaction in
let attachmentsChanges = transaction.difference.map {
switch $0 {
case .insert(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.insert(offset: offset, element: attachment, associatedWith: associatedWith)
case .remove(let offset, let element, let associatedWith):
guard case .attachment(let attachment) = element else { fatalError() }
return CollectionDifference<DraftAttachment>.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith)
}
}
let attachmentsDiff = CollectionDifference(attachmentsChanges)!
callbacks.reorderAttachments(with: attachmentsDiff)
}
let longPressRecognizer = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(WrappedCollectionViewCoordinator.reorderingLongPressRecognized))
longPressRecognizer.delegate = context.coordinator
view.addGestureRecognizer(longPressRecognizer)
return view
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.attachments, .buttons])
snapshot.appendItems(attachments.map {
.attachment($0)
}, toSection: .attachments)
snapshot.appendItems([
.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
}
func makeCoordinator() -> WrappedCollectionViewCoordinator {
WrappedCollectionViewCoordinator(callbacks: callbacks)
}
enum Section: Hashable {
case attachments, buttons
}
enum Item: Hashable {
case attachment(DraftAttachment)
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, aEnabled), .button(b, bEnabled)):
return a == b && aEnabled == bEnabled
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .attachment(let draftAttachment):
hasher.combine(0)
hasher.combine(draftAttachment.objectID)
case .button(let button, let enabled):
hasher.combine(1)
hasher.combine(button)
hasher.combine(enabled)
}
}
}
enum Button: Hashable {
case addPhoto
case addDrawing
case togglePoll(adding: Bool)
}
}
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 {
var size = _intrinsicContentSize
size.height += heightOfCellBeingDeleted
return size
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize != _intrinsicContentSize {
_intrinsicContentSize = contentSize
invalidateIntrinsicContentSize()
}
}
}
@available(iOS 16.0, *)
private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate, UIGestureRecognizerDelegate {
var callbacks: Callbacks
var setHeightOfCellBeingDeleted: ((CGFloat) -> Void)?
var dataSource: UICollectionViewDiffableDataSource<WrappedCollectionView.Section, WrappedCollectionView.Item>!
init(callbacks: Callbacks) {
self.callbacks = callbacks
}
private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in
cell.contentConfiguration = UIHostingConfiguration {
AttachmentRowView(attachment: item)
}
}
private let buttonCell = UICollectionView.CellRegistration<UICollectionViewListCell, (WrappedCollectionView.Button, Bool)> { cell, indexPath, item in
var config = cell.defaultContentConfiguration()
switch item.0 {
case .addPhoto:
config.image = UIImage(systemName: "photo")
config.text = "Add photo or video"
case .addDrawing:
config.image = UIImage(systemName: "hand.draw")
config.text = "Add drawing"
case .togglePoll(let adding):
config.image = UIImage(systemName: "chart.bar.doc.horizontal")
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
}
func makeCell(collectionView: UICollectionView, indexPath: IndexPath, item: WrappedCollectionView.Item) -> UICollectionViewCell {
switch item {
case .attachment(let attachment):
return collectionView.dequeueConfiguredReusableCell(using: attachmentCell, for: indexPath, item: attachment)
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(_, 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 {
return
}
switch button {
case .addPhoto:
callbacks.addPhoto()
case .addDrawing:
callbacks.addDrawing()
case .togglePoll:
callbacks.togglePoll()
}
}
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let snapshot = dataSource.snapshot()
let attachmentsSection = snapshot.indexOfSection(.attachments)!
if proposedIndexPath.section != attachmentsSection {
return IndexPath(item: snapshot.itemIdentifiers(inSection: .attachments).count - 1, section: attachmentsSection)
} else {
return proposedIndexPath
}
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let collectionView = gestureRecognizer.view as! UICollectionView
let location = gestureRecognizer.location(in: collectionView)
guard let indexPath = collectionView.indexPathForItem(at: location) else {
return false
}
return collectionView.beginInteractiveMovementForItem(at: indexPath)
}
@objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) {
let collectionView = recognizer.view as! UICollectionView
switch recognizer.state {
case .began:
break
case .changed:
collectionView.updateInteractiveMovementTargetPosition(recognizer.location(in: collectionView))
case .ended:
collectionView.endInteractiveMovement()
case .cancelled:
collectionView.cancelInteractiveMovement()
default:
break
}
}
}
//#Preview {
// AttachmentsListView()
//}

View File

@ -22,9 +22,10 @@ struct ComposeView: View {
private var navigationRoot: some View {
ZStack {
ScrollView(.vertical) {
scrollContent
List {
listContent
}
.listStyle(.plain)
.scrollDismissesKeyboard(.interactively)
#if !os(visionOS) && !targetEnvironment(macCatalyst)
.modifier(ToolbarSafeAreaInsetModifier())
@ -46,6 +47,9 @@ struct ComposeView: View {
.ignoresSafeArea(.keyboard)
})
#endif
// Have these after the overlays so they barely work instead of not working at all. FB11790805
.modifier(DropAttachmentModifier(draft: draft))
.modifier(AddAttachmentConditionsModifier(draft: draft, instanceFeatures: mastodonController.instanceFeatures))
.modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -63,19 +67,28 @@ struct ComposeView: View {
}
@ViewBuilder
private var scrollContent: some View {
VStack(spacing: 4) {
private var listContent: some View {
NewReplyStatusView(draft: draft, mastodonController: mastodonController)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
ContentWarningTextField(draft: draft, focusedField: $focusedField)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
NewMainTextView(value: $draft.text, focusedField: $focusedField)
NewMainTextView(value: $draft.text, focusedField: $focusedField, handleAttachmentDrop: self.addAttachments)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
AttachmentsListView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
AttachmentsListSection(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
}
.padding(8)
private func addAttachments(_ itemProviders: [NSItemProvider]) {
AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: itemProviders)
}
}
@ -133,6 +146,7 @@ private struct ToolbarActions: ToolbarContent {
}
private var postButton: some View {
// TODO: don't use the controller for this
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}

View File

@ -12,10 +12,11 @@ struct NewMainTextView: View {
@Binding var value: String
@FocusState.Binding var focusedField: FocusableField?
var handleAttachmentDrop: ([NSItemProvider]) -> Void
@State private var becomeFirstResponder = true
var body: some View {
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder)
NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder, handleAttachmentDrop: handleAttachmentDrop)
.focused($focusedField, equals: .body)
.modifier(FocusedInputModifier())
.overlay(alignment: .topLeading) {
@ -29,6 +30,7 @@ struct NewMainTextView: View {
private struct NewMainTextViewRepresentable: UIViewRepresentable {
@Binding var value: String
@Binding var becomeFirstResponder: Bool
var handleAttachmentDrop: ([NSItemProvider]) -> Void
@Environment(\.composeInputBox) private var inputBox
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
@ -40,6 +42,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
func makeUIView(context: Context) -> UITextView {
// TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary
let view = WrappedTextView(usingTextLayoutManager: true)
view.addInteraction(UIDropInteraction(delegate: context.coordinator))
view.delegate = context.coordinator
view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping
@ -67,6 +70,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
func updateUIView(_ uiView: UIViewType, context: Context) {
context.coordinator.value = $value
context.coordinator.handleAttachmentDrop = handleAttachmentDrop
context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView)
uiView.isEditable = isEnabled
@ -86,7 +90,7 @@ private struct NewMainTextViewRepresentable: UIViewRepresentable {
}
func makeCoordinator() -> WrappedTextViewCoordinator {
let coordinator = WrappedTextViewCoordinator(value: $value)
let coordinator = WrappedTextViewCoordinator(value: $value, handleAttachmentDrop: handleAttachmentDrop)
// DispatchQueue.main.async {
// inputBox.wrappedValue = coordinator
// }
@ -120,9 +124,11 @@ private final class WrappedTextViewCoordinator: NSObject {
}()
var value: Binding<String>
var handleAttachmentDrop: ([NSItemProvider]) -> Void
init(value: Binding<String>) {
init(value: Binding<String>, handleAttachmentDrop: @escaping ([NSItemProvider]) -> Void) {
self.value = value
self.handleAttachmentDrop = handleAttachmentDrop
}
private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String {
@ -230,6 +236,21 @@ extension WrappedTextViewCoordinator: UITextViewDelegate {
//
//}
// Because of FB11790805, we can't handle drops for the entire screen.
// The onDrop modifier also doesn't work when applied to the NewMainTextView.
// So, manually add the UIInteraction to at least handle that.
extension WrappedTextViewCoordinator: UIDropInteractionDelegate {
func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool {
session.canLoadObjects(ofClass: DraftAttachment.self)
}
func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) -> UIDropProposal {
UIDropProposal(operation: .copy)
}
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) {
handleAttachmentDrop(session.items.map(\.itemProvider))
}
}
private final class WrappedTextView: UITextView {
}