// // 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.. 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 var didChange: (UITextView) -> Void var uiState: ComposeUIState init(text: Binding, 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.. 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.. 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) }