Search code examples
swiftuisyntaxuikituitextviewuiviewrepresentable

UITextView weird behavior


I just wrote this SwiftUI or UiKit Editor with basic line numbers. I works pretty well, but has a major flaw. Whenever the variable behind the text value changes the editor messes up. This is pretty difficult to explain so I am going to give a quick example.

Let's say I have an array of data called files. ( I can't use strings sadly ) In order to use my editor with this array of data I wrote a simple binding. Here:

let binding = Binding(
    get: {
        String(data: files[i].content, encoding: .utf8) ?? ""
    },
    set: {
        files[i].content = $0.data(using: .utf8) ?? Data()
    }
)
                        
CodeEditor(text: binding)

In the code above 'i' is just he value that's responsible of which file is selected in the array. For example if 'i' is 0 then the fist data object is selected and etc...

Now for the actually problem. If 'i' changes and then actual changes get made via the editor it will just save them to whatever it wants to and just overrides everything. I tested the binding previously with SwiftUI's regular TextEditor and everything worked just fine.

Here is CodeEditor.swift:

import SwiftUI

struct CodeEditor: UIViewRepresentable {
    @Binding var text: String
    var colorScheme: ColorScheme = .dark
    var showLineNumbers: Bool = true
    
    var textView = LineNumberedTextView()

    func makeUIView(context: Context) -> LineNumberedTextView {
        textView.isEditable = true
        textView.delegate = context.coordinator
        textView.autocorrectionType = .no
        textView.autocapitalizationType = .none
        textView.keyboardType = .asciiCapable
        textView.font = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular)
        textView.backgroundColor = UIColor.white
        textView.textContainer.lineBreakMode = .byWordWrapping
        return textView
    }
    
    func updateUIView(_ uiView: LineNumberedTextView, context: Context) {
        // Prevent redundant updates
        if uiView.text != text {
            uiView.text = text
        }
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: CodeEditor
        
        init(_ parent: CodeEditor) {
            self.parent = parent
        }
        func textViewDidChange(_ textView: UITextView) {
            // Update the binding only if the text has actually changed
            if self.parent.text != textView.text {
                self.parent.text = textView.text
            }
        }
        func textViewDidChangeSelection(_ textView: UITextView) {
            self.parent.textView.currentLines = textView.text.lineNumbersForRange(textView.selectedRange) ?? []
            self.parent.textView.setNeedsLayout()
        }
    }
}

func findRegex(pattern: String, text: String) -> [NSRange] {
    
    var result: [NSRange] = []
    
    if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.count))
        for match in matches {
            result.append(match.range)
        }
    }
    
    return result
}

extension String {
    func lineNumbersForRange(_ range: NSRange) -> [Int]? {
        guard
            let startIndex = self.index(self.startIndex, offsetBy: range.location, limitedBy: self.endIndex),
            let endIndex = self.index(startIndex, offsetBy: range.length, limitedBy: self.endIndex)
        else {
            return nil
        }
        
        let substring = self[startIndex..<endIndex]
        var lineNumbers: [Int] = []
        
        // Find the lines within the range
        let linesInRange = substring.components(separatedBy: .newlines)
        
        // Calculate the number of newline characters before the start index
        let lineBreaksBeforeStartIndex = self[..<startIndex].components(separatedBy: .newlines).count - 1
        
        // Iterate over each line within the range
        for (index, _) in linesInRange.enumerated() {
            // Calculate the line number for each line within the range
            let lineNumber = lineBreaksBeforeStartIndex + index + 1
            lineNumbers.append(lineNumber)
        }
        
        return lineNumbers
    }
}


class LineNumberedTextView: UITextView {
    let lineNumberGutterWidth: CGFloat = 50
    var currentLines: [Int] = []
    var showLineNumbers: Bool = true
    var colorScheme: ColorScheme = .light
    
    // This is the function which draws the line numbers
    override func draw(_ rect: CGRect) {

        // Set the alignment, color, font and size for the line numbers
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .left
        
        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.monospacedSystemFont(ofSize: 15, weight: .ultraLight),
            .paragraphStyle: paragraphStyle,
            .foregroundColor: colorScheme == .light ? UIColor(white: 0.8, alpha: 1) : UIColor(white: 0.2, alpha: 1)
        ]
        let attributes_active: [NSAttributedString.Key: Any] = [
            .font: UIFont.monospacedSystemFont(ofSize: 15, weight: .ultraLight),
            .paragraphStyle: paragraphStyle,
            .foregroundColor: colorScheme == .light ? UIColor.black : UIColor.white
        ]
        
