damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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