WWDC Quick Look 💓 By SwiftGGTeam
Elevate your app's text experience with TextKit

Elevate your app's text experience with TextKit

Watch original video

Highlight

UITextView and NSTextView now expose internal TextKit hooks, allowing developers to add custom behaviors such as line numbers and collapsible sections without giving up framework-provided editing features like selection, undo, and dictation.

Core Content

Text editing on Apple platforms has long felt like a choice between two paths: use UITextView or NSTextView, or build everything yourself.

Framework text views work out of the box. Text input, selection, accessibility, undo and redo, dictation, inline predictions: all of these come for free. The cost is limited customization. Want to change how line numbers are drawn? Want to add custom views next to text? That has been difficult.

The other option is to build a custom text view from scratch with TextKit. You set up NSTextLayoutManager, handle viewport layout yourself, and render every piece of content yourself. You get full control, but you lose all of the features the framework gave you. Building a production-grade text editor on your own is not a small project.

TextKit’s four-layer architecture explains this dilemma: the storage layer holds text data, the layout layer chunks and measures it, the viewport layer tracks visible content, and the view layer performs the actual rendering. The first three layers are shared across frameworks, but the view layer is your own.

The 2027 update breaks through this wall. UITextView and NSTextView now conform to NSTextViewportLayoutControllerDelegate, so you can subclass them and override viewport layout callbacks. This means you can inject your own drawing logic while retaining all framework behavior.

Details

TextKit architecture recap

TextKit’s rendering flow has four layers (00:17):

  1. Storage layer: NSTextContentStorage decomposes an NSAttributedString into NSTextParagraph objects.
  2. Layout layer: NSTextLayoutManager measures glyphs and creates immutable NSTextLayoutFragment values.
  3. Viewport layer: NSTextViewportLayoutController tracks the visible region and renders only what the user can see.
  4. View layer: The UIView, NSView, or CALayer that actually renders text.

The core idea is on-demand rendering: viewport layout only processes layout fragments that intersect the current scroll position.

New Rendering Surface API

Before 2027, TextKit had no unified way to refer to a target rendering view (09:47). Two new protocols solve this problem:

class MyView: UIView, NSTextViewportRenderingSurface {}

Key points:

  • NSTextViewportRenderingSurface: represents a drawable visual element inside the viewport.
  • NSTextViewportRenderingSurfaceKey: uniquely identifies a rendering surface; NSTextLayoutFragment can be used as the key.

Use NSMapTable as a cache:

var cache: NSMapTable<NSTextLayoutFragment, MyView>

This lets you track the rendering view corresponding to each layout fragment during viewport layout.

Using UITextView in SwiftUI

SwiftUI’s TextEditor is suitable for simple scenarios, but sometimes you need the full capability of UITextView (12:39):

import SwiftUI

struct MyTextView: View {
    var body: some View { TextViewRepresentable() }
}

#if os(macOS)
struct TextViewRepresentable: NSViewRepresentable {
    func makeNSView(context: Context) -> NSTextView {
      NSTextView()
    }
    func updateNSView(_ nsView: NSTextView, context: Context) {}
}
#else
struct TextViewRepresentable: UIViewRepresentable {
    func makeUIView(context: Context) -> UITextView {
        UITextView()
    }
    func updateUIView(_ uiView: UITextView, context: Context) {}
}
#endif

Key points:

  • Use NSViewRepresentable on macOS, and UIViewRepresentable on iOS or Mac Catalyst.
  • Initialize the text view in the make method.
  • Keep the view synchronized in the update method.

Adding line numbers

The first example in the talk builds a code editor for iPad and adds line numbers (13:33):

import UIKit

class TextView: UITextView {}

class ContainerView: UIView {
    let textView = TextView()
    let lineNumberView = UIView()

    textView.font = UIFont.monospacedSystemFont
}

The core is to override three viewport controller delegate methods in TextView (14:42):

class TextView: UITextView {
    // Setup phase: reset state and calculate the starting line number
    override func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerWillLayout(textViewportLayoutController)
        // ...
    }

    // Collect position information for each paragraph
    override func textViewportLayoutController(_ textViewportLayoutController: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
        super.textViewportLayoutController(textViewportLayoutController, configureRenderingSurfaceFor: textLayoutFragment)
        // ...
    }

    // Pass accumulated information to ContainerView
    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
        // ...
    }
}

Calculate the starting line number with enumerateTextElements (15:59):

func startingLineNumber(for viewportRange: NSTextRange?) -> Int {
    guard let viewportRange,
          let storage = textLayoutManager?.textContentManager as? NSTextContentStorage else { return 0 }
    let startLocation = storage.documentRange.location
    var count = 1
    storage.enumerateTextElements(from: startLocation) { element in
        guard let range = element.elementRange else { return true }
        if range.location.compare(viewportRange.location) != .orderedAscending { return false }
        count += 1
        return true
    }
    return count
}

