Building a native block editor with Swift and CoreText
How we connect UITextInput, NSTextInputClient, and text layout to keep editing responsive as notes grow.

We built Phrase's native block editor in Swift and CoreText because we wanted structured notes to feel like one continuous writing surface. You can select text across blocks and replace it in a single edit, even when the selection starts in a heading and ends in a paragraph.
That edit connects several parts of the system. The document changes, text wraps, the caret moves, and the keyboard needs the new selection. We render the editable document natively, without a WebView. On iPhone, UIKit and UITextInput connect our editor to the system. Our Mac editor, shipping in Phrase for Mac, uses AppKit and NSTextInputClient.
The difficult part is keeping those pieces in agreement while doing little enough work to respond to the next input. Here is how we divide that responsibility, and what we have measured in a document with 10,000 blocks.
One document across block boundaries
A heading and a paragraph have different formatting, but a selection can include text from both. We support one continuous text range across those blocks. Replacing it has a consistent meaning regardless of which views happen to be on screen.
We share the document and editing foundations in Swift. The native surfaces handle the interaction around them. Touch selection on iPhone and pointer selection on Mac can reach the same document operation through different gestures.
This separation also lets a note contain math, Mermaid diagrams, images, and link previews without making the surrounding text feel like a collection of unrelated fields.

An architectural overview of how an edit reaches the screen. Native input uses the geometry of the text we draw.
Where Swift, CoreText, and native input fit
| Technology | Its role in our editor |
|---|---|
| Swift | Shared document structure and editing behavior |
| CoreText | Text shaping, line layout, and the geometry we use for drawing and selection |
| UIKit | The native iPhone editing surface and touch environment |
| UITextInput | The iPhone connection to system text input, selection, and composition |
| AppKit | The native Mac editing surface and responder environment |
| NSTextInputClient | The Mac connection to system text input, selection, and composition |
The document decides what an edit means. Layout determines where the result belongs. The native surface draws it and answers the input system's questions about it. Keeping these responsibilities separate gives us a place to optimize layout without changing the meaning of an edit.
CoreText: one layout for text, caret, and selection
CoreText provides low-level text layout and font handling. We use its typeset lines as the basis for drawing text and determining where the caret and selection belong.
Those answers need to agree. When a word wraps onto a new line, its selection highlight has to follow it. When you tap between letters, the caret should land in the text you see. Estimating either position independently would give us another answer to keep in sync.
We reuse the same layout information for these tasks. That keeps text geometry consistent and avoids repeating measurement simply because a different part of the editor needs it.
CoreText supplies the text layout. We still define what happens when a selection crosses blocks, when Enter splits a paragraph, or when a deletion joins content. Our article on text and selection geometry goes further into that boundary.
UITextInput: a custom editor inside the iOS input system
On iPhone, we implement UITextInput so the system can work with our document's text, selection, marked text, and geometry.
An input method does more than deliver finished characters. While someone composes a word, the visible text may still be provisional. Choosing a different candidate can replace it before it becomes a committed edit.
We preserve that composition state as the input method revises it. The candidate interface also needs to know where the composition is on screen, so native input depends on the same geometry as the rendered text.
This is why keyboard support reaches into the editor's architecture. Getting insertion right is only part of the job. The selection, provisional text, and visible layout all have to describe the same editing session. We cover that behavior in our IME composition article.
NSTextInputClient: the Mac connection
Our AppKit surface adopts NSTextInputClient to participate in Mac text input. It handles the platform conversation around insertion, selection, marked text, and the geometry an input method requests.
The document operations remain shared. The integration belongs to macOS, including its responder environment and keyboard behavior. We can work on pointer interaction and window layout without forcing the iPhone editor through a desktop interaction model.
Keeping a 10,000-block document responsive
Choosing native frameworks gives us control over the work behind an edit. We still have to decide how much of that work to do.
In our macOS implementation, we reuse native views around the visible area. A document with 10,000 blocks does not need 10,000 live block views. We also reuse text layout that remains valid after an edit, so changing a sentence does not mean typesetting every paragraph again.
The goal is to leave room for the next interaction. Scrolling, typing, and text selection all compete for time on the main thread. We measure individual editing operations to see whether that work stays small as the document grows.
In our Release-build test on Apple Silicon, with a generated document starting at 10,000 blocks, we measured these median times:
| Operation | Median time | Samples |
|---|---|---|
| Steady scrolling after layout refinement | 0.28 ms | 100 |
| Inserting one character | 1.01 ms | 20 |
| Resizing the editor width and reflowing text | 1.98 ms | 30 |
These are main-thread editor operation times from an in-memory macOS fixture. They exclude persistence, synchronization, and the rest of the rendering pipeline, so they do not measure display frame rate or end-to-end input latency. Our full performance article includes the method, sample counts, and remaining measurements.
The next edit matters
We can improve text measurement without changing what a cross-block edit means. We can reuse a view without losing the selection it used to display. And we can refine each platform's input behavior while keeping the document consistent.
Those are the freedoms we wanted from a native editor. A note can grow to thousands of blocks while our attention stays on the small revision the writer is making now.
Read more about our block editor architecture, or try Phrase for iPhone.