apple-text-attributed-string

Use when choosing between AttributedString and NSAttributedString, defining custom attributes, or converting between them

Attributed String Reference

Use this skill when the main question is which attributed-text model to use and how to convert safely between them.

When to Use

  • You are choosing between AttributedString and NSAttributedString.
  • You need custom attributes or scopes.
  • You need conversion rules between SwiftUI and UIKit/AppKit text APIs.

Quick Decision

  • SwiftUI-first, Codable, or Markdown-heavy pipeline -> AttributedString
  • UIKit/AppKit or TextKit API boundary -> NSAttributedString
  • Exact formatting attribute catalog needed -> /skill apple-text-formatting-ref

Core Guidance

Keep this file for the model choice, mutation rules, and conversion boundaries. For custom attribute scopes, paragraph-style recipes, and the full attribute quick reference, use advanced-patterns.md. For Apple-authored Xcode-backed guidance on the newest Foundation changes, use /skill apple-text-apple-docs.

AttributedString vs NSAttributedString

AspectAttributedString (Swift)NSAttributedString (ObjC)
TypeValue type (struct)Reference type (class)
AttributesType-safe key pathsUntyped [Key: Any] dictionary
CodableYesNo (use NSCoding)
MarkdownBuilt-in parsingNo
Thread safetyCopy-on-write (safe)Immutable safe, mutable not
Mutable variantSame type (var)NSMutableAttributedString
Required bySwiftUI TextUIKit/AppKit text properties, TextKit
AvailableiOS 15+ / macOS 12+All versions

When to Use Which

Need Codable/serialization?         → AttributedString
Need Markdown parsing?              → AttributedString
SwiftUI Text view?                  → AttributedString
UIKit label/textView attributedText? → NSAttributedString
TextKit 1 (NSTextStorage)?          → NSAttributedString
TextKit 2 (delegate methods)?       → NSAttributedString
Core Text?                          → NSAttributedString (CFAttributedString)
Cross-platform code?                → AttributedString (convert at boundaries)

Best practice: Use AttributedString as your internal representation. Convert to NSAttributedString at UIKit/AppKit API boundaries.

Swift AttributedString (iOS 15+)

Basic Usage

var str = AttributedString("Hello World")
str.font = .body
str.foregroundColor = .red

// Range-based attributes
if let range = str.range(of: "World") {
    str[range].link = URL(string: "https://example.com")
    str[range].font = .body.bold()
}

Concatenation

var greeting = AttributedString("Hello ")
greeting.font = .body
var name = AttributedString("World")
name.font = .body.bold()
name.foregroundColor = .blue

let combined = greeting + name

Markdown

// Basic Markdown
let str = try AttributedString(markdown: "**Bold** and *italic*")

// Custom interpretation
let str = try AttributedString(
    markdown: "Visit [Apple](https://apple.com)",
    including: \.foundation,
    options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)

Views (Character-Level Access)

let str = AttributedString("Hello 👋🏽")

str.characters       // CharacterView — Swift Characters
str.unicodeScalars   // UnicodeScalarView
str.utf8             // UTF8View
str.utf16            // UTF16View

// Iterate characters with attributes
for run in str.runs {
    let substring = str[run.range]
    let font = run.font
    let color = run.foregroundColor
}

Runs

A "run" is a contiguous range with identical attributes:

for run in str.runs {
    print("Range: \(run.range)")
    print("Font: \(run.font ?? .body)")

    // Access the attributed substring
    let sub = str[run.range]
}

// Filter runs by attribute
for (color, range) in str.runs[\.foregroundColor] {
    print("Color: \(String(describing: color)) at \(range)")
}

Mutation

var str = AttributedString("Hello World")

// Replace text
if let range = str.range(of: "World") {
    str.replaceSubrange(range, with: AttributedString("Swift"))
}

// Set attribute on entire string
str.foregroundColor = .red

// Merge attributes
var container = AttributeContainer()
container.font = .body.bold()
container.foregroundColor = .blue
str.mergeAttributes(container)

