```html SwiftUI: How to Build a Code Editor (iOS 17+, 2026)

How to Build a Code Editor in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: UITextView / SyntaxHighlighting Updated: May 11, 2026
TL;DR

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

  1. LineNumberTextView subclass — Because UITextView has a native draw(_:) pass, subclassing lets us paint the gutter directly into the view's layer without any additional overlay views. The textContainerInset.left is widened to gutterWidth + 4 so text never overlaps the gutter.
  2. SyntaxHighlighter tokenizer — Six NSRegularExpression patterns are compiled once as a static let to 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.
  3. updateUIView guard — The guard tv.text != text else { return } line in updateUIView prevents an attribution loop: without it, every keystroke would replace the entire attributedText, trigger another textViewDidChange, and so on.
  4. Cursor preservation — Replacing attributedText resets selectedRange to zero. Saving and restoring tv.selectedRange around the assignment keeps the insertion point stable after each highlight pass.
  5. 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

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.

```