WIP attachments list
This commit is contained in:
parent
f001e8edcd
commit
5d9974ddf8
|
@ -135,6 +135,7 @@ public final class ComposeController: ViewController {
|
|||
if Preferences.shared.hasFeatureFlag(.composeRewrite) {
|
||||
ComposeUI.ComposeView(draft: draft, mastodonController: mastodonController)
|
||||
.environment(\.currentAccount, currentAccount)
|
||||
.environment(\.composeUIConfig, config)
|
||||
} else {
|
||||
ComposeView(poster: poster)
|
||||
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||
|
|
|
@ -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()
|
||||
//}
|
|
@ -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()
|
||||
//}
|
|
@ -79,6 +79,8 @@ struct ComposeView: View {
|
|||
ContentWarningTextField(draft: draft, focusedField: $focusedField)
|
||||
|
||||
NewMainTextView(value: $draft.text, focusedField: $focusedField)
|
||||
|
||||
AttachmentsListView(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
|
|
@ -133,7 +133,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
picker.modalPresentationStyle = .pageSheet
|
||||
picker.overrideUserInterfaceStyle = .dark
|
||||
// sheet detents don't play nice with PHPickerViewController, see
|
||||
// let sheet = picker.sheetPresentationController!
|
||||
// sheet.detents = [.medium(), .large()]
|
||||
|
|
Loading…
Reference in New Issue