Add TextKit Line Numbers And Foldable Sections Without Rebuilding Text Editing
The trap in editor work is thinking there are only two choices.
One choice is SwiftUI’s TextEditor, which is wonderful until you need code-editor line numbers, foldable sections, custom inline controls, or deep layout knowledge. The other choice is building your own TextKit editor, which gives you control but also hands you text input, selection, scrolling, accessibility, undo, dictation, writing tools, hardware keyboard behavior, and a dozen other things you probably did not want to reimplement.
The more interesting path is in the middle: keep the system text view, then extend the TextKit behavior it already uses.
Apple’s WWDC26 session Elevate your app’s text experience with TextKit is exactly about that middle path. It shows line numbers, collapsible sections, and attachment view-provider reuse while still starting from UITextView and NSTextView. Apple’s accompanying Enriching your text in text views sample/doc page is the place to compare the final SDK spelling against the ideas below.
One caveat before the code: this is beta-era TextKit 2 API territory for the Xcode 27 / 2027 SDK cycle. Treat the examples here as production-shaped sketches, not compiler-verified snippets. Verify exact method names, enum cases, and invalidation calls in the current Xcode 27 SDK before dropping them into an app.
The shape
The architecture I would start with looks like this:
- SwiftUI owns navigation, toolbar state, document loading, and save prompts.
- A
UIViewRepresentablehosts one UIKit container view. - The UIKit container owns a custom
UITextViewsubclass and a gutter view. - The
UITextViewsubclass observes TextKit viewport layout to report visible paragraph frames. - A fold model describes which heading sections are collapsed.
- TextKit’s content storage delegate skips body paragraphs that belong to collapsed sections.
- Inline attachments keep durable state in your model, while
NSTextAttachmentViewProviderreuse preserves the live view/provider across edits or scrolling.
That last sentence is the point of the whole post. SwiftUI can still be the app shell. UIKit can still own the editing surface. TextKit can still do the layout. Your code adds the behavior around the edges.
Start with a SwiftUI wrapper
Keep the representable boring. It should create the UIKit editor once, push SwiftUI state into it, and send text changes back out. Avoid rebuilding the text view on every update or you will lose selection, undo history, attachment view state, and scroll position.
import SwiftUI
import UIKit
struct SectionID: Hashable, Codable {
var rawValue: String
}
struct TextKitOutlineEditor: UIViewRepresentable {
@Binding var text: NSAttributedString
@Binding var collapsedSections: Set<SectionID>
func makeUIView(context: Context) -> TextEditorContainerView {
let container = TextEditorContainerView()
container.textView.delegate = context.coordinator
container.textView.onVisibleLinesChanged = { [weak container] lines in
container?.gutterView.visibleLines = lines
}
container.textView.onSectionToggleRequested = { sectionID in
context.coordinator.toggle(sectionID)
}
return container
}
func updateUIView(_ container: TextEditorContainerView, context: Context) {
container.textView.setAttributedTextIfNeeded(text)
container.textView.setCollapsedSections(collapsedSections)
}
func makeCoordinator() -> Coordinator {
Coordinator(
text: $text,
collapsedSections: $collapsedSections
)
}
final class Coordinator: NSObject, UITextViewDelegate {
private var text: Binding<NSAttributedString>
private var collapsedSections: Binding<Set<SectionID>>
init(
text: Binding<NSAttributedString>,
collapsedSections: Binding<Set<SectionID>>
) {
self.text = text
self.collapsedSections = collapsedSections
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.attributedText
}
func toggle(_ sectionID: SectionID) {
if collapsedSections.wrappedValue.contains(sectionID) {
collapsedSections.wrappedValue.remove(sectionID)
} else {
collapsedSections.wrappedValue.insert(sectionID)
}
}
}
}
The small helper below matters more than it looks. SwiftUI may call updateUIView often. If you assign attributedText every time, UIKit treats that as a real edit-like replacement. Preserve the current selection when the incoming value is the same document state.
extension UITextView {
func setAttributedTextIfNeeded(_ newValue: NSAttributedString) {
guard attributedText != newValue else { return }
let oldSelection = selectedRange
attributedText = newValue
let safeLocation = min(oldSelection.location, attributedText.length)
let safeLength = min(oldSelection.length, attributedText.length - safeLocation)
selectedRange = NSRange(location: safeLocation, length: safeLength)
}
}
In a real document editor, I would also avoid echo loops by tracking whether a text update originated from UIKit or from SwiftUI document loading. Keep that policy in the coordinator or a small store, not scattered through the view.
Put the gutter beside the text view
The UIKit container is not exciting, and that is a feature. It gives the gutter and the text view stable identities for the lifetime of the SwiftUI screen.
import UIKit
final class TextEditorContainerView: UIView {
let gutterView = LineNumberGutterView()
let textView = OutlineTextView()
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
private func configure() {
backgroundColor = .systemBackground
gutterView.translatesAutoresizingMaskIntoConstraints = false
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(gutterView)
addSubview(textView)
textView.font = .monospacedSystemFont(ofSize: 15, weight: .regular)
textView.backgroundColor = .systemBackground
textView.textContainerInset = UIEdgeInsets(
top: 12,
left: 12,
bottom: 24,
right: 12
)
NSLayoutConstraint.activate([
gutterView.leadingAnchor.constraint(equalTo: leadingAnchor),
gutterView.topAnchor.constraint(equalTo: topAnchor),
gutterView.bottomAnchor.constraint(equalTo: bottomAnchor),
gutterView.widthAnchor.constraint(equalToConstant: 52),
textView.leadingAnchor.constraint(equalTo: gutterView.trailingAnchor),
textView.trailingAnchor.constraint(equalTo: trailingAnchor),
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}
This is also a good place to decide what the gutter is for. If it only shows line numbers, it should probably be ignored by accessibility. If it includes fold buttons, those buttons need labels and hit targets large enough for touch.
Draw line numbers from viewport fragments
Do not calculate line numbers by measuring text yourself. That is the road to a second layout engine, and the second one will disagree with TextKit at the worst possible time.
TextKit already knows which paragraphs are visible. In the WWDC26 example, UITextView and NSTextView can participate in the viewport layout process. A subclass can override viewport controller delegate methods, collect the NSTextLayoutFragment frames that intersect the viewport, and pass those frames to a gutter.
Here is the data the gutter needs:
struct VisibleEditorLine: Equatable {
var number: Int
var rectInViewport: CGRect
var sectionID: SectionID?
var isHeading: Bool
var isCollapsed: Bool
}
And here is a simple gutter that draws right-aligned line numbers plus a disclosure marker for headings:
final class LineNumberGutterView: UIView {
var visibleLines: [VisibleEditorLine] = [] {
didSet { setNeedsDisplay() }
}
var onToggleSection: ((SectionID) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = true
backgroundColor = .secondarySystemBackground
}
required init?(coder: NSCoder) {
super.init(coder: coder)
isOpaque = true
backgroundColor = .secondarySystemBackground
}
override func draw(_ rect: CGRect) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular),
.foregroundColor: UIColor.secondaryLabel,
.paragraphStyle: paragraphStyle
]
for line in visibleLines {
let numberRect = CGRect(
x: 4,
y: line.rectInViewport.minY + 2,
width: bounds.width - 14,
height: 18
)
NSString(string: "\(line.number)").draw(
in: numberRect,
withAttributes: attributes
)
guard line.isHeading, line.sectionID != nil else { continue }
let marker = line.isCollapsed ? ">" : "v"
NSString(string: marker).draw(
at: CGPoint(x: bounds.width - 10, y: numberRect.minY),
withAttributes: attributes
)
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let point = touches.first?.location(in: self) else { return }
let hitLine = visibleLines.first { line in
guard line.isHeading else { return false }
let hitRect = CGRect(
x: bounds.width - 28,
y: line.rectInViewport.minY,
width: 28,
height: max(28, line.rectInViewport.height)
)
return hitRect.contains(point)
}
if let sectionID = hitLine?.sectionID {
onToggleSection?(sectionID)
}
}
}
That uses plain text markers so the snippet stays focused. In a shipping app, I would use a real UIButton or accessibility element for each visible disclosure control if folding is a primary interaction.
Capture viewport layout in UITextView
Now the text view subclass can collect the layout fragments TextKit is already producing. The method names below follow the WWDC26 session shape: clear state in willLayout, collect bounds in configureRenderingSurfaceFor, then publish line data in didLayout.
final class OutlineTextView: UITextView {
var onVisibleLinesChanged: ([VisibleEditorLine]) -> Void = { _ in }
var onSectionToggleRequested: (SectionID) -> Void = { _ in }
private(set) var foldMap = FoldMap()
private var collapsedSections = Set<SectionID>()
private var paragraphNumberByLocation: [NSTextLocation: Int] = [:]
private var pendingFragmentFrames: [CGRect] = []
private var startingLineNumber = 1
override func textViewportLayoutControllerWillLayout(
_ controller: NSTextViewportLayoutController
) {
super.textViewportLayoutControllerWillLayout(controller)
pendingFragmentFrames.removeAll(keepingCapacity: true)
startingLineNumber = lineNumber(atStartOf: controller.viewportRange)
}
override func textViewportLayoutController(
_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment
) {
super.textViewportLayoutController(
controller,
configureRenderingSurfaceFor: textLayoutFragment
)
pendingFragmentFrames.append(textLayoutFragment.layoutFragmentFrame)
}
override func textViewportLayoutControllerDidLayout(
_ controller: NSTextViewportLayoutController
) {
super.textViewportLayoutControllerDidLayout(controller)
let viewportOrigin = controller.viewportBounds.origin
let visibleLines = pendingFragmentFrames.enumerated().map { offset, frame in
let rect = frame.offsetBy(
dx: -viewportOrigin.x,
dy: -viewportOrigin.y
)
let lineNumber = startingLineNumber + offset
return VisibleEditorLine(
number: lineNumber,
rectInViewport: rect,
sectionID: foldMap.sectionIDForHeading(atParagraph: lineNumber),
isHeading: foldMap.isHeading(paragraph: lineNumber),
isCollapsed: foldMap.isCollapsedHeading(paragraph: lineNumber)
)
}
onVisibleLinesChanged(visibleLines)
}
}
The line numbers in this version are paragraph numbers, which map nicely to source lines when your code editor treats each newline-delimited paragraph as a line. Wrapped visual lines are a different feature. For those, inspect the line fragments inside each NSTextLayoutFragment instead of numbering the paragraph fragments themselves.
Folding adds one more choice. The startingLineNumber + offset version gives you compact visible numbering. If you want code-editor source numbering, where a folded block jumps from line 24 to line 82, map each layout fragment back to its represented text element range and look up the original paragraph number in your paragraph cache.
The starting line calculation can be simple at first:
extension OutlineTextView {
private func lineNumber(atStartOf viewportRange: NSTextRange?) -> Int {
guard
let viewportRange,
let contentStorage = textLayoutManager?.textContentManager
as? NSTextContentStorage
else {
return 1
}
var lineNumber = 1
let documentStart = contentStorage.documentRange.location
contentStorage.enumerateTextElements(from: documentStart) { element in
guard let range = element.elementRange else { return true }
if range.location.compare(viewportRange.location) != .orderedAscending {
return false
}
lineNumber += 1
return true
}
return lineNumber
}
}
This mirrors the session’s idea: count text elements before the viewport start. For a large document, cache paragraph offsets and invalidate the cache after edits. You do not want a long walk from the start of a 50,000-line file on every scroll tick.
Model foldable sections outside layout
Collapsing text is not just drawing a triangle. You need a model that can answer two questions quickly:
- Which visible paragraphs are headings?
- Which body paragraphs should TextKit skip because their heading is collapsed?
Apple’s recipe-style example tracks collapsed paragraph offsets. That works for a demo, but I prefer stable section IDs in an app because users edit text above the folded area. Paragraph numbers are layout positions, not document identity.
For Markdown-ish documents, you can build a derived fold map from heading lines:
struct FoldSection: Hashable {
var id: SectionID
var headingParagraph: Int
var bodyParagraphs: Range<Int>
}
struct FoldMap {
var sections: [FoldSection] = []
var collapsed: Set<SectionID> = []
func isHeading(paragraph: Int) -> Bool {
sections.contains { $0.headingParagraph == paragraph }
}
func sectionIDForHeading(atParagraph paragraph: Int) -> SectionID? {
sections.first { $0.headingParagraph == paragraph }?.id
}
func isCollapsedHeading(paragraph: Int) -> Bool {
guard let id = sectionIDForHeading(atParagraph: paragraph) else {
return false
}
return collapsed.contains(id)
}
func shouldHide(paragraph: Int) -> Bool {
sections.contains { section in
collapsed.contains(section.id)
&& section.bodyParagraphs.contains(paragraph)
}
}
}
Then rebuild that map after text edits:
extension FoldMap {
static func markdownHeadings(
in attributedText: NSAttributedString,
collapsed: Set<SectionID>
) -> FoldMap {
let lines = attributedText.string.split(
separator: "\n",
omittingEmptySubsequences: false
)
var sections: [FoldSection] = []
var currentHeading: (id: SectionID, paragraph: Int)?
for index in lines.indices {
let line = lines[index]
let paragraph = index + 1
let isHeading = line.hasPrefix("#")
guard isHeading else { continue }
if let previous = currentHeading {
sections.append(
FoldSection(
id: previous.id,
headingParagraph: previous.paragraph,
bodyParagraphs: (previous.paragraph + 1)..<paragraph
)
)
}
let normalizedTitle = String(line)
.trimmingCharacters(in: .whitespaces)
.lowercased()
currentHeading = (
SectionID(rawValue: "\(normalizedTitle)-\(paragraph)"),
paragraph
)
}
if let previous = currentHeading {
sections.append(
FoldSection(
id: previous.id,
headingParagraph: previous.paragraph,
bodyParagraphs: (previous.paragraph + 1)..<(lines.count + 1)
)
)
}
return FoldMap(sections: sections, collapsed: collapsed)
}
}
That ID strategy is intentionally modest. It survives many edits, but a heading rename changes the ID. For a serious outliner, store a stable section identifier as an attribute on the heading paragraph or in a sidecar document model. Then your fold state can survive heading renames, syncing, and collaboration.
Skip folded paragraphs with the content delegate
The fold map describes your intent. TextKit still needs to know not to enumerate hidden body paragraphs for layout.
The session’s collapsible sections example uses NSTextContentStorageDelegate and textContentManager(shouldEnumerate:options:) to skip collapsed NSTextElements. The exact signature may shift, but the shape is:
extension OutlineTextView: NSTextContentStorageDelegate {
func installTextStorageDelegateIfNeeded() {
let storage = textLayoutManager?.textContentManager
as? NSTextContentStorage
storage?.delegate = self
}
func textContentManager(
shouldEnumerate textElement: NSTextElement,
options: NSTextContentManager.EnumerationOptions
) -> Bool {
guard let paragraph = paragraphNumber(for: textElement) else {
return true
}
return !foldMap.shouldHide(paragraph: paragraph)
}
}
The missing helper is your paragraph index cache. Do not count from the start of the document for every element during enumeration. Rebuild a cache after edits and use NSTextLocation or the current SDK’s comparable location type as the key.
extension OutlineTextView {
private func rebuildFoldMap() {
foldMap = FoldMap.markdownHeadings(
in: attributedText,
collapsed: collapsedSections
)
paragraphNumberByLocation.removeAll(keepingCapacity: true)
guard
let storage = textLayoutManager?.textContentManager
as? NSTextContentStorage
else { return }
var paragraph = 1
storage.enumerateTextElements(from: storage.documentRange.location) { element in
if let range = element.elementRange {
paragraphNumberByLocation[range.location] = paragraph
}
paragraph += 1
return true
}
}
private func paragraphNumber(for element: NSTextElement) -> Int? {
guard let location = element.elementRange?.location else { return nil }
return paragraphNumberByLocation[location]
}
}
That snippet assumes NSTextLocation is usable as your dictionary key in the current SDK. If it is not, wrap the comparable location in your own stable cache key or store paragraph metadata directly while enumerating.
When the collapsed set changes, update the map and invalidate layout for the affected document range:
extension OutlineTextView {
func setCollapsedSections(_ newValue: Set<SectionID>) {
guard collapsedSections != newValue else { return }
collapsedSections = newValue
rebuildFoldMap()
invalidateTextLayoutForFoldChanges()
}
private func invalidateTextLayoutForFoldChanges() {
guard let viewportController = textLayoutManager?.textViewportLayoutController else {
setNeedsLayout()
return
}
viewportController.delegate?
.textViewportLayoutControllerReceivedSetNeedsLayout?(viewportController)
}
}
Again, verify the invalidation path in the SDK you are using. Apple’s session sample asks the viewport layout controller’s delegate to schedule layout after a fold toggle. The important behavior is that TextKit must re-enumerate the affected elements so hidden paragraphs leave layout and newly expanded paragraphs come back.
Keep folding interaction close to the gutter
The gutter can request a toggle, but the SwiftUI binding should remain the source of truth. Wire the request from the text view to the gutter once in the container:
final class TextEditorContainerView: UIView {
// Existing properties...
private func configure() {
// Existing setup...
gutterView.onToggleSection = { [weak textView] sectionID in
textView?.onSectionToggleRequested(sectionID)
}
}
}
That flow is a little indirect, but each layer keeps its job:
- The gutter handles hit testing.
- The text view knows which visible lines correspond to headings.
- SwiftUI owns the collapsed section set.
- TextKit is invalidated when that set changes.
The payoff is that expanding a section is not a visual trick. The hidden paragraphs are removed from TextKit’s layout work while folded, which is exactly what you want for large documents.
Reuse attachment view providers
Line numbers and folded sections are about text. Real editors also have inline things: issue chips, image thumbnails, comments, diagrams, math previews, audio waveforms, or task controls.
In TextKit, those are text attachments. The storage layer has an NSTextAttachment, and the layout/view side uses an NSTextAttachmentViewProvider to provide the view. The WWDC26 session calls out a practical problem: if the paragraph is edited or the attachment scrolls out of the viewport, recreating providers can lose live state and cause visual glitches.
The new reuse policies shown in the session let a text view cache or preserve providers for specific attachment provider types:
final class OutlineTextView: UITextView {
private func configureAttachmentReuse() {
register(
[.onEditingInlineParagraphs],
forTextAttachmentViewProviderType: CommentAttachmentViewProvider.self
)
register(
[.onScrollingOutOfViewport],
forTextAttachmentViewProviderType: PreviewAttachmentViewProvider.self
)
}
}
Think of those policies as tuning knobs.
Use onEditingInlineParagraphs when edits near an attachment should not reset the live provider. A checklist attachment should not lose its pressed state because someone typed earlier in the same paragraph.
Use onScrollingOutOfViewport when recreating the provider is expensive or visually disruptive. A generated chart preview, waveform, or image crop control can keep its surface warm as it leaves and re-enters the viewport.
Do not mistake provider reuse for persistence. Durable state belongs in your document model or attachment payload. Provider reuse preserves the live presentation object so the editor feels stable while the user edits and scrolls.
A provider still wants model-backed configuration:
final class CommentAttachment: NSTextAttachment {
let commentID: UUID
init(commentID: UUID) {
self.commentID = commentID
super.init(data: nil, ofType: nil)
}
required init?(coder: NSCoder) {
fatalError("Use your document decoder for comment attachments.")
}
}
final class CommentAttachmentViewProvider: NSTextAttachmentViewProvider {
override func loadView() {
guard let comment = textAttachment as? CommentAttachment else {
view = UIView()
return
}
view = CommentChipView(commentID: comment.commentID)
}
}
The provider owns the view. The view can read and mutate state through your editor store by commentID. That way, if TextKit does recreate the provider, the attachment still comes back with the right durable state.
What I would ship first
I would not try to build the whole editor in one pass. The safe order is:
- Wrap a plain
UITextViewand confirm typing, selection, scrolling, undo, and saves work. - Add the container and gutter without line numbers.
- Add viewport-driven paragraph numbers.
- Cache paragraph indexes so large documents scroll smoothly.
- Add a fold map that only affects gutter disclosure state.
- Connect
NSTextContentStorageDelegateand actually skip folded body paragraphs. - Add attachment provider reuse only for attachment types that have real state or expensive views.
There are also a few tests and manual checks I would insist on before shipping:
- Edit above a folded section and confirm the same section stays folded.
- Paste a large block of text and confirm the paragraph cache rebuilds.
- Undo and redo a heading edit.
- Scroll quickly through thousands of paragraphs.
- Use Dynamic Type and a hardware keyboard.
- Turn on VoiceOver and make sure fold controls are discoverable.
- Confirm inline attachments do not lose durable state when edited around or scrolled away.
The nice thing about this approach is that each step preserves the system editor. You are not replacing text editing. You are adding the missing pieces around TextKit’s existing storage, layout, viewport, and view layers.
That is a much calmer place to build from.
Further reading: