TextViewWrapper.swift (10950B)
1 // 2 // TextViewWrapper.swift 3 // damus 4 // 5 // Created by Swift on 2/24/23. 6 // 7 8 import SwiftUI 9 10 struct TextViewWrapper: UIViewRepresentable { 11 @Binding var attributedText: NSMutableAttributedString 12 @EnvironmentObject var tagModel: TagModel 13 @Binding var textHeight: CGFloat? 14 let initialTextSuffix: String? 15 16 let cursorIndex: Int? 17 var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil 18 let updateCursorPosition: ((Int) -> Void) 19 20 func makeUIView(context: Context) -> UITextView { 21 let textView = UITextView() 22 textView.backgroundColor = UIColor(DamusColors.adaptableWhite) 23 textView.delegate = context.coordinator 24 25 // Disable scrolling (this view will expand vertically as needed to fit text) 26 textView.isScrollEnabled = false 27 // Set low content compression resistance to make this view wrap lines of text, and avoid text overflowing to the right 28 textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 29 textView.setContentCompressionResistancePriority(.required, for: .vertical) 30 31 // Inline text suggestions interfere with mentions generation 32 if #available(iOS 17.0, *) { 33 textView.inlinePredictionType = .no 34 } 35 36 TextViewWrapper.setTextProperties(textView) 37 return textView 38 } 39 40 static func setTextProperties(_ uiView: UITextView) { 41 uiView.textColor = UIColor.label 42 uiView.font = UIFont.preferredFont(forTextStyle: .body) 43 let linkAttributes: [NSAttributedString.Key : Any] = [ 44 NSAttributedString.Key.foregroundColor: UIColor(Color.accentColor)] 45 uiView.linkTextAttributes = linkAttributes 46 } 47 48 func updateUIView(_ uiView: UITextView, context: Context) { 49 uiView.attributedText = attributedText 50 51 TextViewWrapper.setTextProperties(uiView) 52 setCursorPosition(textView: uiView) 53 let range = uiView.selectedRange 54 55 // Set the text height that will fit all the text 56 // This is needed because the UIKit auto-layout prefers to overflow the text to the right than to expand the text box vertically, even with low horizontal compression resistance 57 self.setIdealHeight(uiView: uiView) 58 59 uiView.selectedRange = NSRange(location: range.location + tagModel.diff, length: range.length) 60 tagModel.diff = 0 61 } 62 63 /// Based on our desired layout, calculate the ideal size of the text box, then set the height to the ideal size 64 private func setIdealHeight(uiView: UITextView) { 65 DispatchQueue.main.async { // Queue on main thread, because modifying view state directly during re-render causes undefined behavior 66 let idealSize = uiView.sizeThatFits(CGSize( 67 width: uiView.frame.width, // We want to stay within the horizontal bounds given to us 68 height: .infinity // We can expand vertically without any resistance 69 )) 70 if self.textHeight != idealSize.height { // Only update height when it changes, to avoid infinite re-render calls 71 self.textHeight = idealSize.height 72 } 73 } 74 } 75 76 private func setCursorPosition(textView: UITextView) { 77 guard let index = cursorIndex, let newPosition = textView.position(from: textView.beginningOfDocument, offset: index) else { 78 return 79 } 80 textView.selectedTextRange = textView.textRange(from: newPosition, to: newPosition) 81 } 82 83 func makeCoordinator() -> Coordinator { 84 Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, initialTextSuffix: initialTextSuffix) 85 } 86 87 class Coordinator: NSObject, UITextViewDelegate { 88 @Binding var attributedText: NSMutableAttributedString 89 var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil 90 let updateCursorPosition: ((Int) -> Void) 91 let initialTextSuffix: String? 92 var initialTextSuffixWasAdded: Bool = false 93 94 init(attributedText: Binding<NSMutableAttributedString>, 95 getFocusWordForMention: ((String?, NSRange?) -> Void)?, 96 updateCursorPosition: @escaping ((Int) -> Void), 97 initialTextSuffix: String? 98 ) { 99 _attributedText = attributedText 100 self.getFocusWordForMention = getFocusWordForMention 101 self.updateCursorPosition = updateCursorPosition 102 self.initialTextSuffix = initialTextSuffix 103 } 104 105 func textViewDidChange(_ textView: UITextView) { 106 if let initialTextSuffix, !self.initialTextSuffixWasAdded { 107 self.initialTextSuffixWasAdded = true 108 var mutable = NSMutableAttributedString(attributedString: textView.attributedText) 109 let originalRange = textView.selectedRange 110 addUnattributedText(initialTextSuffix, to: &mutable, inRange: originalRange) 111 attributedText = mutable 112 DispatchQueue.main.async { 113 self.updateCursorPosition(originalRange.location) 114 } 115 } 116 else { 117 attributedText = NSMutableAttributedString(attributedString: textView.attributedText) 118 } 119 processFocusedWordForMention(textView: textView) 120 } 121 122 private func processFocusedWordForMention(textView: UITextView) { 123 var val: (String?, NSRange?) = (nil, nil) 124 125 guard let selectedRange = textView.selectedTextRange else { return } 126 127 let wordRange = rangeOfMention(in: textView, from: selectedRange.start) 128 129 if let wordRange, 130 let startPosition = textView.position(from: wordRange.start, offset: -1), 131 let cursorPosition = textView.position(from: selectedRange.start, offset: 0) 132 { 133 let word = textView.text(in: textView.textRange(from: startPosition, to: cursorPosition)!) 134 val = (word, convertToNSRange(startPosition, cursorPosition, textView)) 135 } 136 137 getFocusWordForMention?(val.0, val.1) 138 } 139 140 func rangeOfMention(in textView: UITextView, from position: UITextPosition) -> UITextRange? { 141 var startPosition = position 142 143 while startPosition != textView.beginningOfDocument { 144 guard let previousPosition = textView.position(from: startPosition, offset: -1), 145 let range = textView.textRange(from: previousPosition, to: startPosition), 146 let text = textView.text(in: range), !text.isEmpty, 147 let lastChar = text.last else { 148 break 149 } 150 151 if [" ", "\n", "@"].contains(lastChar) { 152 break 153 } 154 155 startPosition = previousPosition 156 } 157 158 return startPosition == position ? nil : textView.textRange(from: startPosition, to: position) 159 } 160 161 private func convertToNSRange( _ startPosition: UITextPosition, _ endPosition: UITextPosition, _ textView: UITextView) -> NSRange? { 162 let startOffset = textView.offset(from: textView.beginningOfDocument, to: startPosition) 163 let endOffset = textView.offset(from: textView.beginningOfDocument, to: endPosition) 164 let length = endOffset - startOffset 165 guard length >= 0, startOffset >= 0 else { 166 return nil 167 } 168 return NSRange(location: startOffset, length: length) 169 } 170 171 // This `UITextViewDelegate` method is automatically called by the editor when edits occur, to check whether a change should occur 172 // We will use this method to manually handle edits concerning mention ("@") links, to avoid manual text edits to attributed mention links 173 func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { 174 guard let attributedString = textView.attributedText else { 175 return true // If we cannot get an attributed string, just fail gracefully and allow changes 176 } 177 var mutable = NSMutableAttributedString(attributedString: attributedString) 178 179 let entireRange = NSRange(location: 0, length: attributedString.length) 180 var shouldAllowChange = true 181 var performEditActionManually = false 182 183 attributedString.enumerateAttribute(.link, in: entireRange, options: []) { (value, linkRange, stop) in 184 guard value != nil else { 185 return // This range is not a link. Skip checking. 186 } 187 188 if range.contains(linkRange.upperBound) && range.contains(linkRange.lowerBound) { 189 // Edit range engulfs all of this link's range. 190 // This link will naturally disappear, so no work needs to be done in this range. 191 return 192 } 193 else if linkRange.intersection(range) != nil { 194 // If user tries to change an existing link directly, remove the link attribute 195 mutable.removeAttribute(.link, range: linkRange) 196 // Perform action manually to flush above changes to the view, and to prevent the character being added from having an attributed link property 197 performEditActionManually = true 198 return 199 } 200 else if range.location == linkRange.location + linkRange.length && range.length == 0 { 201 // If we are inserting a character at the right edge of a link, UITextInput tends to include the new character inside the link. 202 // Therefore, we need to manually append that character outside of the link 203 performEditActionManually = true 204 return 205 } 206 } 207 208 if performEditActionManually { 209 shouldAllowChange = false 210 addUnattributedText(text, to: &mutable, inRange: range) 211 attributedText = mutable 212 213 // Move caret to the end of the newly changed text. 214 updateCursorPosition(range.location + text.count) 215 } 216 217 return shouldAllowChange 218 } 219 220 func addUnattributedText(_ text: String, to attributedString: inout NSMutableAttributedString, inRange range: NSRange) { 221 if range.length == 0 { 222 attributedString.insert(NSAttributedString(string: text, attributes: nil), at: range.location) 223 } 224 else { 225 attributedString.replaceCharacters(in: range, with: text) 226 } 227 } 228 229 } 230 } 231