diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift index d684ff6f..abe6c7c9 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -12,14 +12,27 @@ import Photos struct AttachmentThumbnailView: View { let attachment: DraftAttachment - let contentMode: ContentMode = .fit + var contentMode: ContentMode = .fit + + var body: some View { + AttachmentThumbnailViewContent(attachment: attachment, contentMode: contentMode) + .id(attachment.id) + } + +} + +private struct AttachmentThumbnailViewContent: View { + var attachment: DraftAttachment + var contentMode: ContentMode = .fit @State private var mode: Mode = .empty @EnvironmentObject private var composeController: ComposeController - + var body: some View { switch mode { case .empty: Image(systemName: "photo") + .imageScale(.large) + .foregroundStyle(.gray) .task { await loadThumbnail() } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentCollectionViewCell.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentCollectionViewCell.swift new file mode 100644 index 00000000..fdc5e4d9 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentCollectionViewCell.swift @@ -0,0 +1,152 @@ +// +// AttachmentCollectionViewCell.swift +// ComposeUI +// +// Created by Shadowfacts on 11/20/24. +// + +import UIKit +import SwiftUI + +final class AttachmentCollectionViewCell: UICollectionViewCell { + let attachmentView: UIView & UIContentView + + override init(frame: CGRect) { + attachmentView = UIHostingConfiguration(content: { + AttachmentCollectionViewCellView(attachment: nil) + }).makeContentView() + + super.init(frame: frame) + + attachmentView.frame = bounds + addSubview(attachmentView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateUI(attachment: DraftAttachment) { + attachmentView.configuration = UIHostingConfiguration(content: { + AttachmentCollectionViewCellView(attachment: attachment) + }).margins(.all, .zero) + } +} + +private struct AttachmentCollectionViewCellView: View { + let attachment: DraftAttachment? + + var body: some View { + if let attachment { + AttachmentThumbnailView(attachment: attachment, contentMode: .fill) + .squareFrame() + .background { + RoundedSquare(cornerRadius: 5) + .fill(.quaternary) + } + .overlay(alignment: .bottom) { + AttachmentDescriptionLabel(attachment: attachment) + } + .overlay(alignment: .topTrailing) { + AttachmentRemoveButton(attachment: attachment) + } + .clipShape(RoundedSquare(cornerRadius: 5)) + } + } + +} + +private struct AttachmentRemoveButton: View { + let attachment: DraftAttachment + + var body: some View { + Button("Remove", systemImage: "xmark.circle.fill") { + let draft = attachment.draft + let attachments = draft.attachments.mutableCopy() as! NSMutableOrderedSet + attachments.remove(attachment) + draft.attachments = attachments + DraftsPersistentContainer.shared.viewContext.delete(attachment) + } + .labelStyle(.iconOnly) + .imageScale(.large) + .foregroundStyle(.white) + .shadow(radius: 2) + .padding([.top, .trailing], 2) + } +} + +private struct AttachmentDescriptionLabel: View { + let attachment: DraftAttachment + + var body: some View { + ZStack(alignment: .bottomLeading) { + LinearGradient( + stops: [.init(color: .clear, location: 0), .init(color: .clear, location: 0.6), .init(color: .black.opacity(0.5), location: 1)], + startPoint: .top, + endPoint: .bottom + ) + + label + .lineLimit(1) + .font(.callout) + .foregroundStyle(.white) + .shadow(radius: 1) + .padding([.horizontal, .bottom], 4) + } + } + + @ViewBuilder + private var label: some View { + if attachment.attachmentDescription.isEmpty { + Label("Add alt", systemImage: "pencil") + .labelStyle(NarrowSpacingLabelStyle()) + } else { + Text(attachment.attachmentDescription) + } + } +} + +private struct NarrowSpacingLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 4) { + configuration.icon + configuration.title + } + } +} + +private struct RoundedSquare: Shape { + let cornerRadius: CGFloat + + nonisolated func path(in rect: CGRect) -> Path { + let minDimension = min(rect.width, rect.height) + let square = CGRect(x: rect.minX - (rect.width - minDimension) / 2, y: rect.minY - (rect.height - minDimension), width: minDimension, height: minDimension) + return RoundedRectangle(cornerRadius: cornerRadius).path(in: square) + } +} + +private struct SquareFrame: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + precondition(subviews.count == 1) + let size = proposal.replacingUnspecifiedDimensions(by: subviews[0].sizeThatFits(proposal)) + let minDimension = min(size.width, size.height) + return CGSize(width: minDimension, height: minDimension) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + precondition(subviews.count == 1) + let subviewSize = subviews[0].sizeThatFits(proposal) + let minDimension = min(bounds.width, bounds.height) + let origin = CGPoint(x: bounds.minX - (subviewSize.width - minDimension) / 2, y: bounds.minY - (subviewSize.height - minDimension) / 2) + subviews[0].place(at: origin, proposal: ProposedViewSize(subviewSize)) + } +} + +private extension View { + func squareFrame() -> some View { + SquareFrame { + self + } + } +} + diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift new file mode 100644 index 00000000..e1aef7c8 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Attachments/AttachmentsSection.swift @@ -0,0 +1,281 @@ +// +// AttachmentsSection.swift +// ComposeUI +// +// Created by Shadowfacts on 11/17/24. +// + +import SwiftUI +import PhotosUI +import PencilKit + +struct AttachmentsSection: View { + @ObservedObject var draft: Draft + + var body: some View { + WrappedCollectionView( + draft: draft, + spacing: 8, + minItemSize: 100 + ) + // 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: 100) + } +} + +private struct WrappedCollectionView: UIViewRepresentable { + @ObservedObject var draft: Draft + let spacing: CGFloat + let minItemSize: CGFloat + + func makeUIView(context: Context) -> some UIView { + let layout = UICollectionViewCompositionalLayout { section, environment in + let (itemSize, itemsPerRow) = itemSize(width: environment.container.contentSize.width) + + let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(itemSize), heightDimension: .absolute(itemSize))) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemSize)), repeatingSubitem: item, count: itemsPerRow) + group.interItemSpacing = .fixed(spacing) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = spacing + return section + } + let view = IntrinsicContentSizeCollectionView(frame: .zero, collectionViewLayout: layout) + let dataSource = UICollectionViewDiffableDataSource(collectionView: view) { collectionView, indexPath, itemIdentifier in + context.coordinator.makeCell(collectionView: collectionView, indexPath: indexPath, item: itemIdentifier) + } + dataSource.reorderingHandlers.canReorderItem = { item in + switch item { + case .attachment(_): + true + case .addButton: + false + } + } + dataSource.reorderingHandlers.didReorder = { transaction in + let attachmentChanges = transaction.difference.map { + switch $0 { + case .insert(let offset, let element, let associatedWith): + guard case .attachment(let attachment) = element else { fatalError() } + return CollectionDifference.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.Change.remove(offset: offset, element: attachment, associatedWith: associatedWith) + } + } + let attachmentsDiff = CollectionDifference(attachmentChanges)! + let array = draft.draftAttachments.applying(attachmentsDiff)! + draft.attachments = NSMutableOrderedSet(array: array) + } + context.coordinator.dataSource = dataSource + + view.isScrollEnabled = false + view.clipsToBounds = false + view.delegate = context.coordinator + + let longPressRecognizer = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(WrappedCollectionViewCoordinator.reorderingLongPressRecognized)) + longPressRecognizer.delegate = context.coordinator + view.addGestureRecognizer(longPressRecognizer) + + return view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.all]) + snapshot.appendItems(draft.draftAttachments.map { .attachment($0) }) + snapshot.appendItems([.addButton]) + context.coordinator.dataSource.apply(snapshot) + context.coordinator.addAttachment = { + DraftsPersistentContainer.shared.viewContext.insert($0) + $0.draft = draft + draft.attachments.add($0) + } + context.coordinator.draft = draft + } + + func makeCoordinator() -> WrappedCollectionViewCoordinator { + WrappedCollectionViewCoordinator() + } + + private func itemSize(width: CGFloat) -> (CGFloat, Int) { + // The maximum item size is 2*minItemSize + spacing - 1, + // in the case where one item fits in the row but we are one pt short of + // adding a second item. + var itemSize = minItemSize + var fittingCount = floor((width + spacing) / (itemSize + spacing)) + var usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing + var remainingSpace = width - usedSpaceForFittingCount + if fittingCount == 1 && remainingSpace > minItemSize / 2 { + // If there's only one item that would fit at min size, and giving + // it the rest of the space would increase it by at least 50%, + // add a second item anywyas. + itemSize = (width - spacing) / 2 + fittingCount = 2 + usedSpaceForFittingCount = fittingCount * itemSize + (fittingCount - 1) * spacing + remainingSpace = width - usedSpaceForFittingCount + } + itemSize = itemSize + remainingSpace / fittingCount + return (itemSize, Int(fittingCount)) + } + + enum Section { + case all + } + + enum Item: Hashable { + case attachment(DraftAttachment) + case addButton + } +} + +private class WrappedCollectionViewCoordinator: NSObject { + var draft: Draft! + var dataSource: UICollectionViewDiffableDataSource! + var currentInteractiveMoveStartOffsetInCell: CGPoint? + var currentInteractiveMoveCell: AttachmentCollectionViewCell? + var addAttachment: ((DraftAttachment) -> Void)? = nil + + private let attachmentCell = UICollectionView.CellRegistration { cell, indexPath, attachment in + cell.updateUI(attachment: attachment) + } + + private let addButtonCell = UICollectionView.CellRegistration { cell, indexPath, item in + let (coordinator, enabled) = item + cell.contentConfiguration = UIHostingConfiguration(content: { + AddAttachmentButton(coordinator: coordinator, enabled: enabled) + }).margins(.all, .zero) + } + + 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 .addButton: + return collectionView.dequeueConfiguredReusableCell(using: addButtonCell, for: indexPath, item: (self, true)) + } + } + + @objc func reorderingLongPressRecognized(_ recognizer: UILongPressGestureRecognizer) { + let collectionView = recognizer.view as! UICollectionView + switch recognizer.state { + case .began: + break + case .changed: + var pos = recognizer.location(in: collectionView) + if let currentInteractiveMoveStartOffsetInCell { + pos.x -= currentInteractiveMoveStartOffsetInCell.x + pos.y -= currentInteractiveMoveStartOffsetInCell.y + } + collectionView.updateInteractiveMovementTargetPosition(pos) + case .ended: + collectionView.endInteractiveMovement() + UIView.animate(withDuration: 0.2) { + self.currentInteractiveMoveCell?.attachmentView.transform = .identity + } + currentInteractiveMoveCell = nil + currentInteractiveMoveStartOffsetInCell = nil + case .cancelled: + collectionView.cancelInteractiveMovement() + UIView.animate(withDuration: 0.2) { + self.currentInteractiveMoveCell?.attachmentView.transform = .identity + } + currentInteractiveMoveCell = nil + currentInteractiveMoveStartOffsetInCell = nil + default: + break + } + } +} + +extension WrappedCollectionViewCoordinator: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let collectionView = gestureRecognizer.view as! UICollectionView + let location = gestureRecognizer.location(in: collectionView) + guard let indexPath = collectionView.indexPathForItem(at: location), + let cell = collectionView.cellForItem(at: indexPath) as? AttachmentCollectionViewCell else { + return false + } + UIView.animate(withDuration: 0.2) { + cell.attachmentView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + } + currentInteractiveMoveCell = cell + currentInteractiveMoveStartOffsetInCell = gestureRecognizer.location(in: cell) + currentInteractiveMoveStartOffsetInCell!.x -= cell.bounds.midX + currentInteractiveMoveStartOffsetInCell!.y -= cell.bounds.midY + return collectionView.beginInteractiveMovementForItem(at: indexPath) + } +} + +extension WrappedCollectionViewCoordinator: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath { + let snapshot = dataSource.snapshot() + let items = snapshot.itemIdentifiers(inSection: .all).count + if proposedIndexPath.row == items - 1 { + return IndexPath(item: items - 2, section: proposedIndexPath.section) + } else { + return proposedIndexPath + } + } +} + +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() + } + } +} + +private struct AddAttachmentButton: View { + let coordinator: WrappedCollectionViewCoordinator + let enabled: Bool + @Environment(\.composeUIConfig.presentAssetPicker) private var presentAssetPicker + @Environment(\.composeUIConfig.presentDrawing) private var presentDrawing + + var body: some View { + Menu { + if let presentAssetPicker { + Button("Add photo or video", systemImage: "photo") { + presentAssetPicker { + let draft = coordinator.draft! + AttachmentsListSection.insertAttachments(in: draft, at: draft.attachments.count, itemProviders: $0.map(\.itemProvider)) + } + } + } + if let presentDrawing { + Button("Draw something", systemImage: "hand.draw") { + presentDrawing(PKDrawing()) { drawing in + let draft = coordinator.draft! + let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext) + attachment.id = UUID() + attachment.drawing = drawing + attachment.draft = draft + draft.attachments.add(attachment) + } + } + } + } label: { + Image(systemName: "photo.badge.plus") + .imageScale(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + RoundedRectangle(cornerRadius: 5) + .foregroundStyle(.tint.opacity(0.1)) + + RoundedRectangle(cornerRadius: 5) + .stroke(.tint, style: StrokeStyle(lineWidth: 2, dash: [5])) + } + } + .disabled(!enabled) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift index a8b20745..982afeec 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift @@ -34,6 +34,8 @@ struct ComposeDraftView: View { ContentWarningTextField(draft: draft, focusedField: $focusedField) DraftContentEditor(draft: draft, focusedField: $focusedField) + + AttachmentsSection(draft: draft) } } }