WIP attachments list

This commit is contained in:
Shadowfacts 2024-08-19 11:29:16 -04:00
parent f001e8edcd
commit 5d9974ddf8
5 changed files with 430 additions and 1 deletions

View File

@ -135,6 +135,7 @@ public final class ComposeController: ViewController {
if Preferences.shared.hasFeatureFlag(.composeRewrite) { if Preferences.shared.hasFeatureFlag(.composeRewrite) {
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController) ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
.environment(\.currentAccount, currentAccount) .environment(\.currentAccount, currentAccount)
.environment(\.composeUIConfig, config)
} else { } else {
ComposeView(poster: poster) ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext) .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)

View File

@ -0,0 +1,20 @@
//
// AttachmentRowView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/18/24.
//
import SwiftUI
struct AttachmentRowView: View {
@ObservedObject var attachment: DraftAttachment
var body: some View {
Text(attachment.id.uuidString)
}
}
//#Preview {
// AttachmentRowView()
//}

View File

@ -0,0 +1,407 @@
//
// 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
@State private var attachmentHeights = [NSManagedObjectID: CGFloat]()
@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 totalHeight: CGFloat {
let buttonsHeight = 3 * (40 + AttachmentsListPaddingModifier.cellPadding)
let rowHeights = draft.attachments.compactMap {
attachmentHeights[($0 as! NSManagedObject).objectID]
}.reduce(0) { partialResult, height in
partialResult + height + AttachmentsListPaddingModifier.cellPadding
}
return buttonsHeight + rowHeights
}
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
}
.listStyle(.plain)
.frame(height: totalHeight)
.scrollDisabledIfAvailable(true)
}
}
@ViewBuilder
private var content: some View {
ForEach(draft.draftAttachments) { attachment in
AttachmentRowView(attachment: attachment)
.modifier(AttachmentRowHeightModifier(height: $attachmentHeights[attachment.objectID]))
}
.onMove(perform: self.moveAttachments)
.onDelete(perform: self.removeAttachments)
AddPhotoButton(canAddAttachment: canAddAttachment, draft: draft, insertAttachments: self.insertAttachments)
AddDrawingButton(canAddAttachment: canAddAttachment)
TogglePollButton(poll: draft.poll)
}
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)
}
}
}
}
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 removeAttachments(at indices: IndexSet) {
var array = draft.draftAttachments
array.remove(atOffsets: indices)
draft.attachments = NSMutableOrderedSet(array: array)
}
}
private struct Callbacks: AttachmentsListCallbacks {
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 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
}
}
private struct AttachmentRowHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct AttachmentRowHeightModifier: ViewModifier {
@Binding var height: CGFloat?
func body(content: Content) -> some View {
content
.background {
GeometryReader { proxy in
Color.clear
// do the preference dance because onChange(of:inital:_:) is iOS 17+ :/
.preference(key: AttachmentRowHeightPreferenceKey.self, value: proxy.size.height)
.onPreferenceChange(AttachmentRowHeightPreferenceKey.self) { newValue in
height = newValue
}
}
}
}
}
private struct AttachmentsListPaddingModifier: ViewModifier {
static let cellPadding: CGFloat = 12
func body(content: Content) -> some View {
content
.listRowInsets(EdgeInsets(top: Self.cellPadding / 2, leading: Self.cellPadding / 2, bottom: Self.cellPadding / 2, trailing: Self.cellPadding / 2))
}
}
private struct AttachmentsListButton<Label: View>: View {
let action: () -> Void
@ViewBuilder let label: Label
var body: some View {
Button(action: action) {
label
}
.foregroundStyle(.tint)
.frame(height: 40)
.modifier(AttachmentsListPaddingModifier())
}
}
private struct AddPhotoButton: View {
let canAddAttachment: Bool
let draft: Draft
let insertAttachments: (Int, [NSItemProvider]) -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker
var body: some View {
AttachmentsListButton {
presentAssetPicker?() { results in
insertAttachments(draft.attachments.count, results.map(\.itemProvider))
}
} label: {
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
}
.disabled(!canAddAttachment)
}
}
private struct AddDrawingButton: View {
let canAddAttachment: Bool
var body: some View {
AttachmentsListButton {
fatalError("TODO")
} label: {
Label("Draw something", systemImage: "hand.draw")
}
.disabled(!canAddAttachment)
}
}
private struct TogglePollButton: View {
let poll: Poll?
var canAddPoll: Bool {
// TODO
true
}
var body: some View {
AttachmentsListButton {
fatalError("TODO")
} label: {
Label(poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
}
.disabled(!canAddPoll)
}
}
@available(iOS 16.0, *)
private struct WrappedCollectionView: UIViewRepresentable {
let attachments: [DraftAttachment]
let hasPoll: Bool
let callbacks: AttachmentsListCallbacks
func makeUIView(context: Context) -> UICollectionView {
let config = UICollectionLayoutListConfiguration(appearance: .plain)
let layout = UICollectionViewCompositionalLayout.list(using: config)
let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout)
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
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),
.button(.addDrawing),
.button(.togglePoll(adding: !hasPoll))
], 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)
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
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):
hasher.combine(1)
hasher.combine(button)
}
}
}
enum Button: Hashable {
case addPhoto
case addDrawing
case togglePoll(adding: Bool)
}
}
private final class IntrinsicContentSizeCollectionView: UICollectionView {
private var _intrinsicContentSize = CGSize.zero
override var intrinsicContentSize: CGSize {
_intrinsicContentSize
}
override func layoutSubviews() {
super.layoutSubviews()
if contentSize != _intrinsicContentSize {
_intrinsicContentSize = contentSize
invalidateIntrinsicContentSize()
}
}
}
protocol AttachmentsListCallbacks {
func addPhoto()
func addDrawing()
func togglePoll()
}
@available(iOS 16.0, *)
private final class WrappedCollectionViewCoordinator: NSObject, UICollectionViewDelegate {
var callbacks: AttachmentsListCallbacks
var dataSource: UICollectionViewDiffableDataSource<WrappedCollectionView.Section, WrappedCollectionView.Item>!
init(callbacks: AttachmentsListCallbacks) {
self.callbacks = callbacks
}
private let attachmentCell = UICollectionView.CellRegistration<UICollectionViewListCell, DraftAttachment> { cell, indexPath, item in
cell.contentConfiguration = UIHostingConfiguration {
Text(item.id.uuidString)
}
}
private let buttonCell = UICollectionView.CellRegistration<UICollectionViewListCell, WrappedCollectionView.Button> { cell, indexPath, item in
var config = cell.defaultContentConfiguration()
switch item {
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
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):
return collectionView.dequeueConfiguredReusableCell(using: buttonCell, for: indexPath, item: button)
}
}
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath)! {
case .attachment:
return false
case .button:
return true
}
}
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()
}
}
}
//#Preview {
// AttachmentsListView()
//}

View File

@ -79,6 +79,8 @@ struct ComposeView: View {
ContentWarningTextField(draft: draft, focusedField: $focusedField) ContentWarningTextField(draft: draft, focusedField: $focusedField)
NewMainTextView(value: $draft.text, focusedField: $focusedField) NewMainTextView(value: $draft.text, focusedField: $focusedField)
AttachmentsListView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
} }
.padding(8) .padding(8)
} }

View File

@ -133,7 +133,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
let picker = PHPickerViewController(configuration: config) let picker = PHPickerViewController(configuration: config)
picker.delegate = self picker.delegate = self
picker.modalPresentationStyle = .pageSheet picker.modalPresentationStyle = .pageSheet
picker.overrideUserInterfaceStyle = .dark
// sheet detents don't play nice with PHPickerViewController, see // sheet detents don't play nice with PHPickerViewController, see
// let sheet = picker.sheetPresentationController! // let sheet = picker.sheetPresentationController!
// sheet.detents = [.medium(), .large()] // sheet.detents = [.medium(), .large()]