// // MainTextView.swift // ComposeUI // // Created by Shadowfacts on 3/6/23. // import SwiftUI struct MainTextView: View { @EnvironmentObject private var controller: ComposeController @EnvironmentObject private var draft: Draft @Environment(\.colorScheme) private var colorScheme @ScaledMetric private var fontSize = 20 @State private var hasFirstAppeared = false @State private var height: CGFloat? private let minHeight: CGFloat = 150 private var effectiveHeight: CGFloat { height ?? minHeight } var config: ComposeUIConfig { controller.config } var body: some View { ZStack(alignment: .topLeading) { colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground) if draft.text.isEmpty { ControllerView(controller: { PlaceholderController() }) .font(.system(size: fontSize)) .foregroundColor(.secondary) .offset(x: 4, y: 8) .accessibilityHidden(true) } MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, textDidChange: textDidChange) } .frame(height: effectiveHeight) .onAppear(perform: becomeFirstResponderOnFirstAppearance) } private func becomeFirstResponderOnFirstAppearance() { if !hasFirstAppeared { hasFirstAppeared = true controller.mainComposeTextViewBecomeFirstResponder = true } } private func textDidChange(textView: UITextView) { height = max(textView.contentSize.height, minHeight) } } fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable { typealias UIViewType = UITextView @Binding var text: String @Binding var becomeFirstResponder: Bool let textDidChange: (UITextView) -> Void @EnvironmentObject private var controller: ComposeController @Environment(\.isEnabled) private var isEnabled: Bool func makeUIView(context: Context) -> UITextView { let textView = WrappedTextView(composeController: controller) context.coordinator.textView = textView textView.delegate = context.coordinator textView.isEditable = true textView.backgroundColor = .clear textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)) textView.adjustsFontForContentSizeCategory = true textView.textContainer.lineBreakMode = .byWordWrapping return textView } func updateUIView(_ uiView: UITextView, context: Context) { if text != uiView.text { context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true uiView.text = text } uiView.isEditable = isEnabled uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default context.coordinator.text = $text // 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 { 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 { Coordinator(controller: controller, text: $text, textDidChange: textDidChange) } class WrappedTextView: UITextView { private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))] private let composeController: ComposeController init(composeController: ComposeController) { self.composeController = composeController super.init(frame: .zero, textContainer: nil) } required init?(coder: NSCoder) { fatalError() } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if formattingActions.contains(action) { return composeController.config.contentType != .plain } return super.canPerformAction(action, withSender: sender) } override func toggleBoldface(_ sender: Any?) { (delegate as! Coordinator).applyFormat(.bold) } override func toggleItalics(_ sender: Any?) { (delegate as! Coordinator).applyFormat(.italics) } override func validate(_ command: UICommand) { super.validate(command) if formattingActions.contains(command.action), composeController.config.contentType != .plain { command.attributes.remove(.disabled) } } override func paste(_ sender: Any?) { // we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion // and things like URLs end up pasting as attachments if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) { composeController.paste(itemProviders: UIPasteboard.general.itemProviders) } else { super.paste(sender) } } } class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling { weak var textView: UITextView? let controller: ComposeController var text: Binding let textDidChange: (UITextView) -> Void var caretScrollPositionAnimator: UIViewPropertyAnimator? @Published var autocompleteState: AutocompleteState? var autocompleteStatePublisher: Published.Publisher { $autocompleteState } var skipNextSelectionChangedAutocompleteUpdate = false init(controller: ComposeController, text: Binding, textDidChange: @escaping (UITextView) -> Void) { self.controller = controller self.text = text self.textDidChange = textDidChange super.init() NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil) } @objc private func keyboardDidShow() { guard let textView, textView.isFirstResponder else { return } ensureCursorVisible(textView: textView) } // MARK: UITextViewDelegate func textViewDidChange(_ textView: UITextView) { text.wrappedValue = textView.text textDidChange(textView) ensureCursorVisible(textView: textView) } func textViewDidBeginEditing(_ textView: UITextView) { controller.currentInput = self updateAutocompleteState() } func textViewDidEndEditing(_ textView: UITextView) { controller.currentInput = nil updateAutocompleteState() } func textViewDidChangeSelection(_ textView: UITextView) { if skipNextSelectionChangedAutocompleteUpdate { skipNextSelectionChangedAutocompleteUpdate = false } else { updateAutocompleteState() } } func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { var actions = suggestedActions if controller.config.contentType != .plain, let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) { if range.length > 0 { let formatMenu = suggestedActions[index] as! UIMenu let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in var image: UIImage? if let imageName = fmt.imageName { image = UIImage(systemName: imageName) } return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in self?.applyFormat(fmt) } }) actions[index] = newFormatMenu } else { actions.remove(at: index) } } if range.length == 0 { actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in self?.controller.shouldEmojiAutocompletionBeginExpanded = true self?.beginAutocompletingEmoji() })) } return UIMenu(children: actions) } // MARK: ComposeInput var toolbarElements: [ToolbarElement] { [.emojiPicker, .formattingButtons] } func autocomplete(with string: String) { textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState) } func applyFormat(_ format: StatusFormat) { guard let textView, textView.isFirstResponder, let insertionResult = format.insertionResult(for: controller.config.contentType) 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.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound) let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound) let selectedText = textView.text.utf16[start.. 0 { let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound)) insertSpace = !text[characterBeforeCursorIndex].isWhitespace } textView.insertText((insertSpace ? " " : "") + ":") } private func updateAutocompleteState() { guard let textView else { autocompleteState = nil return } autocompleteState = textView.updateAutocompleteState(permittedModes: .all) } } }