SwiftUI Text and Rich Content
Comprehensive guide to SwiftUI text rendering, AttributedString, native Markdown support, and rich text editing for iOS 26 development.
Prerequisites
-
iOS 15+ for AttributedString (iOS 26 recommended for rich text editing)
-
Xcode 26+
Basic Text
Text View Fundamentals
// Simple text Text("Hello, World!")
// Multi-line text (automatic) Text("This is a longer piece of text that will automatically wrap to multiple lines when it exceeds the available width.")
// Verbatim (no localization) Text(verbatim: "user_name") // Won't look up in Localizable.strings
Font Modifiers
Text("Hello") .font(.largeTitle) .font(.title) .font(.title2) .font(.title3) .font(.headline) .font(.subheadline) .font(.body) .font(.callout) .font(.caption) .font(.caption2) .font(.footnote)
// Custom font Text("Custom") .font(.custom("Helvetica Neue", size: 24)) .font(.system(size: 20, weight: .bold, design: .rounded))
// Dynamic type with relative size Text("Scaled") .font(.body.leading(.loose))
Text Styling
Text("Styled Text") .fontWeight(.bold) .italic() .underline() .underline(color: .blue) .strikethrough() .strikethrough(color: .red) .kerning(2) // Letter spacing .tracking(2) // Similar to kerning .baselineOffset(10) // Vertical offset .textCase(.uppercase) .textCase(.lowercase)
Text Truncation and Lines
Text("Long text that might need truncation...") .lineLimit(2) .lineLimit(1...3) // Range (iOS 16+) .truncationMode(.tail) // .head, .middle, .tail .allowsTightening(true) // Reduce spacing before truncating .minimumScaleFactor(0.5) // Scale down to fit
Text Alignment
Text("Aligned text") .multilineTextAlignment(.leading) .multilineTextAlignment(.center) .multilineTextAlignment(.trailing)
// Frame alignment for single line Text("Single") .frame(maxWidth: .infinity, alignment: .leading)
Native Markdown Support
Automatic Markdown Rendering
SwiftUI Text views automatically render Markdown:
// Basic Markdown in Text
Text("Bold, italic, and strikethrough")
Text("Visit Apple")
Text("inline code looks different")
// Combined formatting Text("This is bold and italic together")
Supported Markdown Syntax
// Emphasis Text("italic or italic") Text("bold or bold") Text("bold italic")
// Strikethrough
Text("deleted")
// Code
Text("monospace")
// Links Text("Link Text")
// Soft breaks Text("Line one\nLine two")
Markdown from Variables
// String interpolation with AttributedString let markdownString = "Important: Check the documentation"
// Option 1: Direct (for literals only) Text("Bold text")
// Option 2: AttributedString for variables if let attributed = try? AttributedString(markdown: markdownString) { Text(attributed) }
AttributedString
Creating AttributedString
// From plain string var attributed = AttributedString("Hello World")
// From Markdown let markdown = try? AttributedString(markdown: "Bold and italic")
// From localized string let localized = AttributedString(localized: "greeting_message")
Applying Attributes
var text = AttributedString("Hello World")
// Whole string attributes text.font = .title text.foregroundColor = .blue text.backgroundColor = .yellow
// Range-based attributes if let range = text.range(of: "World") { text[range].font = .title.bold() text[range].foregroundColor = .red }
Available Attributes
var text = AttributedString("Styled")
// Typography text.font = .body text.foregroundColor = .primary text.backgroundColor = .clear
// Text decoration text.strikethroughStyle = .single text.strikethroughColor = .red text.underlineStyle = .single text.underlineColor = .blue
// Spacing text.kern = 2.0 // Character spacing text.tracking = 1.0 // Similar to kern text.baselineOffset = 5 // Vertical offset
// Links text.link = URL(string: "https://apple.com")
// Accessibility text.accessibilityLabel = "Custom label" text.accessibilitySpeechSpellsOutCharacters = true
Combining AttributedStrings
var greeting = AttributedString("Hello ") greeting.font = .title
var name = AttributedString("World") name.font = .title.bold() name.foregroundColor = .blue
let combined = greeting + name Text(combined)
Iterating Over Runs
let attributed = try? AttributedString(markdown: "Bold and italic")
// Iterate through styled runs for run in attributed?.runs ?? [] { print("Text: (attributed?[run.range] ?? "")") print("Font: (run.font ?? .body)") }
Markdown Parsing Options
Basic Parsing
let source = "# Heading\nBold text"
// Default parsing let attributed = try? AttributedString(markdown: source)
// With options let options = AttributedString.MarkdownParsingOptions( interpretedSyntax: .inlineOnlyPreservingWhitespace ) let parsed = try? AttributedString(markdown: source, options: options)
Interpreted Syntax Options
// Full Markdown (default) .interpretedSyntax: .full
// Inline only (no block elements) .interpretedSyntax: .inlineOnly
// Inline, preserving whitespace .interpretedSyntax: .inlineOnlyPreservingWhitespace
Handling Parse Errors
do { let attributed = try AttributedString(markdown: source) // Use attributed string } catch { // Fallback to plain text let plain = AttributedString(source) }
Custom Attribute Scopes
// Define custom attributes enum MyAttributes: AttributeScope { let customHighlight: CustomHighlightAttribute }
struct CustomHighlightAttribute: CodableAttributedStringKey { typealias Value = Bool static let name = "customHighlight" }
// Extend AttributeScopes extension AttributeScopes { var myAttributes: MyAttributes.Type { MyAttributes.self } }
// Use custom attributes var text = AttributedString("Highlighted") text.customHighlight = true
Rich Text Editing (iOS 26)
TextEditor with AttributedString
iOS 26 introduces first-class rich text editing:
struct RichTextEditor: View { @State private var content = AttributedString("Edit me with formatting") @State private var selection = AttributedTextSelection()
var body: some View {
TextEditor(text: $content, selection: $selection)
.textEditorStyle(.plain)
}
}
AttributedTextSelection
struct FormattingEditor: View { @State private var content = AttributedString() @State private var selection = AttributedTextSelection()
var body: some View {
VStack {
// Formatting toolbar
HStack {
Button("Bold") { toggleBold() }
Button("Italic") { toggleItalic() }
Button("Underline") { toggleUnderline() }
}
TextEditor(text: $content, selection: $selection)
}
}
func toggleBold() {
content.transformAttributes(in: selection.range) { container in
// Toggle bold
if container.font?.isBold == true {
container.font = container.font?.removingBold()
} else {
container.font = container.font?.bold()
}
}
}
func toggleItalic() {
content.transformAttributes(in: selection.range) { container in
if container.font?.isItalic == true {
container.font = container.font?.removingItalic()
} else {
container.font = container.font?.italic()
}
}
}
func toggleUnderline() {
content.transformAttributes(in: selection.range) { container in
if container.underlineStyle != nil {
container.underlineStyle = nil
} else {
container.underlineStyle = .single
}
}
}
}
Built-in Keyboard Shortcuts
iOS 26 TextEditor supports standard keyboard shortcuts:
-
⌘B - Bold
-
⌘I - Italic
-
⌘U - Underline
Font Resolution Context
TextEditor(text: $content, selection: $selection) .environment(.fontResolutionContext, FontResolutionContext( defaultFont: .body, defaultForegroundColor: .primary ))
Text Interpolation
Format Styles
// Numbers Text("Count: (count)") Text("Price: (price, format: .currency(code: "USD"))") Text("Percentage: (value, format: .percent)") Text("Decimal: (number, format: .number.precision(.fractionLength(2)))")
// Dates Text("Date: (date, format: .dateTime)") Text("Day: (date, format: .dateTime.day().month().year())") Text("Time: (date, format: .dateTime.hour().minute())")
// Relative dates Text(date, style: .relative) // "2 hours ago" Text(date, style: .timer) // "2:30:00" Text(date, style: .date) // "June 15, 2025" Text(date, style: .time) // "3:30 PM" Text(date, style: .offset) // "+2 hours"
// Date ranges Text(startDate...endDate)
// Lists Text(names, format: .list(type: .and)) // "Alice, Bob, and Charlie"
// Measurements Text(distance, format: .measurement(width: .abbreviated))
Person Name Components
let name = PersonNameComponents(givenName: "John", familyName: "Doe") Text(name, format: .name(style: .long))
ByteCount
Text(fileSize, format: .byteCount(style: .file))
Localization
LocalizedStringKey
// Automatic localization lookup Text("welcome_message") // Looks up in Localizable.strings
// With interpolation Text("greeting_(username)") // "greeting_%@" in strings file
// Explicit localized string Text(LocalizedStringKey("settings_title"))
String Catalogs (.xcstrings)
Modern localization uses String Catalogs:
// In String Catalog (Localizable.xcstrings) // Key: "items_count" // English: "%lld items" // French: "%lld éléments"
Text("items_count (count)")
Pluralization
// In String Catalog, define variants: // "items_count" with plural variants: // - zero: "No items" // - one: "1 item" // - other: "%lld items"
Text("items_count (count)")
AttributedString Localization
// Localized with attributes let attributed = AttributedString(localized: "formatted_message") Text(attributed)
Text Selection
Enabling Selection
Text("Selectable text that users can copy") .textSelection(.enabled)
// Disable selection Text("Not selectable") .textSelection(.disabled)
Selection on Lists
List(items) { item in Text(item.content) .textSelection(.enabled) }
TextField and SecureField
Basic TextField
@State private var text = ""
TextField("Placeholder", text: $text)
// With prompt TextField("Username", text: $username, prompt: Text("Enter username"))
// Axis for multiline TextField("Description", text: $description, axis: .vertical) .lineLimit(3...6)
TextField Styles
TextField("Input", text: $text) .textFieldStyle(.automatic) .textFieldStyle(.plain) .textFieldStyle(.roundedBorder)
SecureField
SecureField("Password", text: $password)
Formatting TextField
// Number input TextField("Amount", value: $amount, format: .currency(code: "USD"))
// Date input TextField("Date", value: $date, format: .dateTime)
// Custom format TextField("Phone", value: $phone, format: PhoneNumberFormat())
TextField Focus
@FocusState private var isFocused: Bool
TextField("Input", text: $text) .focused($isFocused)
Button("Focus") { isFocused = true }
Keyboard Types
TextField("Email", text: $email) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) .autocorrectionDisabled()
TextField("Phone", text: $phone) .keyboardType(.phonePad) .textContentType(.telephoneNumber)
TextField("URL", text: $url) .keyboardType(.URL) .textContentType(.URL)
Submit Actions
TextField("Search", text: $query) .onSubmit { performSearch() } .submitLabel(.search)
// Submit labels: .done, .go, .join, .next, .return, .search, .send
Label
Basic Label
Label("Settings", systemImage: "gear") Label("Document", image: "doc-icon")
// Custom label Label { Text("Custom") .font(.headline) } icon: { Image(systemName: "star.fill") .foregroundStyle(.yellow) }
Label Styles
Label("Title", systemImage: "star") .labelStyle(.automatic) .labelStyle(.titleOnly) .labelStyle(.iconOnly) .labelStyle(.titleAndIcon)
Link
Basic Links
Link("Apple", destination: URL(string: "https://apple.com")!)
Link(destination: URL(string: "https://apple.com")!) { Label("Visit Apple", systemImage: "safari") }
Links in Text
// Using Markdown Text("Visit our website for more info")
// Using AttributedString var text = AttributedString("Visit our website") if let range = text.range(of: "our website") { text[range].link = URL(string: "https://example.com") text[range].foregroundColor = .blue } Text(text)
Privacy Sensitive Content
Redaction
Text(sensitiveData) .privacySensitive()
// Manual redaction Text("Hidden Content") .redacted(reason: .privacy) .redacted(reason: .placeholder)
// Unredacted Text("Always Visible") .unredacted()
Conditional Redaction
struct ContentView: View { @Environment(.redactionReasons) var redactionReasons
var body: some View {
if redactionReasons.contains(.privacy) {
Text("•••••")
} else {
Text(accountBalance, format: .currency(code: "USD"))
}
}
}
Text Rendering Performance
Efficient Text Updates
// GOOD: Separate text views for changing content VStack { Text("Static label:") Text("(dynamicValue)") // Only this updates }
// AVOID: Combining static and dynamic in one Text Text("Static label: (dynamicValue)") // Whole text re-renders
Large Text Handling
// For very long text, use ScrollView ScrollView { Text(veryLongContent) .textSelection(.enabled) }
// Or LazyVStack for segmented content ScrollView { LazyVStack(alignment: .leading) { ForEach(paragraphs, id: .self) { paragraph in Text(paragraph) .padding(.bottom) } } }
Accessibility
VoiceOver Customization
Text("5 stars") .accessibilityLabel("5 out of 5 stars")
Text("$99") .accessibilityLabel("99 dollars")
// Heading level Text("Section Title") .accessibilityAddTraits(.isHeader)
Dynamic Type Support
// Respect user's text size preference Text("Accessible text") .font(.body) // Scales with Dynamic Type
// Fixed size (use sparingly) Text("Fixed size") .font(.system(size: 14)) .dynamicTypeSize(.large) // Cap at large
// Size range Text("Limited scaling") .dynamicTypeSize(.small...(.accessibilityLarge))
Best Practices
- Use Semantic Fonts
// GOOD: Semantic fonts scale with Dynamic Type .font(.headline) .font(.body) .font(.caption)
// AVOID: Fixed sizes unless necessary .font(.system(size: 16))
- Support Markdown for User Content
// Parse user input as Markdown safely func renderUserContent(_ input: String) -> Text { if let attributed = try? AttributedString( markdown: input, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) ) { return Text(attributed) } return Text(input) }
- Enable Text Selection for Copyable Content
Text(address) .textSelection(.enabled)
- Handle Localization Properly
// Use LocalizedStringKey for user-facing text Text("button_title")
// Use verbatim for data Text(verbatim: userGeneratedContent)
- Consider Privacy
Text(sensitiveInfo) .privacySensitive()
Official Resources
-
Text Documentation
-
AttributedString Documentation
-
TextEditor Documentation
-
Markdown in SwiftUI
-
WWDC21: What's new in Foundation