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

View file

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

View file

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

View file

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

View file

@ -15,7 +15,7 @@ final class CompositionView: UIView {
let inReplyToView = UIView() let inReplyToView = UIView()
let hasReplyFollowingView = UIView() let hasReplyFollowingView = UIView()
let attachmentsView = AttachmentsView() let attachmentsView = AttachmentsView()
let attachmentUploadView: AttachmentUploadView let attachmentUploadsStackView = UIStackView()
let pollView: CompositionPollView let pollView: CompositionPollView
let markAttachmentsSensitiveView: MarkAttachmentsSensitiveView let markAttachmentsSensitiveView: MarkAttachmentsSensitiveView
@ -27,7 +27,6 @@ final class CompositionView: UIView {
self.viewModel = viewModel self.viewModel = viewModel
self.parentViewModel = parentViewModel self.parentViewModel = parentViewModel
attachmentUploadView = AttachmentUploadView(viewModel: viewModel)
markAttachmentsSensitiveView = MarkAttachmentsSensitiveView(viewModel: viewModel) markAttachmentsSensitiveView = MarkAttachmentsSensitiveView(viewModel: viewModel)
pollView = CompositionPollView(viewModel: viewModel, parentViewModel: parentViewModel) pollView = CompositionPollView(viewModel: viewModel, parentViewModel: parentViewModel)
@ -128,8 +127,9 @@ private extension CompositionView {
stackView.addArrangedSubview(attachmentsView) stackView.addArrangedSubview(attachmentsView)
attachmentsView.isHidden_stackViewSafe = true attachmentsView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(attachmentUploadView) stackView.addArrangedSubview(attachmentUploadsStackView)
attachmentUploadView.isHidden_stackViewSafe = true attachmentUploadsStackView.axis = .vertical
attachmentUploadsStackView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(markAttachmentsSensitiveView) stackView.addArrangedSubview(markAttachmentsSensitiveView)
markAttachmentsSensitiveView.isHidden_stackViewSafe = true markAttachmentsSensitiveView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(pollView) stackView.addArrangedSubview(pollView)
@ -227,10 +227,22 @@ private extension CompositionView {
.sink { [weak self] in self?.textView.canPasteImage = $0 } .sink { [weak self] in self?.textView.canPasteImage = $0 }
.store(in: &cancellables) .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 textView.pastedItemProviders.sink { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.viewModel.attach(itemProvider: $0, self.viewModel.attach(itemProviders: [$0],
parentViewModel: self.parentViewModel) parentViewModel: self.parentViewModel)
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -366,4 +378,29 @@ private extension CompositionView {
range: textToSelectedRangeRange) range: textToSelectedRangeRange)
spoilerTextFieldEditingChanged() 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()
}
}
} }