// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import UIKit public protocol TextViewWithPlaceholderDelegate: AnyObject { /// A method invoked by the text field when its cursor/selection changed without any change /// to the text func textViewDidUpdateSelection(_ textView: TextViewWithPlaceholder) /// A method invoked by the text field whenever its text contents have changed /// This also implies an update to the selection func textViewDidUpdateText(_ textView: TextViewWithPlaceholder) /// A method invoked by the text field whenever the user tries to insert new text func textView(_ textView: TextViewWithPlaceholder, uiTextView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool /// A method invoked by the text field whenever it begins editing, i.e. /// in response to `becomeFirstResponder()`. func textViewDidBeginEditing(_ textView: TextViewWithPlaceholder) /// A method invoked by the text field whenever it ends editing, i.e. in /// response to `resignFirstResponder()`. func textViewDidEndEditing(_ textView: TextViewWithPlaceholder) } public extension TextViewWithPlaceholderDelegate { func textViewDidUpdateSelection(_ textView: TextViewWithPlaceholder) {} func textViewDidUpdateText(_ textView: TextViewWithPlaceholder) {} func textView(_ textView: TextViewWithPlaceholder, uiTextView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { return true } func textViewDidBeginEditing(_ textView: TextViewWithPlaceholder) {} func textViewDidEndEditing(_ textView: TextViewWithPlaceholder) {} } // MARK: - public class TextViewWithPlaceholder: UIView, UITextViewDelegate { // MARK: - Public Properties /// A delegate to receive callbacks on any data updates public weak var delegate: TextViewWithPlaceholderDelegate? /// Fallback placeholder text if the field is empty public var placeholderText: String = "" { didSet { placeholderTextView.text = placeholderText textView.accessibilityLabel = placeholderText } } public func acceptAutocorrectSuggestion() { textView.acceptAutocorrectSuggestion() } /// Any text the user has input public var text: String? { get { textView.text } set { textView.text = newValue textViewDidChange(textView) } } public var editorFont: UIFont? { get { textView.font } set { textView.font = newValue } } public var textContainerInset: UIEdgeInsets { get { textView.textContainerInset } set { textView.textContainerInset = newValue placeholderTextView.textContainerInset = newValue } } public var autocorrectionType: UITextAutocorrectionType { get { textView.autocorrectionType } set { textView.autocorrectionType = newValue } } public var spellCheckingType: UITextSpellCheckingType { get { textView.spellCheckingType } set { textView.spellCheckingType = newValue } } public var returnKeyType: UIReturnKeyType { get { textView.returnKeyType } set { textView.returnKeyType = newValue } } public var keyboardType: UIKeyboardType { get { textView.keyboardType } set { textView.keyboardType = newValue } } public var dataDetectorTypes: UIDataDetectorTypes { get { textView.dataDetectorTypes } set { textView.dataDetectorTypes = newValue } } public var isEditable: Bool { get { textView.isEditable } set { textView.isEditable = newValue } } public var linkTextAttributes: [NSAttributedString.Key: Any] { get { textView.linkTextAttributes } set { textView.linkTextAttributes = newValue } } @discardableResult override public func becomeFirstResponder() -> Bool { textView.becomeFirstResponder() } @discardableResult override public func resignFirstResponder() -> Bool { textView.resignFirstResponder() } override public var canBecomeFirstResponder: Bool { textView.canBecomeFirstResponder } override public var isFirstResponder: Bool { textView.isFirstResponder } public func setSecureTextEntry(val: Bool) { textView.isSecureTextEntry = val } public func setTextContentType(val: UITextContentType) { textView.textContentType = val } public func reformatText(replacementText: String) { textView.text = "" _ = self.delegate?.textView(self, uiTextView: textView, shouldChangeTextIn: NSRange(location: 0, length: 0), replacementText: replacementText) } // MARK: - Private Properties private func buildTextView() -> UITextView { let textView = UITextView() textView.disableAiWritingTools() textView.isScrollEnabled = false textView.backgroundColor = .clear textView.font = UIFont.dynamicTypeBody textView.adjustsFontForContentSizeCategory = true textView.textContainer.lineFragmentPadding = 0 // This would make things align a bit more nicely, but it totally breaks VoiceOver for some reason // Leaving the default inset for now until I can track down what's tripping up VoiceOver. // textView.textContainerInset = .zero return textView } private lazy var textView = buildTextView() private lazy var placeholderTextView = buildTextView() // MARK: - Lifecycle override public init(frame: CGRect) { super.init(frame: frame) applyTheme() textView.delegate = self textView.isEditable = true placeholderTextView.isEditable = false placeholderTextView.isUserInteractionEnabled = false // The placeholderTextView is perfectly aligned with the textView to allow for us to easily // hide/show placeholder text without needing to manipulate the text property of our primary // text view. This makes VoiceOver navigation by dragging a bit tricky, since a user won't be // able to find the placeholder text. Let's disable it in VoiceOver. Instead, placeholderText // will be an accessibility label on the primary text view. placeholderTextView.accessibilityElementsHidden = true // Layout + Constraints for subview in [textView, placeholderTextView] { addSubview(subview) subview.autoPinEdgesToSuperviewEdges() subview.setCompressionResistanceHigh() } NotificationCenter.default.addObserver(self, selector: #selector(applyTheme), name: .themeDidChange, object: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Private @objc private func applyTheme() { placeholderTextView.textColor = .placeholderText textView.textColor = Theme.primaryTextColor } // MARK: - // These help to track the user's edit focus private var oldStartCaretRect: CGRect = .null private var oldEndCaretRect: CGRect = .null private var focusedLineRect: CGRect = .null /// Returns a rect the height of a cursor and the width of the view, indicating the line the user is currently focused on /// This is determined by looking at the current position of the cursor(s) and how they have changed since this method /// was last invoked. /// If one cursor changed -> It's line has focus /// If both cursors changed -> The bottom cursor is designated to have tiebreaker focus /// Returns CGRectNull if there's no current selection func getUpdatedFocusLine() -> CGRect { guard let selectedRange = textView.selectedTextRange else { // Reset all stored rects to force an update whenever this is non-nil oldStartCaretRect = .null oldEndCaretRect = .null focusedLineRect = .null return .null } // Note: textView.caretRect(for:) will return CGRectNull while mid-edit // If the rects are null, we just ignore them. let startCaretRect = textView.caretRect(for: selectedRange.start) let endCaretRect = textView.caretRect(for: selectedRange.end) let didModifyStart = !startCaretRect.equalTo(oldStartCaretRect) && !startCaretRect.isNull let didModifyEnd = !endCaretRect.equalTo(oldEndCaretRect) && !startCaretRect.isNull // End cursor is the tiebreaker if they're both modified. Last writer wins if didModifyStart { oldStartCaretRect = startCaretRect focusedLineRect = createWideRect(from: startCaretRect) } if didModifyEnd { oldEndCaretRect = endCaretRect focusedLineRect = createWideRect(from: endCaretRect) } return focusedLineRect } /// Ensures the currently focused area is scrolled into the visible content inset /// If it's already visible, this will do nothing public func scrollToFocus(in scrollView: UIScrollView, animated: Bool) { let visibleRect = scrollView.bounds.inset(by: scrollView.adjustedContentInset) let rawCursorFocusRect = getUpdatedFocusLine() let cursorFocusRect = scrollView.convert(rawCursorFocusRect, from: self) let paddedCursorRect = cursorFocusRect.insetBy(dx: 0, dy: -6) let entireContentFits = scrollView.contentSize.height <= visibleRect.height let focusRect = entireContentFits ? visibleRect : paddedCursorRect // If we have a null rect, there's nowhere to scroll to // If the focusRect is already visible, there's no need to scroll guard !focusRect.isNull else { return } guard !visibleRect.contains(focusRect) else { return } let targetYOffset: CGFloat if focusRect.minY < visibleRect.minY { targetYOffset = focusRect.minY - scrollView.adjustedContentInset.top } else { let bottomEdgeOffset = scrollView.height - scrollView.adjustedContentInset.bottom targetYOffset = focusRect.maxY - bottomEdgeOffset } scrollView.setContentOffset(CGPoint(x: 0, y: targetYOffset), animated: animated) } /// Helper to take a rect and horizontally size it to the current bounds private func createWideRect(from rect: CGRect) -> CGRect { return CGRect(x: 0, y: rect.minY, width: width, height: rect.height) } // MARK: - UITextViewDelegate public func textViewDidChangeSelection(_ textView: UITextView) { delegate?.textViewDidUpdateSelection(self) } public func textViewDidChange(_ textView: UITextView) { let showPlaceholder = textView.text.isEmpty placeholderTextView.isHidden = !showPlaceholder delegate?.textViewDidUpdateText(self) } public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { delegate?.textView(self, uiTextView: textView, shouldChangeTextIn: range, replacementText: text) ?? true } public func textViewDidBeginEditing(_ textView: UITextView) { delegate?.textViewDidBeginEditing(self) } public func textViewDidEndEditing(_ textView: UITextView) { delegate?.textViewDidEndEditing(self) } }