// Remove attribute
str.foregroundColor = nil

Index Invalidation

Critical: Mutating an AttributedString invalidates ALL existing indices and ranges.

// ❌ WRONG — indices invalidated after mutation
var str = AttributedString("Hello World")
let range = str.range(of: "World")!
str.replaceSubrange(str.range(of: "Hello")!, with: AttributedString("Hi"))
// range is NOW INVALID — using it may crash

// ✅ CORRECT — re-find ranges after mutation
var str = AttributedString("Hello World")
str.replaceSubrange(str.range(of: "Hello")!, with: AttributedString("Hi"))
if let range = str.range(of: "World") {
    str[range].font = .body.bold()
}

NSAttributedString

Basic Usage

let attrs: [NSAttributedString.Key: Any] = [
    .font: UIFont.systemFont(ofSize: 16),
    .foregroundColor: UIColor.red,
    .kern: 1.5
]
let str = NSAttributedString(string: "Hello", attributes: attrs)

Mutable Variant

let mutable = NSMutableAttributedString(string: "Hello World")

// Add attributes to range
mutable.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 16),
                     range: NSRange(location: 6, length: 5))

// Set attributes (replaces all existing in range)
mutable.setAttributes([.foregroundColor: UIColor.blue],
                      range: NSRange(location: 0, length: 5))

// Remove attribute
mutable.removeAttribute(.font, range: NSRange(location: 0, length: mutable.length))

// Replace characters
mutable.replaceCharacters(in: NSRange(location: 0, length: 5), with: "Hi")

// Insert
mutable.insert(NSAttributedString(string: "Hey "), at: 0)

// Delete
mutable.deleteCharacters(in: NSRange(location: 0, length: 4))

Enumerating Attributes

let str: NSAttributedString = ...

// Enumerate all attributes
str.enumerateAttributes(in: NSRange(location: 0, length: str.length)) { attrs, range, stop in
    if let font = attrs[.font] as? UIFont {
        print("Font: \(font) at \(range)")
    }
}

// Enumerate specific attribute
str.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: str.length)) { value, range, stop in
    if let color = value as? UIColor {
        print("Color: \(color) at \(range)")
    }
}

Conversion Between Types

AttributedString → NSAttributedString

// Using all default scopes
let nsAS = NSAttributedString(attrString)

// With specific scope (preserves custom attributes)
let nsAS = try NSAttributedString(attrString, including: \.myApp)

NSAttributedString → AttributedString

// Using all default scopes
let swiftAS = try AttributedString(nsAS, including: \.foundation)

// With specific scope
let swiftAS = try AttributedString(nsAS, including: \.myApp)

Pitfall: Conversion without including: your custom scope silently drops custom attributes. Always include the scope containing your custom keys.

Common Pitfalls

  1. Forgetting including: in conversion — Custom attributes silently dropped during AttributedStringNSAttributedString conversion.
  2. Index invalidation — Mutating AttributedString invalidates all existing indices. Re-find ranges after mutation.
  3. NSParagraphStyle is immutable — Always create NSMutableParagraphStyle, then assign. Cannot modify after setting on attributed string.
  4. Mixing AttributedString and NSAttributedString — UIKit APIs require NSAttributedString. SwiftUI requires AttributedString. Convert at boundaries.
  5. Scope must include standard scopes — Custom AttributeScope should include FoundationAttributes and UIKitAttributes/SwiftUIAttributes for round-trip conversion.
  6. NSTextStorage IS an NSMutableAttributedString — Can use all NSAttributedString APIs directly on text storage.

Related Skills

  • For custom scopes, paragraph-style recipes, and the attribute-key table, see advanced-patterns.md.
  • Use /skill apple-text-formatting-ref for the full formatting-key catalog.
  • Use /skill apple-text-swiftui-bridging when the real issue is what SwiftUI renders or drops.
  • Use /skill apple-text-markdown when Markdown parsing is driving the attributed-text shape.