Key points:

  • enumerateTextElements iterates over text elements and returns false to stop iteration.
  • Compare positions to determine whether the viewport start has been reached.
  • In a real implementation, add caching to avoid recalculating this on every layout pass.

The DidLayout method needs to convert coordinates from the text container to the viewport (17:02):

class TextView: UITextView {
    private var lines: [CGRect] = []
    private var startingLineNumber = 0
    var onDidLayout: ((Int, [CGRect]) -> Void)?

    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(controller)
        let origin = controller.viewportBounds.origin
        onDidLayout?(startingLineNumber, lines.map { $0.offsetBy(dx: 0, dy: -origin.y) })
    }
}

Draw line numbers in ContainerView (17:16):

class ContainerView: UIView {
    let textView = TextView()
    let lineNumberView = UIView()

    func setup() {
        textView.onDidLayout = { startingLineNumber, lines in
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
                .foregroundColor: UIColor.secondaryLabel
            ]
            for (i, frame) in lines.enumerated() {
                let number = "\(startingLineNumber + i)" as NSString
                number.draw(at: CGPoint(x: 8, y: frame.minY), withAttributes: attributes)
            }
        }
    }
}

Key points:

  • The onDidLayout closure passes layout information from TextView to ContainerView.
  • Use NSString.draw(withAttributes:) to draw text directly.
  • frame.minY aligns the line number with the corresponding line.

Implementing collapsible sections

The second example is a recipe app that collapses multi-paragraph recipes down to headings only (19:22):

class TextView: UITextView, NSTextContentStorageDelegate {
    var collapsedSections: Set<Int> = []

    override func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerWillLayout(textViewportLayoutController)
        // ...
    }

    override func textViewportLayoutController(_ textViewportLayoutController: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
        super.textViewportLayoutController(textViewportLayoutController, configureRenderingSurfaceFor: textLayoutFragment)
        // ...
    }

    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
        // ...
    }

    // Skip layout for collapsed paragraphs
    func textContentManager(shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions) -> Bool {
        // ...
    }

    // Handle section collapse toggling
    func toggleSection(headerOffset: Int) {
        if collapsedSections.contains(headerOffset) {
            collapsedSections.remove(headerOffset)
        } else {
            collapsedSections.insert(headerOffset)
        }
        guard let textLayoutManager = textLayoutManager else { return }

        let textViewportLayoutController = textLayoutManager.textViewportLayoutController
        textViewportLayoutController.delegate?.textViewportLayoutControllerReceivedSetNeedsLayout?(textViewportLayoutController)
    }
}

Key points:

  • The shouldEnumerate method from NSTextContentStorageDelegate controls whether layout runs for a given text element.
  • collapsedSections stores the offsets of collapsed paragraphs in a Set.
  • After toggling collapse state, call textViewportLayoutControllerReceivedSetNeedsLayout to trigger relayout.

Reusing text attachment view providers

Text attachments such as inline images and stickers are rendered through NSTextAttachmentViewProvider. Because these objects are immutable, editing a paragraph recreates them and can lose animation state or other state.

The new 2027 registration API lets you specify a reuse strategy (22:06):

import UIKit

class ViewController: UIViewController {
    var textView: UITextView

    func setupTextView() {
        textView = UITextView()
        textView.register(
            [.onEditingInlineParagraphs],
            forTextAttachmentViewProviderType: AnimatedAttachmentViewProvider.self
        )
    }
}

Key points:

  • onEditingInlineParagraphs: preserve the view provider when editing a paragraph.
  • onScrollingOutOfViewport: cache the provider when it scrolls out of the viewport and restore it when it scrolls back.
  • The two strategies can be combined.

Key Takeaways

  1. Enhanced code editor: Starting from the line-number example, implement syntax highlighting, code folding, and error markers. Overriding configureRenderingSurfaceFor lets you recognize specific text ranges and apply different rendering styles.

  2. Rich text document editor: The collapsible section approach can be used for managing chapters in long documents. Combined with NSTextContentStorageDelegate, it can synchronize an outline view with content, support chapter navigation, and load content progressively.

  3. Collaborative note-taking app: Use text attachment reuse strategies to build a real-time collaborative note app. Cursor indicators, user avatars, and inline comments can all be text attachments, and the reuse strategy prevents those elements from flickering during editing.

  4. Custom text effects: Use the Rendering Surface API to add background animations, border highlights, or gesture responses to specific text ranges. This is useful for creative writing apps or interactive educational text.

  5. Synchronized multi-view editing: Connect multiple NSTextLayoutManager instances to the same NSTextContentStorage. This enables real-time synchronization between a main editing area and a preview area, or a side-by-side reading mode.

Comments

GitHub Issues · utterances