Assign an NSTextFieldDelegate, implement controlTextDidEndEditing(_:), and copy the field’s stringValue into a SwiftUI binding. A coordinator connects that callback to view state, so a label can update after editing finishes.
This tutorial builds a small Mac app with two editable labels and a summary that shows their committed values. The task is to keep the summary unchanged while someone types, then update it when AppKit finishes the editing session. Apple documents the end-editing callback as occurring after the control commits its changes. Apple’s controlTextDidEndEditing(_:) documentation
Which event should update the view?
For an existing NSTextField, use its control-editing delegate callback. The delegate receives a notification whose object is the control that posted it, allowing the handler to identify the field and read its value. Apple’s textDidEndEditingNotification documentation
Choose the event according to the interface behavior you need:
| Desired behavior | Appropriate path |
|---|---|
| Update a committed value when an AppKit text field finishes editing | Implement controlTextDidEndEditing(_:) on its delegate. |
| Send an existing AppKit target-action handler on editing completion | Configure the cell’s sendsActionOnEndEditing property. |
Handle submission from a native SwiftUI TextField |
Attach .onSubmit for its documented submission behavior. |
These paths have different contracts. Apple documents that enabling sendsActionOnEndEditing sends the action after Return, Tab out of the field, or a click in another text field; disabling it limits the action to Return. SwiftUI documents .onSubmit for a TextField as responding to Return. It therefore does not establish a general focus-loss callback for an embedded AppKit field. Apple’s sendsActionOnEndEditing documentation, Apple’s onSubmit(of:_:) documentation
For this app, the delegate is the single place that commits a field’s text. If you also add a target-action handler later, avoid performing the same commit side effect in both handlers.
Build a complete SwiftUI and AppKit example
Create a macOS app project in Xcode using Swift and the SwiftUI interface. Use a recent Swift 6-capable toolchain and set the deployment target to macOS 13 or later for this example. Replace the generated app entry file with the complete program below, and remove the generated ContentView.swift if it declares another ContentView. Keep exactly one @main entry point. Apple’s App documentation describes this entry-point structure. Apple’s App documentation
The example’s macOS 13 target is a project choice, not the introduction version of the bridge: Apple lists NSViewRepresentable as available starting with macOS 10.15. AppKit makes this implementation specific to a macOS target. Apple’s NSViewRepresentable documentation
import SwiftUI
import AppKit
@main
@MainActor
struct EditingDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@MainActor
struct ContentView: View {
@State private var cardTitle = "Blue Card"
@State private var shelfLabel = "Shelf A"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Card title")
CommitTextField(value: $cardTitle, label: "Card title")
.frame(height: 24)
Text("Shelf label")
CommitTextField(value: $shelfLabel, label: "Shelf label")
.frame(height: 24)
Divider()
Text("Committed values")
.font(.headline)
Text("Card: \(cardTitle)")
Text("Shelf: \(shelfLabel)")
}
.padding(20)
.frame(minWidth: 420)
}
}
@MainActor
struct CommitTextField: NSViewRepresentable {
@Binding var value: String
let label: String
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField(string: value)
field.placeholderString = label
field.delegate = context.coordinator
return field
}
func updateNSView(_ field: NSTextField, context: Context) {
context.coordinator.parent = self
field.placeholderString = label
field.isEnabled = context.environment.isEnabled
// Leave the active editing buffer alone.
if field.currentEditor() == nil, field.stringValue != value {
field.stringValue = value
}
}
static func dismantleNSView(
_ field: NSTextField,
coordinator: Coordinator
) {
field.delegate = nil
}
@MainActor
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: CommitTextField
init(parent: CommitTextField) {
self.parent = parent
super.init()
}
func controlTextDidEndEditing(_ notification: Notification) {
guard let field = notification.object as? NSTextField else {
return
}
let committedValue = field.stringValue
if parent.value != committedValue {
parent.value = committedValue
}
}
}
}
This is a complete app example with synthetic local values. It was inspected against the retrieved API documentation, but it was not compiled or run here: the available execution environment is Linux and has no Swift toolchain or macOS SDK. Build and exercise it in Xcode on a Mac.
How the value reaches the summary
Each parent @State value supplies a binding to its field wrapper. The coordinator writes through that binding when editing ends. SwiftUI then updates the summary because it reads the changed state. Apple documents both the state-driven view update and the use of bindings to let child views modify parent-owned state. Apple’s State documentation
makeNSView creates the editable AppKit control and assigns the coordinator as its delegate. updateNSView handles changes flowing from SwiftUI into that control. The delegate callback handles the reverse direction. Apple’s integration guidance uses this same coordinator-and-binding arrangement for hosting AppKit views. Apple’s “Use SwiftUI with AppKit” session
The assignment context.coordinator.parent = self keeps the coordinator’s stored representable current. A coordinator survives for the hosted view’s lifetime, while the representable’s values can change. Apple explicitly demonstrates refreshing that stored value during updateNSView. Apple’s coordinator lifecycle explanation
Preserve the draft during SwiftUI updates
The essential guard is field.currentEditor() == nil. This example deliberately keeps unfinished text inside AppKit while SwiftUI retains the last committed value. Without an editing guard, copying the committed value into the field during a SwiftUI update could replace the unfinished draft.
Apple documents that currentEditor() returns the control’s field editor, or nil when it has none. The field editor is an NSTextView shared among controls in the window. That is why checking only whether the window’s firstResponder equals the NSTextField is a poor substitute for asking the control about its editor. Apple’s currentEditor() documentation
The resulting behavior is an explicit design policy:
| Situation | Example behavior |
|---|---|
| A field is idle and its binding changes | Copy the binding into the field on the next representable update. |
| A field has an active editor | Preserve the editing buffer. |
| Editing finishes with different text | Write the completed text into the binding. |
| Editing finishes with unchanged text | Skip the redundant state assignment. |
This policy gives the local draft priority during an editing session. If other app code changes the same binding while the person is editing, a later commit can replace that change. The demonstration has no competing writer. In a larger editor, decide how concurrent changes should be resolved—for example, finish the current session before switching the selected item, or retain separate draft and committed values and offer an explicit choice. Do not discard unfinished text merely to synchronize a redraw.
No TextKit layout customization is needed for this task. The relevant boundary is the control’s editing session and its field editor. Apple’s text architecture guide explains the relationship between NSTextField, its delegate, and the shared NSTextView field editor; that guide is archived, while the current currentEditor() reference confirms the shared-editor model. Apple’s “Text Editing” guide, Apple’s current field-editor reference
Verify completion behavior in the app
Use the following sequence to check the example after building it. These are expected checks derived from the implementation and documented editing behavior, rather than reported execution results.
- Replace
Blue CardwithGreen Card. While typing, the summary should still showCard: Blue Card. - Press Return. The summary should change to
Card: Green Card. - Edit the card title again, then click the
Shelf labelfield. The completed card title should appear in the summary. - Edit the shelf value, then press Tab. Confirm the shelf summary updates and inspect keyboard navigation.
- Commit an empty value. The corresponding summary should show its prefix with no value; the example accepts empty strings.
- Finish an edit without changing its text. The equality check should avoid another state assignment.
Return can complete editing without producing the focus movement you expect. Apple documents that after its end-editing notification, NSTextField attempts its action and other Return handling, and may select its text. Tab and Shift-Tab attempt navigation to the next or previous key view. Treat completion and focus destination as separate behaviors when checking the interface. Apple’s textDidEndEditing(_:) documentation
Diagnose a callback that does not arrive
First, verify that field.delegate still references the coordinator. The property is weak, so a temporary delegate object without a separate owner will not remain available. The representable coordinator provides the lifetime needed here. Apple’s delegate documentation, Apple’s coordinator lifecycle explanation
Next, confirm that editing actually ended. A click on an arbitrary background is not a reliable test of this condition. Use Return or another editable field in this demonstration, and inspect the specific responder transition if the result differs.
If your app adds validation, check control(_:textShouldEndEditing:). Its documented return value determines whether editing may end. A rejected attempt to leave the field should not be treated as a successful commit. Apple’s control(_:textShouldEndEditing:) documentation
Also inspect cancellation paths. Apple explicitly states that calling abortEditing() to discard pending edits does not invoke controlTextDidEndEditing(_:). A cancel operation therefore needs its own deliberate state behavior. Apple’s end-editing callback documentation
Request completion from an explicit app command
For a future Done command that has a reference to this field, the supported responder path is to ask its own window to call makeFirstResponder(nil). Check the returned Boolean before continuing: Apple documents that the current responder can refuse to resign, causing the method to return false. Use the field’s actual window rather than assuming an application-wide key window belongs to this editor. Apple’s makeFirstResponder(_:) documentation
Keep this bridge’s AppKit access and UI callbacks on the main actor. The current delegate declaration is main-actor isolated, and Swift documents MainActor as using an executor equivalent to the main dispatch queue. The example expresses that isolation directly with @MainActor; it does not add a deferred task between completion and the binding write. Apple’s callback declaration, Swift’s MainActor documentation
The implementation has one clear commit boundary: AppKit finishes editing, the coordinator reads the field, and the binding updates the dependent SwiftUI text. Keeping the draft protected during representable updates makes that boundary useful for everyday label editing without requiring custom text-layout machinery.
Sources
- controlTextDidEndEditing(_:) — Apple Developer Documentation
- textDidEndEditingNotification — Apple Developer Documentation
- sendsActionOnEndEditing — Apple Developer Documentation
- onSubmit(of:_:) — Apple Developer Documentation
- App — Apple Developer Documentation
- NSViewRepresentable — Apple Developer Documentation
- State — Apple Developer Documentation
- Use SwiftUI with AppKit — Apple Developer
- currentEditor() — Apple Developer Documentation
- Text Editing — Apple Documentation Archive
- textDidEndEditing(_:) — Apple Developer Documentation
- delegate — Apple Developer Documentation
- control(_:textShouldEndEditing:) — Apple Developer Documentation
- makeFirstResponder(_:) — Apple Developer Documentation
- MainActor — Apple Developer Documentation