diff --git a/Data Sources/CompositionAttachmentsDataSource.swift b/Data Sources/CompositionAttachmentsDataSource.swift index 64daa40..822775e 100644 --- a/Data Sources/CompositionAttachmentsDataSource.swift +++ b/Data Sources/CompositionAttachmentsDataSource.swift @@ -9,10 +9,11 @@ final class CompositionAttachmentsDataSource: UICollectionViewDiffableDataSource DispatchQueue(label: "com.metabolist.metatext.composition-attachments-data-source.update-queue") init(collectionView: UICollectionView, - viewModelProvider: @escaping (IndexPath) -> CompositionAttachmentViewModel?) { + viewModelProvider: @escaping (IndexPath) -> (CompositionAttachmentViewModel, CompositionViewModel)) { let registration = UICollectionView.CellRegistration - { - $0.viewModel = $2 + { + $0.viewModel = $2.0 + $0.parentViewModel = $2.1 } super.init(collectionView: collectionView) { collectionView, indexPath, _ in diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 4a784f3..dd55297 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -38,6 +38,8 @@ "camera-access.description" = "Open system settings to allow camera access"; "camera-access.open-system-settings" = "Open system settings"; "cancel" = "Cancel"; +"compose.attachment.uploading" = "Uploading"; +"compose.attachment.remove" = "Remove"; "error" = "Error"; "favorites" = "Favorites"; "registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue"; diff --git a/Mastodon/Sources/Mastodon/Entities/Attachment.swift b/Mastodon/Sources/Mastodon/Entities/Attachment.swift index 7e37668..eee7709 100644 --- a/Mastodon/Sources/Mastodon/Entities/Attachment.swift +++ b/Mastodon/Sources/Mastodon/Entities/Attachment.swift @@ -44,4 +44,19 @@ public struct Attachment: Codable, Hashable { public extension Attachment { typealias Id = String + + var aspectRatio: Double? { + if + let info = meta?.original, + let width = info.width, + let height = info.height, + width != 0, + height != 0 { + let aspectRatio = Double(width) / Double(height) + + return aspectRatio.isNaN ? nil : aspectRatio + } + + return nil + } } diff --git a/ViewModels/Sources/ViewModels/AttachmentViewModel.swift b/ViewModels/Sources/ViewModels/AttachmentViewModel.swift index be44fe8..56b4f33 100644 --- a/ViewModels/Sources/ViewModels/AttachmentViewModel.swift +++ b/ViewModels/Sources/ViewModels/AttachmentViewModel.swift @@ -22,21 +22,6 @@ public extension AttachmentViewModel { attachment.id.appending(status.id).hashValue } - var aspectRatio: Double? { - if - let info = attachment.meta?.original, - let width = info.width, - let height = info.height, - width != 0, - height != 0 { - let aspectRatio = Double(width) / Double(height) - - return aspectRatio.isNaN ? nil : aspectRatio - } - - return nil - } - var shouldAutoplay: Bool { switch attachment.type { case .video: diff --git a/ViewModels/Sources/ViewModels/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/CompositionViewModel.swift index 09e4691..6944e7e 100644 --- a/ViewModels/Sources/ViewModels/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CompositionViewModel.swift @@ -46,8 +46,8 @@ public extension CompositionViewModel { visibility: visibility) } - func attachmentViewModel(indexPath: IndexPath) -> CompositionAttachmentViewModel { - attachmentViewModels[indexPath.item] + func remove(attachmentViewModel: CompositionAttachmentViewModel) { + attachmentViewModels.removeAll { $0 === attachmentViewModel } } } diff --git a/Views/AttachmentUploadView.swift b/Views/AttachmentUploadView.swift index bbd8ad9..7c49ac2 100644 --- a/Views/AttachmentUploadView.swift +++ b/Views/AttachmentUploadView.swift @@ -5,6 +5,7 @@ import UIKit import ViewModels final class AttachmentUploadView: UIView { + let label = UILabel() let progressView = UIProgressView(progressViewStyle: .default) private var progressCancellable: AnyCancellable? @@ -24,13 +25,26 @@ final class AttachmentUploadView: UIView { init() { super.init(frame: .zero) + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.font = .preferredFont(forTextStyle: .callout) + label.textAlignment = .center + label.text = NSLocalizedString("compose.attachment.uploading", comment: "") + label.textColor = .secondaryLabel + label.numberOfLines = 0 + addSubview(progressView) progressView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + progressView.topAnchor.constraint(equalTo: label.bottomAnchor, constant: .defaultSpacing), progressView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), progressView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - progressView.centerYAnchor.constraint(equalTo: centerYAnchor) + progressView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) ]) } diff --git a/Views/CompositionAttachmentCollectionViewCell.swift b/Views/CompositionAttachmentCollectionViewCell.swift index 433aa0c..1584f8a 100644 --- a/Views/CompositionAttachmentCollectionViewCell.swift +++ b/Views/CompositionAttachmentCollectionViewCell.swift @@ -5,11 +5,15 @@ import ViewModels class CompositionAttachmentCollectionViewCell: UICollectionViewCell { var viewModel: CompositionAttachmentViewModel? + var parentViewModel: CompositionViewModel? override func updateConfiguration(using state: UICellConfigurationState) { - guard let viewModel = viewModel else { return } + guard let viewModel = viewModel, let parentViewModel = parentViewModel else { return } - contentConfiguration = CompositionAttachmentContentConfiguration(viewModel: viewModel).updated(for: state) + contentConfiguration = CompositionAttachmentContentConfiguration( + viewModel: viewModel, + parentViewModel: parentViewModel) + .updated(for: state) backgroundConfiguration = UIBackgroundConfiguration.clear().updated(for: state) } } diff --git a/Views/CompositionAttachmentContentConfiguration.swift b/Views/CompositionAttachmentContentConfiguration.swift index a1796b8..8105799 100644 --- a/Views/CompositionAttachmentContentConfiguration.swift +++ b/Views/CompositionAttachmentContentConfiguration.swift @@ -5,6 +5,7 @@ import ViewModels struct CompositionAttachmentContentConfiguration { let viewModel: CompositionAttachmentViewModel + let parentViewModel: CompositionViewModel } extension CompositionAttachmentContentConfiguration: UIContentConfiguration { diff --git a/Views/CompositionAttachmentView.swift b/Views/CompositionAttachmentView.swift index d46d335..7a52277 100644 --- a/Views/CompositionAttachmentView.swift +++ b/Views/CompositionAttachmentView.swift @@ -6,11 +6,16 @@ import ViewModels class CompositionAttachmentView: UIView { let imageView = UIImageView() + let removeButton = UIButton() + let editButton = UIButton() private var compositionAttachmentConfiguration: CompositionAttachmentContentConfiguration + private var aspectRatioConstraint: NSLayoutConstraint init(configuration: CompositionAttachmentContentConfiguration) { self.compositionAttachmentConfiguration = configuration + aspectRatioConstraint = imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: 2) + super.init(frame: .zero) initialSetup() @@ -38,22 +43,70 @@ extension CompositionAttachmentView: UIContentView { } private extension CompositionAttachmentView { + // swiftlint:disable:next function_body_length func initialSetup() { + backgroundColor = .secondarySystemBackground + layer.cornerRadius = .defaultCornerRadius + clipsToBounds = true + addSubview(imageView) imageView.translatesAutoresizingMaskIntoConstraints = false imageView.contentMode = .scaleAspectFill - imageView.layer.cornerRadius = .defaultCornerRadius - imageView.clipsToBounds = true + imageView.kf.indicatorType = .activity + + addSubview(removeButton) + removeButton.translatesAutoresizingMaskIntoConstraints = false + removeButton.setImage( + UIImage( + systemName: "xmark.circle.fill", + withConfiguration: UIImage.SymbolConfiguration(scale: .large)), + for: .normal) + removeButton.showsMenuAsPrimaryAction = true + removeButton.menu = UIMenu( + children: [ + UIAction( + title: NSLocalizedString("compose.attachment.remove", comment: ""), + image: UIImage(systemName: "xmark.circle.fill"), + attributes: .destructive, handler: { [weak self] _ in + guard let self = self else { return } + + self.compositionAttachmentConfiguration.parentViewModel.remove( + attachmentViewModel: self.compositionAttachmentConfiguration.viewModel) + })]) + + addSubview(editButton) + editButton.translatesAutoresizingMaskIntoConstraints = false + editButton.setImage( + UIImage( + systemName: "pencil.circle.fill", + withConfiguration: UIImage.SymbolConfiguration(scale: .large)), + for: .normal) + editButton.addAction(UIAction { [weak self] _ in }, for: .touchUpInside) NSLayoutConstraint.activate([ + aspectRatioConstraint, imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.topAnchor.constraint(equalTo: topAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor) + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + removeButton.topAnchor.constraint(equalTo: topAnchor), + removeButton.trailingAnchor.constraint(equalTo: trailingAnchor), + removeButton.heightAnchor.constraint(equalToConstant: .minimumButtonDimension), + removeButton.widthAnchor.constraint(equalToConstant: .minimumButtonDimension), + editButton.trailingAnchor.constraint(equalTo: trailingAnchor), + editButton.bottomAnchor.constraint(equalTo: bottomAnchor), + editButton.heightAnchor.constraint(equalToConstant: .minimumButtonDimension), + editButton.widthAnchor.constraint(equalToConstant: .minimumButtonDimension) ]) } func applyCompositionAttachmentConfiguration() { imageView.kf.setImage(with: compositionAttachmentConfiguration.viewModel.attachment.previewUrl) + aspectRatioConstraint.isActive = false + aspectRatioConstraint = imageView.widthAnchor.constraint( + equalTo: imageView.heightAnchor, + multiplier: CGFloat(compositionAttachmentConfiguration.viewModel.attachment.aspectRatio ?? 1)) + aspectRatioConstraint.priority = .justBelowMax + aspectRatioConstraint.isActive = true } } diff --git a/Views/CompositionView.swift b/Views/CompositionView.swift index f22c845..8cd7adc 100644 --- a/Views/CompositionView.swift +++ b/Views/CompositionView.swift @@ -17,9 +17,10 @@ final class CompositionView: UIView { private var cancellables = Set() private lazy var attachmentsDataSource: CompositionAttachmentsDataSource = { - CompositionAttachmentsDataSource( - collectionView: attachmentsCollectionView) { [weak self] in - self?.viewModel.attachmentViewModel(indexPath: $0) + let vm = viewModel + + return .init(collectionView: attachmentsCollectionView) { + (vm.attachmentViewModels[$0.item], vm) } }() @@ -28,20 +29,25 @@ final class CompositionView: UIView { self.parentViewModel = parentViewModel let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(0.2), - heightDimension: .fractionalHeight(1.0)) + widthDimension: .estimated(Self.attachmentCollectionViewHeight), + heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .fractionalWidth(0.2)) + widthDimension: .estimated(Self.attachmentCollectionViewHeight), + heightDimension: .fractionalHeight(1)) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, subitems: [item]) - - group.interItemSpacing = .fixed(.defaultSpacing) - let section = NSCollectionLayoutSection(group: group) - let attachmentsLayout = UICollectionViewCompositionalLayout(section: section) + + section.interGroupSpacing = .defaultSpacing + + let configuration = UICollectionViewCompositionalLayoutConfiguration() + + configuration.scrollDirection = .horizontal + + let attachmentsLayout = UICollectionViewCompositionalLayout(section: section, configuration: configuration) + attachmentsCollectionView = UICollectionView(frame: .zero, collectionViewLayout: attachmentsLayout) super.init(frame: .zero) @@ -66,7 +72,7 @@ extension CompositionView: UITextViewDelegate { } private extension CompositionView { - static let attachmentUploadViewHeight: CGFloat = 100 + static let attachmentCollectionViewHeight: CGFloat = 200 // swiftlint:disable:next function_body_length func initialSetup() { @@ -114,6 +120,7 @@ private extension CompositionView { stackView.addArrangedSubview(attachmentsCollectionView) attachmentsCollectionView.dataSource = attachmentsDataSource + attachmentsCollectionView.backgroundColor = .clear stackView.addArrangedSubview(attachmentUploadView) @@ -139,7 +146,6 @@ private extension CompositionView { .store(in: &cancellables) viewModel.$attachmentViewModels - .receive(on: DispatchQueue.main) // hack to punt to next run loop, consider refactoring .sink { [weak self] in self?.attachmentsDataSource.apply($0.map(\.attachment).snapshot()) self?.attachmentsCollectionView.isHidden = $0.isEmpty @@ -161,10 +167,7 @@ private extension CompositionView { stackView.topAnchor.constraint(equalTo: guide.topAnchor), stackView.trailingAnchor.constraint(equalTo: guide.trailingAnchor), stackView.bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor), - attachmentsCollectionView.heightAnchor.constraint( - equalTo: attachmentsCollectionView.widthAnchor, - multiplier: 1 / 4), - attachmentUploadView.heightAnchor.constraint(equalToConstant: Self.attachmentUploadViewHeight) + attachmentsCollectionView.heightAnchor.constraint(equalToConstant: Self.attachmentCollectionViewHeight) ] if UIDevice.current.userInterfaceIdiom == .pad { diff --git a/Views/Status/StatusAttachmentsView.swift b/Views/Status/StatusAttachmentsView.swift index 76591ce..2d915c7 100644 --- a/Views/Status/StatusAttachmentsView.swift +++ b/Views/Status/StatusAttachmentsView.swift @@ -47,7 +47,7 @@ final class StatusAttachmentsView: UIView { let newAspectRatio: CGFloat - if attachmentCount == 1, let aspectRatio = attachmentViewModels.first?.aspectRatio { + if attachmentCount == 1, let aspectRatio = attachmentViewModels.first?.attachment.aspectRatio { newAspectRatio = max(CGFloat(aspectRatio), 16 / 9) } else { newAspectRatio = 16 / 9