Tusker/Tusker/Screens/Compose/MainComposeTextView.swift

349 lines
15 KiB
Swift

//
// MainComposeTextView.swift
// Tusker
//
// Created by Shadowfacts on 8/29/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct MainComposeTextView: View {
@ObservedObject var draft: Draft
let placeholder: Text
let minHeight: CGFloat = 150
@State private var height: CGFloat?
@State private var becomeFirstResponder: Bool = false
@State private var hasFirstAppeared = false
var body: some View {
ZStack(alignment: .topLeading) {
Color(UIColor.secondarySystemBackground)
if draft.text.isEmpty {
placeholder
.font(.system(size: 20))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
MainComposeWrappedTextView(
text: $draft.text,
visibility: draft.visibility,
becomeFirstResponder: $becomeFirstResponder
) { (textView) in
self.height = max(textView.contentSize.height, minHeight)
}
.frame(height: height ?? minHeight)
}.onAppear {
if !hasFirstAppeared {
hasFirstAppeared = true
becomeFirstResponder = true
}
}
}
}
struct MainComposeWrappedTextView: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
let visibility: Status.Visibility
@Binding var becomeFirstResponder: Bool
var textDidChange: (UITextView) -> Void
@EnvironmentObject var uiState: ComposeUIState
@State var visibilityButton: UIBarButtonItem?
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isEditable = true
textView.backgroundColor = .clear
textView.font = .systemFont(ofSize: 20)
textView.textContainer.lineBreakMode = .byWordWrapping
context.coordinator.textView = textView
uiState.autocompleteHandler = context.coordinator
let visibilityAction: Selector?
if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(ComposeHostingController.visibilityButtonPressed(_:))
}
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: visibilityAction)
updateVisibilityMenu(visibilityButton)
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: nil, action: #selector(ComposeHostingController.cwButtonPressed)),
visibilityButton,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
] + createFormattingButtons(coordinator: context.coordinator) + [
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(title: "Drafts", style: .plain, target: nil, action: #selector(ComposeHostingController.draftsButtonPresed)),
]
textView.inputAccessoryView = toolbar
// can't modify @State during view update
DispatchQueue.main.async {
self.visibilityButton = visibilityButton
}
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
return textView
}
private func createFormattingButtons(coordinator: Coordinator) -> [UIBarButtonItem] {
guard Preferences.shared.statusContentType != .plain else {
return []
}
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
let item: UIBarButtonItem
if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
} else if let (str, attributes) = format.title {
item = UIBarButtonItem(title: str, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
item.setTitleTextAttributes(attributes, for: .normal)
item.setTitleTextAttributes(attributes, for: .highlighted)
} else {
fatalError("StatusFormat must have either an image or a title")
}
item.tag = StatusFormat.allCases.firstIndex(of: format)!
item.accessibilityLabel = format.accessibilityLabel
return item
}
for i in (1..<StatusFormat.allCases.count).reversed() {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
formatButtons.insert(spacer, at: i)
}
return formatButtons
}
private func updateVisibilityMenu(_ visibilityButton: UIBarButtonItem) {
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == self.visibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
self.uiState.draft.visibility = visibility
}
}
visibilityButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
if let visibilityButton = visibilityButton {
visibilityButton.image = UIImage(systemName: visibility.imageName)
updateVisibilityMenu(visibilityButton)
}
context.coordinator.text = $text
context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState
// wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size
DispatchQueue.main.async {
self.textDidChange(uiView)
if becomeFirstResponder {
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
uiView.becomeFirstResponder()
// can't update @State vars during the SwiftUI update
becomeFirstResponder = false
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, uiState: uiState, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler {
weak var textView: UITextView?
var text: Binding<String>
var didChange: (UITextView) -> Void
var uiState: ComposeUIState
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
self.text = text
self.didChange = didChange
self.uiState = uiState
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
didChange(textView)
updateAutocompleteState()
}
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
guard let textView = textView, textView.isFirstResponder else { return }
let format = StatusFormat.allCases[sender.tag]
guard let insertionResult = format.insertionResult else { return }
let currentSelectedRange = textView.selectedRange
if currentSelectedRange.length == 0 {
textView.insertText(insertionResult.prefix + insertionResult.suffix)
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
} else {
let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound)
let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound)
let selectedText = textView.text[start..<end]
textView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.utf16.count, length: currentSelectedRange.length)
}
}
@objc func keyboardWillShow(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillShow(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardWillHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
func textViewDidEndEditing(_ textView: UITextView) {
updateAutocompleteState()
}
func textViewDidBeginEditing(_ textView: UITextView) {
updateAutocompleteState()
}
func textViewDidChangeSelection(_ textView: UITextView) {
updateAutocompleteState()
}
func autocomplete(with string: String) {
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
return
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
textView.text.replaceSubrange(lastWordStartIndex..<characterBeforeCursorIndex, with: string)
self.textViewDidChange(textView)
}
private func updateAutocompleteState() {
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
uiState.autocompleteState = nil
return
}
let triggerChars: [Character] = ["@", ":", "#"]
if lastWordStartIndex > text.startIndex {
// if the character before the "word" beginning is a valid part of a "word",
// we aren't able to autocomplete
let c = text[text.index(before: lastWordStartIndex)]
if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
uiState.autocompleteState = nil
return
}
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
if lastWordStartIndex >= text.startIndex {
let lastWord = text[lastWordStartIndex..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign {
uiState.autocompleteState = .mention(String(exceptFirst))
} else {
uiState.autocompleteState = nil
}
return
}
switch lastWord.first {
case "@":
uiState.autocompleteState = .mention(String(exceptFirst))
case ":":
uiState.autocompleteState = .emoji(String(exceptFirst))
case "#":
uiState.autocompleteState = .hashtag(String(exceptFirst))
default:
uiState.autocompleteState = nil
}
} else {
uiState.autocompleteState = nil
}
}
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
}
private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
guard let textView = textView,
textView.isFirstResponder,
textView.selectedRange.length == 0,
textView.selectedRange.upperBound > 0,
let text = textView.text,
text.count > 0 else {
return nil
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)
var lastWordStartIndex = text.index(before: characterBeforeCursorIndex)
var foundFirstAtSign = false
while true {
let c = textView.text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
if foundFirstAtSign {
if c != "@" {
// move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
lastWordStartIndex = text.index(after: lastWordStartIndex)
}
break
} else {
if c == "@" {
foundFirstAtSign = true
} else if c != "." {
// periods are allowed for domain names in mentions
break
}
}
}
if lastWordStartIndex > text.startIndex {
lastWordStartIndex = text.index(before: lastWordStartIndex)
} else {
break
}
}
return (lastWordStartIndex, foundFirstAtSign)
}
}
}
protocol ComposeAutocompleteHandler: class {
func autocomplete(with string: String)
}