-
Notifications
You must be signed in to change notification settings - Fork 0
Book 10 TextEditor And AttributedString
Part III — The User Interface · Claude's Xcode 26 Swift Bible
← Book-09-Text-And-TextField · Chapters and Appendices · Book-11-FileManager-And-Documents →
QuickNote uses
TextEditorfor the note body — the chapter's central pattern in production form. The note body is a multi-line free-form text area that grows with the keyboard, persists as a SwiftData string, and reads back identically across the app and its home-screen widget. Source: github.com/fluhartyml/QuickNote. Inkwell usesAttributedStringto render Lexicon Quick-Define popups — when the reader taps any tinted Swift identifier in a source-mirror code block (in the Under the Hood tab), the popup that appears uses an attributed-string-driven layout to bold the headword, italicize the definition, and link related identifiers. The book renders itself with attributed strings at production scale. See Build-Along 00 (the meta chapter) for the full Inkwell architecture.
TextEditor is SwiftUI's multi-line text input. Use it when TextField with axis: .vertical is not enough.
@State private var notes = ""
TextEditor(text: $notes)
.frame(height: 200)TextEditor has a default background and some built-in padding. You will almost always want to customize it.
TextEditor(text: $notes)
.font(.body)
.foregroundStyle(.primary)
.scrollContentBackground(.hidden) // remove default background
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 12))
.frame(minHeight: 100, maxHeight: 300)Note: Use .scrollContentBackground(.hidden) to remove the default background, then apply your own .background().
TextEditor has no built-in placeholder. Overlay one yourself:
ZStack(alignment: .topLeading) {
if notes.isEmpty {
Text("Write your notes here...")
.foregroundStyle(.tertiary)
.padding(.top, 8)
.padding(.leading, 5)
.allowsHitTesting(false)
}
TextEditor(text: $notes)
.scrollContentBackground(.hidden)
}TextEditor(text: $notes)
.lineLimit(5...10) // constrain vertical growthTextEditor(text: .constant(readOnlyText))
// or
TextEditor(text: $notes)
.disabled(true)Enable the system find bar (Cmd+F on Mac, find UI on iPad):
@State private var isSearchPresented = false
TextEditor(text: $notes)
.findNavigator(isPresented: $isSearchPresented)Toggle isSearchPresented from a button or toolbar item to show/hide find and replace:
.toolbar {
Button("Find", systemImage: "magnifyingglass") {
isSearchPresented.toggle()
}
}Watch out: .findNavigator works on TextEditor, List, and Table. It does not work on plain Text views.
Use onChange to react to text edits:
TextEditor(text: $notes)
.onChange(of: notes) { oldValue, newValue in
wordCount = newValue.split(separator: " ").count
hasUnsavedChanges = true
}The two-parameter onChange closure (old, new) is the current API. The single-parameter version is deprecated.
Control whether text is selectable:
Text("Read-only but selectable")
.textSelection(.enabled)Apply to a container to enable selection for all text inside:
VStack {
Text("First line")
Text("Second line")
Text("Third line")
}
.textSelection(.enabled)AttributedString is Swift's native attributed string type. It replaces most uses of NSAttributedString in SwiftUI.
var str = AttributedString("Hello, world")
str.font = .title
str.foregroundColor = .blue
str.underlineStyle = .single
Text(str)var str = AttributedString("Hello, bold world")
if let range = str.range(of: "bold") {
str[range].font = .body.bold()
str[range].foregroundColor = .red
}
Text(str)var greeting = AttributedString("Hello ")
greeting.font = .headline
var name = AttributedString("Michael")
name.font = .headline
name.foregroundColor = .blue
Text(greeting + name)Common attributes you can set on AttributedString or its ranges:
| Attribute | Type | Example | |---|---|---| | .font | Font | .body, .title | | .foregroundColor | Color | .red, .blue | | .backgroundColor | Color | .yellow | | .strikethroughStyle | Text.LineStyle | .single, .double | | .underlineStyle | Text.LineStyle | .single | | .underlineColor | Color | .red | | .kern | CGFloat | 2.0 | | .tracking | CGFloat | 1.5 | | .baselineOffset | CGFloat | 5.0 | | .link | URL | URL(string: "...") |
AttributedString can parse Markdown directly:
let markdown = "This is **bold**, this is *italic*, and this is a [link](https://example.com)"
if let attributed = try? AttributedString(markdown: markdown) {
Text(attributed)
}let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
let str = try? AttributedString(markdown: source, options: options)Interpreted syntax options:
-
.inlineOnlyPreservingWhitespace-- inline markdown only, keeps whitespace as-is (best for single-line UI text) -
.full-- full CommonMark parsing including block elements
do {
let attributed = try AttributedString(markdown: rawText)
Text(attributed)
} catch {
Text(rawText) // fall back to plain text
}Watch out: The markdown parser is strict. Malformed markdown throws an error rather than rendering partially. Always have a fallback.
You can define your own attributes for domain-specific styling.
enum HighlightAttribute: CodableAttributedStringKey {
typealias Value = Bool
static let name = "highlight"
}
extension AttributeScopes {
struct AppAttributes: AttributeScope {
let highlight: HighlightAttribute
}
var app: AppAttributes.Type { AppAttributes.self }
}var str = AttributedString("Important note")
str.highlight = trueCustom attributes do not auto-style in Text. You walk the runs and apply SwiftUI attributes based on your custom ones:
func styled(_ source: AttributedString) -> AttributedString {
var result = source
for run in result.runs {
if run.highlight == true {
result[run.range].backgroundColor = .yellow
result[run.range].font = .body.bold()
}
}
return result
}AttributedString.runs gives you each contiguous range of uniform attributes:
for run in attributed.runs {
print("Text: \(attributed[run.range].characters)")
print("Font: \(run.font ?? .body)")
print("---")
}When you need to work with UIKit APIs or older code:
let modern = AttributedString("Hello")
let legacy = NSAttributedString(modern)let legacy = NSAttributedString(string: "Hello", attributes: [
.foregroundColor: UIColor.red,
.font: UIFont.boldSystemFont(ofSize: 18)
])
let modern = AttributedString(legacy)
Text(modern)Watch out: Not all NSAttributedString attributes have AttributedString equivalents. UIKit-specific attributes like .paragraphStyle may not translate cleanly. Test the conversion.
- Core Text rendering
- UIKit text views wrapped in
UIViewRepresentable - Specific paragraph style control (line height, alignment, tab stops) not yet in
AttributedString
struct RichTextView: View {
let markdown: String
var body: some View {
if let attributed = try? AttributedString(markdown: markdown) {
Text(attributed)
.font(.body)
.textSelection(.enabled)
} else {
Text(markdown)
.font(.body)
}
}
}struct NotesEditor: View {
@Binding var text: String
@State private var wordCount = 0
@State private var showFind = false
@FocusState private var isFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TextEditor(text: $text)
.focused($isFocused)
.font(.body)
.scrollContentBackground(.hidden)
.background(.background.secondary)
.clipShape(.rect(cornerRadius: 8))
.findNavigator(isPresented: $showFind)
.onChange(of: text) { _, newValue in
wordCount = newValue.split(separator: " ").count
}
HStack {
Text("\(wordCount) words")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Find") { showFind.toggle() }
.font(.caption)
}
}
}
}func highlightErrors(in log: String) -> AttributedString {
var result = AttributedString(log)
let lines = log.components(separatedBy: "\n")
var position = result.startIndex
for line in lines {
let lineEnd = result.index(position, offsetByCharacters: line.count)
let range = position..<lineEnd
if line.contains("ERROR") {
result[range].foregroundColor = .red
result[range].font = .body.bold()
} else if line.contains("WARNING") {
result[range].foregroundColor = .orange
} else {
result[range].foregroundColor = .secondary
}
// move past the newline
if lineEnd < result.endIndex {
position = result.index(lineEnd, offsetByCharacters: 1)
} else {
break
}
}
return result
}| What | How | |---|---| | Multi-line input | TextEditor(text: $binding) | | Hide default background | .scrollContentBackground(.hidden) | | Find bar | .findNavigator(isPresented: $bool) | | Track changes | .onChange(of: text) { old, new in } | | Parse markdown | try AttributedString(markdown: str) | | Style a range | attributed[range].font = .bold() | | Walk attributes | for run in attributed.runs { } | | Bridge to NSAttributedString | NSAttributedString(attributedString) | | Enable text selection | .textSelection(.enabled) |
← Book-09-Text-And-TextField · Chapters and Appendices · Book-11-FileManager-And-Documents →
Feedback: Found something off? Open an issue · Discuss it · Email Michael
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo