Dunfey · Hotel WWDC as data, est. 1983
Front desk everything
Years
Topics

2026 App ServicesSwiftUI & UI Frameworks

WWDC26 · 24 min · App Services / SwiftUI & UI Frameworks

Elevate your app’s text experience with TextKit

Discover how to combine the convenience of built-in text views with the control of TextKit. We’ll show you how new APIs make it easy to extend UITextView and NSTextView with custom behaviors like line numbers and collapsible sections. We’ll also explore the TextKit architecture and walk through new caching and reuse policies for text attachments. To get the most out of this session, watch “Meet TextKit 2” from WWDC21 and “What’s New in TextKit and text views” from WWDC22.

Watch at developer.apple.com ↗

Transcript all transcripts

Chapters

  • 0:00 — Introduction
  • 3:09 — TextKit architecture
  • 9:17 — What's new in TextKit
  • 11:27 — Extending framework text views
  • 12:58 — Example: Code editor with line numbers
  • 17:52 — Example: Collapsible recipe sections
  • 19:56 — Text attachments and view provider reuse
  • 23:00 — Next steps

Code shown on screen · 10 snippets

NSTextViewportRenderingSurface conformance swift · at 9:47 ↗
class MyView: UIView, NSTextViewportRenderingSurface {}
NSTextViewportRenderingSurfaceKey and NSMapTable swift · at 10:25 ↗
class MyView: UIView, NSTextViewportRenderingSurface {}

var cache: NSMapTable<NSTextLayoutFragment, MyView>
UITextView/NSTextView in SwiftUI via ViewRepresentable swift · at 12:39 ↗
// Using a TextView in SwiftUI

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
ContainerView with TextView and line number view swift · at 13:33 ↗
// Create a text view subclass for a code editor

import UIKit

class TextView: UITextView {}

class ContainerView: UIView {
    let textView = TextView()
    let lineNumberView = UIView()
   
    textView.font = UIFont.monospacedSystemFont
}
Three NSTextViewportLayoutControllerDelegate overrides swift · at 14:42 ↗
// Override viewport controller delegate methods

class TextView: UITextView {
    // Set up
		override func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
    	super.textViewportLayoutControllerWillLayout(textViewportLayoutController)
      //...
    }

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

    // Share accumulated info back to ContainerView
		override func textViewportLayoutControllerDidLayout (_ textViewportLayoutController: NSTextViewportLayoutController) {
		  super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
      //...
    }
}
startingLineNumber(for:) using enumerateTextElements swift · at 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
}
DidLayout: convert frames to viewport coordinates swift · at 17:02 ↗
// Override viewport controller delegate methods

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

    // Share accumulated info back to ContainerView
		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 closure swift · at 17:16 ↗
// Draw line numbers in the ContainerView

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)
            }
        }
    }
}
Collapsible sections: full TextView class swift · at 19:22 ↗
// Add collapsible sections to your text view

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

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

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

    // Share accumulated info back to ContainerView
		override func textViewportLayoutControllerDidLayout (_ textViewportLayoutController: NSTextViewportLayoutController) {
		  super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
      //...
    }
  
    // Skip layout for paragraphs marked as collapsed
    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)
    }
}
Text attachment view provider reuse policy swift · at 22:06 ↗
// Cache text attachment view providers

import UIKit

class ViewController: UIViewController {

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

Resources