        // The editor needs these values in order to keep track if a line has wrapped or not.
        var lineNum = 1
        var lineRect = CGRect.zero
        let full = NSRange(location: 0, length: self.textStorage.length)
        var isWrapped = false
        
        // Enumerate, aka. go over each single ones of the lines
        layoutManager.enumerateLineFragments(forGlyphRange: full) { (rect, usedRect, textContainer, glyphRange, stop) in

            // This code checks whether a line is inside the full fragment os wraps around.
            let charRange: NSRange = self.layoutManager.glyphRange(forCharacterRange: glyphRange, actualCharacterRange: nil)
            let paraRange: NSRange? = (self.textStorage.string as NSString?)?.paragraphRange(for: charRange)
            
            let wrapped = charRange.location == paraRange?.location
            
            // Only if the line did **not** wrap, it draws the line number.
            if wrapped {
                lineRect = CGRect(x: 8, y: rect.origin.y + self.textContainerInset.top, width: self.lineNumberGutterWidth, height: rect.height)
                let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? attributes_active : attributes)
                attributedString.draw(in: lineRect)
                lineNum += 1
            }
            isWrapped = !wrapped
        }
        
        // Handle the special case where the text is empty, so there is at least one number at all times.
        if self.textStorage.string.isEmpty {
            let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? attributes_active : attributes)
            attributedString.draw(at: CGPoint(x: 8, y: self.textContainerInset.top))
        }
        
        // Another special case where the last line ends with a newline, because for some reason the editor doesn't count that as a new fragment.
        if self.textStorage.string.hasSuffix("\n") {
            let rect = lineRect.offsetBy(dx: 0, dy: isWrapped ? (lineRect.height * 2) : lineRect.height)
            let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? attributes_active : attributes)
            attributedString.draw(in: rect)
        }
    }
    
    // This resets and layouts everything / sets the line number padding
    override func layoutSubviews() {
        super.layoutSubviews()
        textContainerInset = UIEdgeInsets(top: textContainerInset.top, left: lineNumberGutterWidth, bottom: textContainerInset.bottom, right: textContainerInset.right)
        setNeedsDisplay()
    }
    

    // The text changed so this updates the line numbers.
    override var text: String! {
        didSet {
            setNeedsDisplay()
        }
    }
}

Solution

  • Make needs to init it eg

    func makeUIView(context: Context) -> LineNumberedTextView {
        let view = LineNumberedTextView()
        ...
    

    Also you can't pass self to the coordinator cause it will be immediately out of date

    func makeCoordinator() -> Coordinator {
            return Coordinator()
        }
    

    Full code as requested:

    struct CodeEditor: UIViewRepresentable {
        @Binding var text: String
        let colorScheme: ColorScheme = .dark
        let showLineNumbers: Bool = true
        
        func makeUIView(context: Context) -> LineNumberedTextView {
            let textView = LineNumberedTextView()
            textView.isEditable = true
            textView.delegate = context.coordinator
            textView.autocorrectionType = .no
            textView.autocapitalizationType = .none
            textView.keyboardType = .asciiCapable
            textView.font = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular)
            textView.backgroundColor = UIColor.white
            textView.textContainer.lineBreakMode = .byWordWrapping
            return textView
        }
        
        func updateUIView(_ uiView: LineNumberedTextView, context: Context) {
    
            context.coordinator.textDidChange = nil
            
            if uiView.text != text {
                uiView.text = text
            }
            if uiView.colorScheme != colorScheme {
               uiView.colorScheme = colorScheme
            }
            if uiView.showLineNumbers != showLineNumbers {
                uiView.showLineNumbers = showLineNumbers
            }
            
            context.coordinator.textDidChange = { newText in
                text = newText
            }
    
        }
        
        func makeCoordinator() -> Coordinator {
            return Coordinator()
        }
    
        class Coordinator: NSObject, UITextViewDelegate {
            
            var textDidChange: ((String) -> ())? = nil
    
            func textViewDidChange(_ textView: UITextView) {
                textDidChange?(textView.text)
            }
    
            func textViewDidChangeSelection(_ textView: UITextView) {
                textView.currentLines = textView.text.lineNumbersForRange(textView.selectedRange) ?? []
                textView.setNeedsLayout()
            }
        }
    }