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()
}
}
}
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()
}
}
}