Tusker/Tusker/Screens/Compose/ComposeHostingController.swift

411 lines
17 KiB
Swift

//
// ComposeHostingController.swift
// Tusker
//
// Created by Shadowfacts on 8/22/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Combine
import Pachyderm
import PencilKit
class ComposeHostingController: UIHostingController<ComposeContainerView> {
let mastodonController: MastodonController
let uiState: ComposeUIState
var draft: Draft { uiState.draft }
private var cancellables = [AnyCancellable]()
private var keyboardHeight: CGFloat = 0
private var toolbarHeight: CGFloat = 44
private var mainToolbar: UIToolbar!
private var inputAccessoryToolbar: UIToolbar!
private var visibilityBarButtonItems = [UIBarButtonItem]()
override var inputAccessoryView: UIView? { inputAccessoryToolbar }
init(draft: Draft? = nil, mastodonController: MastodonController) {
self.mastodonController = mastodonController
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
DraftsManager.shared.add(realDraft)
self.uiState = ComposeUIState(draft: realDraft)
// we need our own environment object wrapper so that we can set the mastodon controller as an
// environment object and setup the draft change listener while still having a concrete type
// to use as the UIHostingController type parameter
let container = ComposeContainerView(
mastodonController: mastodonController,
uiState: uiState
)
super.init(rootView: container)
self.uiState.delegate = self
// main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing
mainToolbar = createToolbar()
inputAccessoryToolbar = createToolbar()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
// add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it
updateAdditionalSafeAreaInsets()
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
userActivity = UserActivityManager.newPostActivity()
self.uiState.$draft
.flatMap(\.$visibility)
.sink(receiveValue: self.visibilityChanged)
.store(in: &cancellables)
self.uiState.$draft
.flatMap(\.objectWillChange)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
.sink {
DraftsManager.save()
}
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// can't do this in viewDidLoad because viewDidLoad isn't called for UIHostingController
// if mainToolbar.superview == nil {
// view.addSubview(mainToolbar)
// NSLayoutConstraint.activate([
// mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// // use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
// mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
// ])
// }
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if let parent = parent {
parent.view.addSubview(mainToolbar)
NSLayoutConstraint.activate([
mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if !draft.hasContent {
DraftsManager.shared.remove(draft)
}
DraftsManager.save()
}
private func createToolbar() -> UIToolbar {
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.isAccessibilityElement = true
let visibilityAction: Selector?
if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(visibilityButtonPressed(_:))
}
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: visibilityAction)
visibilityBarButtonItems.append(visibilityItem)
visibilityChanged(draft.visibility)
toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)),
visibilityItem,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed))
]
return toolbar
}
private func updateAdditionalSafeAreaInsets() {
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight + keyboardHeight, right: 0)
}
@objc private func keyboardWillShow(_ notification: Foundation.Notification) {
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) {
mainToolbar.isHidden = true
accessoryView.alpha = 1
accessoryView.isHidden = false
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
let userInfo = notification.userInfo!
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// temporarily reset add'l safe area insets so we can access the default inset
additionalSafeAreaInsets = .zero
// there are a few extra points that come from somewhere, it seems to be four
// and without it, the autocomplete suggestions are cut off :S
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height + 4
updateAdditionalSafeAreaInsets()
}
}
@objc private func keyboardWillHide(_ notification: Foundation.Notification) {
keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) {
mainToolbar.isHidden = false
let userInfo = notification.userInfo!
let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber
let duration = TimeInterval(durationObj.doubleValue)
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber
let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)!
let curveOption: UIView.AnimationOptions
switch curve {
case .easeInOut:
curveOption = .curveEaseInOut
case .easeIn:
curveOption = .curveEaseIn
case .easeOut:
curveOption = .curveEaseOut
case .linear:
curveOption = .curveLinear
@unknown default:
curveOption = .curveLinear
}
UIView.animate(withDuration: duration, delay: 0, options: curveOption) {
accessoryView.alpha = 0
} completion: { (finished) in
accessoryView.alpha = 1
}
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
keyboardHeight = 0
updateAdditionalSafeAreaInsets()
}
}
@objc private func keyboardDidHide(_ notification: Foundation.Notification) {
keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) {
accessoryView.isHidden = true
}
private func visibilityChanged(_ newVisibility: Status.Visibility) {
for item in visibilityBarButtonItems {
item.image = UIImage(systemName: newVisibility.imageName)
item.image!.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == newVisibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
self.draft.visibility = visibility
}
}
item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
switch mastodonController.instance.instanceType {
case .pleroma:
return true
case .mastodon:
guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false }
// todo: if providers are videos, this technically allows invalid video/image combinations
return itemProviders.count + draft.attachments.count <= 4
}
}
override func paste(itemProviders: [NSItemProvider]) {
for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) {
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
guard let attachment = object as? CompositionAttachment else { return }
DispatchQueue.main.async {
self.draft.attachments.append(attachment)
}
}
}
}
// MARK: Interaction
@objc func cwButtonPressed() {
draft.contentWarningEnabled = !draft.contentWarningEnabled
}
@objc func visibilityButtonPressed(_ sender: UIBarButtonItem) {
// if #available(iOS 14.0, *) {
// } else {
let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in
guard let visibility = visibility else { return }
self.draft.visibility = visibility
}
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true)
// }
}
@objc func draftsButtonPresed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
draftsVC.delegate = self
present(UINavigationController(rootViewController: draftsVC), animated: true)
}
}
extension ComposeHostingController: ComposeUIStateDelegate {
var assetPickerDelegate: AssetPickerViewControllerDelegate? { self }
func dismissCompose() {
self.dismiss(animated: true)
}
func presentAssetPickerSheet() {
let sheetContainer = AssetPickerSheetContainerViewController()
sheetContainer.assetPicker.assetPickerDelegate = self
self.present(sheetContainer, animated: true)
}
func presentComposeDrawing() {
let drawing: PKDrawing
if case let .edit(id) = uiState.composeDrawingMode,
let attachment = draft.attachments.first(where: { $0.id == id }),
case let .drawing(existingDrawing) = attachment.data {
drawing = existingDrawing
} else {
drawing = PKDrawing()
}
let drawingVC = ComposeDrawingViewController(editing: drawing)
drawingVC.delegate = self
let nav = UINavigationController(rootViewController: drawingVC)
nav.modalPresentationStyle = .fullScreen
present(nav, animated: true)
}
}
extension ComposeHostingController: AssetPickerViewControllerDelegate {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
switch mastodonController.instance.instanceType {
case .pleroma:
return true
case .mastodon:
if (type == .video && draft.attachments.count > 0) ||
draft.attachments.contains(where: { $0.data.type == .video }) ||
assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) {
return false
}
return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4
}
}
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) {
let attachments = attachments.map {
CompositionAttachment(data: $0)
}
draft.attachments.append(contentsOf: attachments)
}
}
extension ComposeHostingController: DraftsTableViewControllerDelegate {
func draftSelectionCanceled() {
}
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void) {
if draft.inReplyToID != self.draft.inReplyToID,
self.draft.hasContent {
let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
completion(false)
}))
alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in
completion(true)
}))
// we can't present the laert ourselves since the compose VC is already presenting the draft selector
// but presenting on the presented view controller seems hacky, is there a better way to do this?
presentedViewController!.present(alertController, animated: true)
} else {
completion(true)
}
}
func draftSelected(_ draft: Draft) {
if self.draft.hasContent {
DraftsManager.save()
} else {
DraftsManager.shared.remove(self.draft)
}
uiState.draft = draft
}
func draftSelectionCompleted() {
}
}
extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
uiState.isShowingSaveDraftSheet = true
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
DraftsManager.save()
}
}
extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
dismiss(animated: true)
}
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
switch uiState.composeDrawingMode {
case nil, .createNew:
let attachment = CompositionAttachment(data: .drawing(drawing))
draft.attachments.append(attachment)
case let .edit(id):
let existing = draft.attachments.first { $0.id == id }
existing?.data = .drawing(drawing)
}
dismiss(animated: true)
}
}