Select multiple images for upload at once

This commit is contained in:
Justin Mazzocchi 2021-03-30 19:14:18 -07:00
parent d0709f718c
commit 1b95b493a1
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
7 changed files with 147 additions and 79 deletions

View file

@ -17,7 +17,7 @@ final class NewStatusViewController: UIViewController {
private let postButton = UIBarButtonItem(title: nil, style: .done, target: nil, action: nil)
private let mediaSelections = PassthroughSubject<[PHPickerResult], Never>()
private let imagePickerResults = PassthroughSubject<[UIImagePickerController.InfoKey: Any]?, Never>()
private let documentPickerResuls = PassthroughSubject<[URL]?, Never>()
private let documentPickerResults = PassthroughSubject<[URL]?, Never>()
private var cancellables = Set<AnyCancellable>()
init(viewModel: NewStatusViewModel, rootViewModel: RootViewModel?) {
@ -127,11 +127,11 @@ extension NewStatusViewController: UIImagePickerControllerDelegate {
extension NewStatusViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
documentPickerResuls.send(urls)
documentPickerResults.send(urls)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
documentPickerResuls.send(nil)
documentPickerResults.send(nil)
}
}
@ -282,19 +282,21 @@ private extension NewStatusViewController {
}
func presentMediaPicker(compositionViewModel: CompositionViewModel) {
mediaSelections.first().sink { [weak self] results in
guard let self = self, let result = results.first else { return }
self.viewModel.attach(itemProvider: result.itemProvider, to: compositionViewModel)
mediaSelections.first().sink { [weak self] in
self?.viewModel.attach(itemProviders: $0.map(\.itemProvider), to: compositionViewModel)
}
.store(in: &cancellables)
var configuration = PHPickerConfiguration()
configuration.preferredAssetRepresentationMode = .current
configuration.selectionLimit = CompositionViewModel.maxAttachmentCount
if !compositionViewModel.canAddNonImageAttachment {
configuration.filter = .images
configuration.selectionLimit = CompositionViewModel.maxAttachmentCount
- compositionViewModel.attachmentViewModels.count
- compositionViewModel.attachmentUploadViewModels.count
}
let picker = PHPickerViewController(configuration: configuration)
@ -332,9 +334,9 @@ private extension NewStatusViewController {
guard let self = self, let info = $0 else { return }
if let url = info[.mediaURL] as? URL, let itemProvider = NSItemProvider(contentsOf: url) {
self.viewModel.attach(itemProvider: itemProvider, to: compositionViewModel)
self.viewModel.attach(itemProviders: [itemProvider], to: compositionViewModel)
} else if let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage {
self.viewModel.attach(itemProvider: NSItemProvider(object: image), to: compositionViewModel)
self.viewModel.attach(itemProviders: [NSItemProvider(object: image)], to: compositionViewModel)
}
}
.store(in: &cancellables)
@ -356,22 +358,27 @@ private extension NewStatusViewController {
#endif
func presentDocumentPicker(compositionViewModel: CompositionViewModel) {
documentPickerResuls.first().sink { [weak self] in
guard let self = self,
let result = $0?.first,
result.startAccessingSecurityScopedResource(),
let itemProvider = NSItemProvider(contentsOf: result)
else { return }
documentPickerResults.first().sink { [weak self] in
guard let self = self, let results = $0 else { return }
self.viewModel.attach(itemProvider: itemProvider, to: compositionViewModel)
result.stopAccessingSecurityScopedResource()
let itemProviders = results.compactMap { result -> NSItemProvider? in
guard result.startAccessingSecurityScopedResource() else { return nil }
return NSItemProvider(contentsOf: result)
}
self.viewModel.attach(itemProviders: itemProviders, to: compositionViewModel)
for result in results {
result.stopAccessingSecurityScopedResource()
}
}
.store(in: &cancellables)
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .movie, .audio])
documentPickerController.delegate = self
documentPickerController.allowsMultipleSelection = false
documentPickerController.allowsMultipleSelection = true
documentPickerController.modalPresentationStyle = .overFullScreen
present(documentPickerController, animated: true)
}

View file

@ -1,9 +1,37 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Foundation
import Mastodon
import ServiceLayer
public struct AttachmentUpload: Hashable {
public let progress: Progress
public let data: Data
public let mimeType: String
public class AttachmentUploadViewModel: ObservableObject {
public let id = Id()
public let progress = Progress(totalUnitCount: 1)
public let parentViewModel: NewStatusViewModel
let data: Data
let mimeType: String
var cancellable: AnyCancellable?
init(data: Data, mimeType: String, parentViewModel: NewStatusViewModel) {
self.data = data
self.mimeType = mimeType
self.parentViewModel = parentViewModel
}
}
public extension AttachmentUploadViewModel {
typealias Id = UUID
func upload() -> AnyPublisher<Attachment, Error> {
parentViewModel.identityContext.service.uploadAttachment(
data: data,
mimeType: mimeType,
progress: progress)
}
func cancel() {
cancellable?.cancel()
}
}

View file

@ -22,7 +22,7 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
@Published public private(set) var contentWarningAutocompleteQuery: String?
@Published public private(set) var pollOptions = [PollOption(text: ""), PollOption(text: "")]
@Published public private(set) var attachmentViewModels = [AttachmentViewModel]()
@Published public private(set) var attachmentUpload: AttachmentUpload?
@Published public private(set) var attachmentUploadViewModels = [AttachmentUploadViewModel]()
@Published public private(set) var isPostable = false
@Published public private(set) var canAddAttachment = true
@Published public private(set) var canAddNonImageAttachment = true
@ -30,7 +30,7 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
public let canRemoveAttachments = true
private let eventsSubject: PassthroughSubject<Event, Never>
private var attachmentUploadCancellable: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
init(eventsSubject: PassthroughSubject<Event, Never>) {
self.eventsSubject = eventsSubject
@ -44,12 +44,17 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
.assign(to: &$isPostable)
$attachmentViewModels
.combineLatest($attachmentUpload, $displayPoll)
.map { $0.count < Self.maxAttachmentCount && $1 == nil && !$2 }
.combineLatest($attachmentUploadViewModels, $displayPoll)
.map { $0.count < Self.maxAttachmentCount && $1.isEmpty && !$2 }
.assign(to: &$canAddAttachment)
$attachmentViewModels.map(\.isEmpty).assign(to: &$canAddNonImageAttachment)
$attachmentUploadViewModels
.compactMap(\.first)
.sink { [weak self] in self?.upload(viewModel: $0) }
.store(in: &cancellables)
$text.map {
let tokens = $0.components(separatedBy: " ")
@ -83,6 +88,7 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab
public extension CompositionViewModel {
static let maxCharacters = 500
static let maxAttachmentCount = 4
static let minPollOptionCount = 2
static let maxPollOptionCount = 4
@ -172,7 +178,7 @@ public extension CompositionViewModel {
self.text = text
}
} else if let itemProvider = inputItem.attachments?.first {
attach(itemProvider: itemProvider, parentViewModel: parentViewModel)
attach(itemProviders: [itemProvider], parentViewModel: parentViewModel)
}
}
@ -197,39 +203,39 @@ public extension CompositionViewModel {
pollOptions.removeAll { $0 === pollOption }
}
func attach(itemProvider: NSItemProvider, parentViewModel: NewStatusViewModel) {
attachmentUploadCancellable = MediaProcessingService.dataAndMimeType(itemProvider: itemProvider)
.flatMap { [weak self] data, mimeType -> AnyPublisher<Attachment, Error> in
guard let self = self else { return Empty().eraseToAnyPublisher() }
let progress = Progress(totalUnitCount: 1)
DispatchQueue.main.async {
self.attachmentUpload = AttachmentUpload(progress: progress, data: data, mimeType: mimeType)
func attach(itemProviders: [NSItemProvider], parentViewModel: NewStatusViewModel) {
Publishers.MergeMany(itemProviders.map {
MediaProcessingService.dataAndMimeType(itemProvider: $0)
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: parentViewModel)
.map { result in
AttachmentUploadViewModel(
data: result.data,
mimeType: result.mimeType,
parentViewModel: parentViewModel)
}
})
.collect()
.assign(to: &$attachmentUploadViewModels)
}
return parentViewModel.identityContext.service.uploadAttachment(
data: data,
mimeType: mimeType,
progress: progress)
}
func upload(viewModel: AttachmentUploadViewModel) {
viewModel.cancellable = viewModel.upload()
.receive(on: DispatchQueue.main)
.assignErrorsToAlertItem(to: \.alertItem, on: parentViewModel)
.handleEvents(receiveCancel: { [weak self] in self?.attachmentUpload = nil })
.assignErrorsToAlertItem(to: \.alertItem, on: viewModel.parentViewModel)
.handleEvents(receiveCancel: { [weak self] in
self?.attachmentUploadViewModels.removeAll { $0 === viewModel }
})
.sink { [weak self] _ in
self?.attachmentUpload = nil
self?.attachmentUploadViewModels.removeAll { $0 === viewModel }
} receiveValue: { [weak self] in
self?.attachmentViewModels.append(
AttachmentViewModel(
attachment: $0,
identityContext: parentViewModel.identityContext))
identityContext: viewModel.parentViewModel.identityContext))
}
}
func cancelUpload() {
attachmentUploadCancellable?.cancel()
}
func update(attachmentViewModel: AttachmentViewModel) {
let publisher = attachmentViewModel.updated()
.receive(on: DispatchQueue.main)
@ -259,7 +265,6 @@ public extension CompositionViewModel.PollOption {
}
private extension CompositionViewModel {
static let maxAttachmentCount = 4
static let autocompleteQueryRegularExpression = #"([@#:]\S+)\z"#
static let emojiOnlyAutocompleteQueryRegularExpression = #"(:\S+)\z"#

View file

@ -187,8 +187,8 @@ public extension NewStatusViewModel {
}
}
func attach(itemProvider: NSItemProvider, to compositionViewModel: CompositionViewModel) {
compositionViewModel.attach(itemProvider: itemProvider, parentViewModel: self)
func attach(itemProviders: [NSItemProvider], to compositionViewModel: CompositionViewModel) {
compositionViewModel.attach(itemProviders: itemProviders, parentViewModel: self)
}
func post() {

View file

@ -9,12 +9,10 @@ final class AttachmentUploadView: UIView {
let cancelButton = UIButton(type: .system)
let progressView = UIProgressView(progressViewStyle: .default)
private let viewModel: CompositionViewModel
private var progressCancellable: AnyCancellable?
private let viewModel: AttachmentUploadViewModel
private var cancellables = Set<AnyCancellable>()
// swiftlint:disable:next function_body_length
init(viewModel: CompositionViewModel) {
init(viewModel: AttachmentUploadViewModel) {
self.viewModel = viewModel
super.init(frame: .zero)
@ -33,7 +31,7 @@ final class AttachmentUploadView: UIView {
cancelButton.titleLabel?.adjustsFontForContentSizeCategory = true
cancelButton.titleLabel?.font = .preferredFont(forTextStyle: .callout)
cancelButton.setTitle(NSLocalizedString("cancel", comment: ""), for: .normal)
cancelButton.addAction(UIAction { _ in viewModel.cancelUpload() }, for: .touchUpInside)
cancelButton.addAction(UIAction { _ in viewModel.cancel() }, for: .touchUpInside)
cancelButton.accessibilityLabel =
NSLocalizedString("compose.attachment.cancel-upload.accessibility-label", comment: "")
@ -53,21 +51,10 @@ final class AttachmentUploadView: UIView {
progressView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
])
viewModel.$attachmentUpload.sink { [weak self] attachmentUpload in
guard let self = self else { return }
UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) {
if let attachmentUpload = attachmentUpload {
self.progressCancellable = attachmentUpload.progress.publisher(for: \.fractionCompleted)
.receive(on: DispatchQueue.main)
.sink { self.progressView.progress = Float($0) }
self.isHidden = false
} else {
self.isHidden = true
}
}
}
.store(in: &cancellables)
viewModel.progress.publisher(for: \.fractionCompleted)
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.progressView.progress = Float($0) }
.store(in: &cancellables)
}
@available(*, unavailable)
@ -75,3 +62,7 @@ final class AttachmentUploadView: UIView {
fatalError("init(coder:) has not been implemented")
}
}
extension AttachmentUploadView {
var id: AttachmentUploadViewModel.Id { viewModel.id }
}

View file

@ -205,8 +205,8 @@ private extension CompositionInputAccessoryView {
.store(in: &cancellables)
viewModel.$attachmentViewModels
.combineLatest(viewModel.$attachmentUpload)
.sink { pollButton.isEnabled = $0.isEmpty && $1 == nil }
.combineLatest(viewModel.$attachmentUploadViewModels)
.sink { pollButton.isEnabled = $0.isEmpty && $1.isEmpty }
.store(in: &cancellables)
viewModel.$remainingCharacters.sink {

View file

@ -15,7 +15,7 @@ final class CompositionView: UIView {
let inReplyToView = UIView()
let hasReplyFollowingView = UIView()
let attachmentsView = AttachmentsView()
let attachmentUploadView: AttachmentUploadView
let attachmentUploadsStackView = UIStackView()
let pollView: CompositionPollView
let markAttachmentsSensitiveView: MarkAttachmentsSensitiveView
@ -27,7 +27,6 @@ final class CompositionView: UIView {
self.viewModel = viewModel
self.parentViewModel = parentViewModel
attachmentUploadView = AttachmentUploadView(viewModel: viewModel)
markAttachmentsSensitiveView = MarkAttachmentsSensitiveView(viewModel: viewModel)
pollView = CompositionPollView(viewModel: viewModel, parentViewModel: parentViewModel)
@ -128,8 +127,9 @@ private extension CompositionView {
stackView.addArrangedSubview(attachmentsView)
attachmentsView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(attachmentUploadView)
attachmentUploadView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(attachmentUploadsStackView)
attachmentUploadsStackView.axis = .vertical
attachmentUploadsStackView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(markAttachmentsSensitiveView)
markAttachmentsSensitiveView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(pollView)
@ -227,10 +227,22 @@ private extension CompositionView {
.sink { [weak self] in self?.textView.canPasteImage = $0 }
.store(in: &cancellables)
viewModel.$attachmentUploadViewModels
.throttle(for: .seconds(TimeInterval.zeroIfReduceMotion(.shortAnimationDuration)),
scheduler: DispatchQueue.main,
latest: true)
.sink { [weak self] attachmentUploadViewModels in
UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) {
self?.attachmentUploadsStackView.isHidden_stackViewSafe = attachmentUploadViewModels.isEmpty
self?.update(attachmentUploadViewModels: attachmentUploadViewModels)
}
}
.store(in: &cancellables)
textView.pastedItemProviders.sink { [weak self] in
guard let self = self else { return }
self.viewModel.attach(itemProvider: $0,
self.viewModel.attach(itemProviders: [$0],
parentViewModel: self.parentViewModel)
}
.store(in: &cancellables)
@ -366,4 +378,29 @@ private extension CompositionView {
range: textToSelectedRangeRange)
spoilerTextFieldEditingChanged()
}
func update(attachmentUploadViewModels: [AttachmentUploadViewModel]) {
let diff = attachmentUploadViewModels.map(\.id)
.difference(from: attachmentUploadsStackView
.arrangedSubviews
.compactMap { ($0 as? AttachmentUploadView)?.id })
for insertion in diff.insertions {
guard case let .insert(index, id, _) = insertion,
let attachmentUploadViewModel = attachmentUploadViewModels.first(where: { $0.id == id })
else { continue }
let attachmentUploadView = AttachmentUploadView(viewModel: attachmentUploadViewModel)
attachmentUploadsStackView.insertArrangedSubview(attachmentUploadView, at: index)
}
for removal in diff.removals {
guard case let .remove(_, id, _) = removal,
let index = attachmentUploadsStackView.arrangedSubviews.firstIndex(where: { ($0 as? AttachmentUploadView)?.id == id })
else { continue }
attachmentUploadsStackView.arrangedSubviews[index].removeFromSuperview()
}
}
}