How to Build a Code Editor in SwiftUI
Wrap UITextView in a UIViewRepresentable, feed its textStorage an NSAttributedString produced by a regex-based tokenizer, and redraw on every textDidChange notification. That gives you real-time syntax highlighting without any third-party dependency.
// Minimal code editor with keyword highlighting
struct CodeEditorView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
let tv = UITextView()
tv.font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)
tv.delegate = context.coordinator
tv.autocorrectionType = .no
tv.autocapitalizationType = .none
tv.backgroundColor = UIColor(named: "EditorBackground")
return tv
}
func updateUIView(_ tv: UITextView, context: Context) {
if tv.text != text {
tv.attributedText = SyntaxHighlighter.highlight(text)
}
}
func makeCoordinator() -> Coordinator { Coordinator($text) }
class Coordinator: NSObject, UITextViewDelegate {
@Binding var text: String
init(_ text: Binding<String>) { _text = text }
func textViewDidChange(_ tv: UITextView) { text = tv.text }
}
}
Full implementation
The full editor layers three concerns: a UIViewRepresentable wrapper that owns the UITextView, a SyntaxHighlighter that tokenizes Swift source using NSRegularExpression and returns a styled NSAttributedString, and a LineNumberGutter view that paints line numbers into the text view's left inset. A CodeTheme struct centralises all colors so dark-mode support is one swap away.
import SwiftUI
import UIKit
// MARK: - Theme
struct CodeTheme {
let background: UIColor
let plainText: UIColor
let keyword: UIColor
let string: UIColor
let comment: UIColor
let number: UIColor
static let dark = CodeTheme(
background: UIColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1),
plainText: UIColor(red: 0.90, green: 0.90, blue: 0.90, alpha: 1),
keyword: UIColor(red: 0.98, green: 0.47, blue: 0.68, alpha: 1),
string: UIColor(red: 0.69, green: 0.87, blue: 0.54, alpha: 1),
comment: UIColor(red: 0.52, green: 0.60, blue: 0.52, alpha: 1),
number: UIColor(red: 0.68, green: 0.80, blue: 0.98, alpha: 1)
)
}
// MARK: - Syntax Highlighter
enum TokenKind { case keyword, string, comment, number, plain }
struct SyntaxHighlighter {
private static let keywords = #"\b(import|struct|class|enum|func|var|let|if|else|guard|return|switch|case|for|in|while|break|continue|true|false|nil|self|super|init|deinit|override|static|final|public|private|internal|mutating|throws|try|catch|async|await)\b"#
private static let patterns: [(NSRegularExpression, TokenKind)] = {
let raw: [(String, TokenKind)] = [
(#"//[^\n]*"#, .comment),
(#"/\*[\s\S]*?\*/"#, .comment),
(#"\"(?:[^\"\\\\]|\\\\.)*\"" , .string),
(#"\b\d+(\.\d+)?\b"#, .number),
(keywords, .keyword)
]
return raw.compactMap { pattern, kind in
guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
return (re, kind)
}
}()
static func highlight(_ source: String, theme: CodeTheme = .dark) -> NSAttributedString {
let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)
let result = NSMutableAttributedString(
string: source,
attributes: [.font: font, .foregroundColor: theme.plainText]
)
let fullRange = NSRange(source.startIndex..., in: source)
for (regex, kind) in patterns {
regex.enumerateMatches(in: source, range: fullRange) { match, _, _ in
guard let range = match?.range else { return }
let color: UIColor = switch kind {
case .keyword: theme.keyword
case .string: theme.string
case .comment: theme.comment
case .number: theme.number
case .plain: theme.plainText
}
result.addAttribute(.foregroundColor, value: color, range: range)
}
}
return result
}
}
// MARK: - Line Number Gutter
final class LineNumberTextView: UITextView {
private let gutterWidth: CGFloat = 44
private let theme: CodeTheme
init(theme: CodeTheme) {
self.theme = theme
super.init(frame: .zero, textContainer: nil)
textContainerInset = UIEdgeInsets(top: 8, left: gutterWidth + 4, bottom: 8, right: 8)
backgroundColor = theme.background
}
required init?(coder: NSCoder) { fatalError() }
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let ctx = UIGraphicsGetCurrentContext() else { return }
ctx.setFillColor(UIColor(white: 1, alpha: 0.04).cgColor)
ctx.fill(CGRect(x: 0, y: rect.minY, width: gutterWidth, height: rect.height))
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
.foregroundColor: UIColor.gray
]
var lineNumber = 1
let glyphRange = layoutManager.glyphRange(forBoundingRect: rect, in: textContainer)
var glyphIndex = glyphRange.location
while glyphIndex < NSMaxRange(glyphRange) {
var lineGlyphRange = NSRange()
let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &lineGlyphRange)
let charIdx = layoutManager.characterIndexForGlyph(at: lineGlyphRange.location)
let isNewLine = charIdx == 0 || (text as NSString).character(at: charIdx - 1) == 10
if isNewLine || glyphIndex == glyphRange.location {
let label = "\(lineNumber)" as NSString
let size = label.size(withAttributes: attrs)
let y = lineRect.minY + textContainerInset.top + (lineRect.height - size.height) / 2
let x = gutterWidth - size.width - 6
label.draw(at: CGPoint(x: x, y: y), withAttributes: attrs)
lineNumber += 1
}
glyphIndex = NSMaxRange(lineGlyphRange)
}
}
}
// MARK: - UIViewRepresentable
struct FullCodeEditorView: UIViewRepresentable {
@Binding var text: String
var theme: CodeTheme = .dark
func makeUIView(context: Context) -> LineNumberTextView {
let tv = LineNumberTextView(theme: theme)
tv.delegate = context.coordinator
tv.autocorrectionType = .no
tv.autocapitalizationType = .none
tv.smartDashesType = .no
tv.smartQuotesType = .no
tv.isAccessibilityElement = true
tv.accessibilityLabel = "Code editor"
tv.accessibilityTraits = [.updatesFrequently]
return tv
}
func updateUIView(_ tv: LineNumberTextView, context: Context) {
guard tv.text != text else { return }
let cursor = tv.selectedRange
tv.attributedText = SyntaxHighlighter.highlight(text, theme: theme)
tv.selectedRange = cursor
tv.setNeedsDisplay() // redraw line numbers
}
func makeCoordinator() -> Coordinator { Coordinator($text) }
class Coordinator: NSObject, UITextViewDelegate {
@Binding var text: String
init(_ b: Binding<String>) { _text = b }
func textViewDidChange(_ tv: UITextView) {
text = tv.text
tv.setNeedsDisplay()
}
}
}
// MARK: - SwiftUI wrapper
struct CodeEditorScreen: View {
@State private var source = """
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
}
}
"""
var body: some View {
FullCodeEditorView(text: $source, theme: .dark)
.ignoresSafeArea(edges: .bottom)
}
}
#Preview {
CodeEditorScreen()
}
How it works
-
LineNumberTextView subclass — Because
UITextViewhas a nativedraw(_:)pass, subclassing lets us paint the gutter directly into the view's layer without any additional overlay views. ThetextContainerInset.leftis widened togutterWidth + 4so text never overlaps the gutter. -
SyntaxHighlighter tokenizer — Six
NSRegularExpressionpatterns are compiled once as astatic letto avoid repeated compilation cost. They are applied in priority order: comments first, then strings, numbers, and finally keywords. Earlier matches paint later, so a keyword inside a comment string stays green. -
updateUIView guard — The
guard tv.text != text else { return }line inupdateUIViewprevents an attribution loop: without it, every keystroke would replace the entireattributedText, trigger anothertextViewDidChange, and so on. -
Cursor preservation — Replacing
attributedTextresetsselectedRangeto zero. Saving and restoringtv.selectedRangearound the assignment keeps the insertion point stable after each highlight pass. -
Accessibility — Setting
accessibilityTraits = [.updatesFrequently]tells VoiceOver to rate-limit announcements so it doesn't read every highlighted token change aloud, keeping the experience usable for low-vision users.
Variants
Light theme support
extension CodeTheme {
static let light = CodeTheme(
background: UIColor(red: 0.98, green: 0.97, blue: 0.95, alpha: 1),
plainText: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1),
keyword: UIColor(red: 0.70, green: 0.10, blue: 0.50, alpha: 1),
string: UIColor(red: 0.20, green: 0.55, blue: 0.10, alpha: 1),
comment: UIColor(red: 0.40, green: 0.45, blue: 0.40, alpha: 1),
number: UIColor(red: 0.15, green: 0.35, blue: 0.80, alpha: 1)
)
}
// In your SwiftUI view:
struct AdaptiveCodeEditor: View {
@Binding var source: String
@Environment(\.colorScheme) var scheme
var body: some View {
FullCodeEditorView(
text: $source,
theme: scheme == .dark ? .dark : .light
)
}
}
Find & replace toolbar
Attach a .toolbar with a TextField for the search term, then iterate over matches from your existing NSRegularExpression infrastructure. Call tv.scrollRangeToVisible(matchRange) through a Coordinator method to jump to each hit. Highlight matches by adding a .backgroundColor attribute with a translucent yellow in a separate pass — keep it in its own NSMutableAttributedString layer so clearing highlights doesn't erase syntax colors.
Common pitfalls
-
iOS 16 regression: The
switch kind { case .keyword: … }shorthand expression requires Swift 5.9 / iOS 17 SDK. On iOS 16 targets you must use a fullswitchstatement with explicitreturnor a dictionary lookup instead. -
Regex overlap bug: If your patterns share ranges (e.g., a keyword inside a string literal), the last
addAttributewins and will incorrectly re-color the token. Apply patterns in descending priority order — comment, string, number, keyword — and skip ranges already marked as comments or strings using a separate pass flag. -
Performance on large files: Running all regex passes on every keystroke is O(n) per pattern. For files beyond ~2 000 lines, throttle with
Task.sleepor debounce withCombine/AsyncStream, and highlight only the visible glyph range reported bylayoutManager.glyphRange(forBoundingRect:in:)rather than the full string.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a code editor in SwiftUI for iOS 17+. Use UITextView/SyntaxHighlighting (NSAttributedString + NSRegularExpression). Include a line-number gutter drawn in UITextView.draw(_:). Support dark and light CodeTheme structs switchable via @Environment(\.colorScheme). Make it accessible (VoiceOver labels, accessibilityTraits .updatesFrequently). Add a #Preview with realistic Swift source code as sample data.
In the Soarias Build phase, paste this prompt into the active session after your screen mockups are approved — Claude Code will scaffold the UIViewRepresentable wrapper, tokenizer, and theme files directly into your Xcode project.
Related
FAQ
Does this work on iOS 16?
Partially. UIViewRepresentable, UITextView, and NSAttributedString all work on iOS 16. However, the Swift 5.9 switch expression shorthand used in SyntaxHighlighter requires Xcode 15+ with Swift 5.9, which targets iOS 17 minimum. Replace the expression-form switch with a standard switch statement and the rest of the code is backward compatible to iOS 15.
How do I support multiple languages (Python, JSON, etc.)?
Refactor SyntaxHighlighter into a protocol with a patterns: [(NSRegularExpression, TokenKind)] requirement, then create concrete types like SwiftHighlighter, PythonHighlighter, and JSONHighlighter. Pass the active highlighter as a parameter to FullCodeEditorView and swap it when the user opens a different file type. Use the file extension from UTType (via UniformTypeIdentifiers) to pick the right highlighter automatically.
What's the UIKit equivalent?
In UIKit you would subclass UIViewController, add a UITextView directly, and implement UITextViewDelegate's textViewDidChange(_:). The SyntaxHighlighter and LineNumberTextView subclass shown above are already UIKit-native — the only SwiftUI-specific piece is the UIViewRepresentable bridge. You can drop LineNumberTextView and SyntaxHighlighter into a pure UIKit project unchanged.
Last reviewed: 2026-05-11 by the Soarias team.