For a Mac app with single-line editable rows, handle Return with onSubmit, insert a new model immediately below the current row, and focus its field using FocusState. Give each row a stable ID so insertion preserves the connection between text, row, and focus.
This tutorial builds a small, local list editor with synthetic entries such as Item A. Pressing Return creates an empty row below the field being edited, preserves the existing text, and moves keyboard focus to the new field. It does not split text at the insertion point.
Which text control supports this behavior?
Use a single-line TextField when each row represents one short entry. Apple explicitly documents that a TextField triggers its onSubmit action when the user presses the hardware or software return key. Apple also documents that assigning a FocusState value moves focus to the associated view. Both APIs are available on macOS 12 and later. Apple: onSubmit(of:_:), Apple: FocusState.
Choose the control according to what the user is editing:
| Editing task | Starting point | Return behavior to implement |
|---|---|---|
| One short entry per row | Single-line TextField |
Submit, insert below, focus new row |
| A paragraph inside each row | TextEditor or an AppKit text view |
Define how inserting a row differs from inserting a newline |
| A document with custom text commands | NSTextView embedded in SwiftUI |
Handle the relevant text command and coordinate model changes |
Apple describes TextEditor as a multiline, scrollable editor. Its documentation does not establish the same Return-to-submit contract documented for TextField. Consequently, changing the example to TextEditor requires revisiting command handling; attaching the same modifier alone is not a documented equivalent. Apple: TextEditor, Apple: onSubmit(of:_:).
Keep row identity separate from row position
Three values serve different purposes: the row’s ID identifies the entry, its array index determines where to insert, and the focused ID identifies the field that should receive keyboard input.
Apple’s ForEach builds views from identified data. Use an Identifiable model with an ID stored when the row is created. In this design, the same ID also becomes the row’s focus value. That connection lets an entry retain its identity when another entry is inserted above it. Apple: ForEach.
Avoid using the editable text as the ID: two empty rows would have the same identity, and editing would change that identity. Array positions also make poor identities for this insertion task because positions shift. These are consequences of the proposed data model, rather than additional SwiftUI requirements.
Each field needs a distinct focus value. Apple warns that binding the same focus value to multiple views creates an ambiguous focus binding. A stored UUID per row provides a straightforward way to distinguish the fields. Apple: FocusState.
Build the complete macOS example
Create a macOS App project in Xcode using SwiftUI, and set its deployment target to macOS 12 or later. Replace the generated app entry point and content view with the following single file, removing the original app declaration so the target contains only one @main entry point.
The example uses a regular VStack inside a scroll view. It records the new row as a pending focus target, then requests focus when that field appears. This is an implementation strategy derived from Apple’s focus and appearance APIs; Apple does not document appearance callbacks as a universal focus-readiness guarantee. The exact timing of onAppear depends on the view. Apple: onAppear(perform:).
import SwiftUI
import Foundation
import Dispatch
private struct EditableRow: Identifiable {
let id = UUID()
var text: String
}
@main
struct RowEntryDemoApp: App {
var body: some Scene {
WindowGroup {
RowEditor()
}
}
}
private struct RowEditor: View {
@State private var rows = [
EditableRow(text: "Item A"),
EditableRow(text: "Item B")
]
@State private var pendingFocusID: UUID?
@FocusState private var focusedRowID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Local row editor")
.font(.headline)
Text("Press Return in a field to insert a row below it.")
.font(.subheadline)
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 10) {
ForEach($rows) { $row in
TextField("New item", text: $row.text)
.textFieldStyle(.roundedBorder)
.focused($focusedRowID, equals: row.id)
.onSubmit {
insertRow(after: row.id)
}
.id(row.id)
.onAppear {
focusIfPending(row.id) {
proxy.scrollTo(row.id, anchor: .center)
}
}
}
}
.padding(4)
}
}
}
.padding(20)
.frame(minWidth: 420, minHeight: 280)
}
private func insertRow(after id: UUID) {
guard pendingFocusID == nil,
let index = rows.firstIndex(where: { $0.id == id })
else { return }
let newRow = EditableRow(text: "")
pendingFocusID = newRow.id
rows.insert(newRow, at: index + 1)
}
private func focusIfPending(
_ id: UUID,
scroll: @escaping () -> Void
) {
guard pendingFocusID == id else { return }
DispatchQueue.main.async {
guard pendingFocusID == id,
rows.contains(where: { $0.id == id })
else { return }
scroll()
focusedRowID = id
pendingFocusID = nil
}
}
}
The model contains all sample data and the file defines every referenced type and function. A Swift compiler and macOS runtime were unavailable in the research environment, so this program was inspected but was not compiled or exercised here.
How insertion and focus work together
The field’s string binding updates the corresponding row as the user edits. Therefore, the submit handler can insert a new row without replacing or clearing the current entry. Apple documents continuous updates for string-bound text fields. Apple: TextField.
The insertion handler finds the submitting row by ID and inserts at index + 1. For a middle row, that places the new entry before the next existing entry. For the last row, the position equals the array’s endIndex, which is a valid insertion position and appends the element. Apple: insert(_:at:).
The new field’s appearance callback checks whether its ID matches the pending request. The dispatched block checks again before scrolling and assigning focus. These checks prevent an unrelated appearance callback from consuming the request. The example also ignores additional submissions while a focus request is pending.
The extra dispatch defers work out of the appearance callback; it is not a documented rendering barrier. Treat this scheduling choice as something to verify on your supported macOS versions and actual container hierarchy.
Scrolling and focus are separate operations. scrollTo locates a child by its view identifier and scrolls toward it; assigning focusedRowID requests keyboard focus. The explicit .id(row.id) supplies the scrolling target. Apple: scrollTo(_:anchor:).
Check the result and diagnose common failures
Use this acceptance sequence with the sample entries:
- Click
Item A, type additional text, and pressReturnonce. - Confirm that one empty field appears between the two original entries.
- Type
Item Cimmediately and confirm that it appears in the new field. - Press
Returnin the last field and confirm that a new row appears at the end. - Press
Returnin an empty field and confirm that another empty row is allowed. - Continue until scrolling is necessary, then confirm that the inserted field becomes visible and accepts typing.
These are expected results to verify locally. The example intentionally allows empty entries and preserves the entire current string, even when the insertion point is in its middle. If your task requires splitting that string into two entries, selection and text-range handling become additional requirements.
If a row appears but typing stays in the old field, check the focused ID, the new field’s appearance callback, and whether the window is active. Also check whether another callback resets focus. Avoid regenerating row IDs while computing the view.
If insertion happens twice, inspect submit handlers higher in the view hierarchy. Apple permits onSubmit on individual controls and containing views, and documents submitScope as a way to stop submission from propagating to higher handlers. Keep the insertion action owned by one handler. Apple: onSubmit(of:_:).
Changing a submit label does not implement navigation or insertion. Apple defines submitLabel as setting the semantic submission label. The model mutation and focus assignment still need their own code. Apple: submitLabel(_:).
For a much larger collection, reassess the eager stack. Apple notes that containers such as List and LazyVStack query ForEach elements lazily. An offscreen field may therefore need to be brought into view before its appearance callback can complete the focus request. Do not replace the container without testing that sequence. Apple: ForEach.
When should you use AppKit and TextKit instead?
Use the SwiftUI example for short, independent entries. When rows contain multiline text or require precise text-command behavior, a supported alternative is to embed your own NSTextView through NSViewRepresentable. Apple documents representables as the bridge for creating and updating AppKit views inside SwiftUI, with a coordinator forwarding delegate interactions. Apple: NSViewRepresentable.
For that alternative, keep the same row-ID model and implement this sequence:
- Create the text view and assign a coordinator as its delegate.
- Forward text changes into the corresponding row’s model.
- Implement
textView(_:doCommandBy:)and recognize theinsertNewline(_:)command according to your editor’s chosen behavior. - When handling that command as row insertion, preserve the current text, insert the new row, and return
trueto consume the command. Returnfalsefor commands the delegate does not handle. - Once the new text view is attached to its containing window, request focus through that window’s
makeFirstResponder(_:)method.
Apple documents both the newline command and the delegate’s Boolean contract: true means the delegate handled the command, while false lets the text view attempt it. This supplies an explicit command-handling path for an app-owned editor. Apple: insertNewline(_:), Apple: textView(_:doCommandBy:).
Preserve active text composition when adding custom command handling. AppKit exposes hasMarkedText() to report marked text; use that state when deciding whether your delegate should leave a command to the text system. Exercise composition in the resulting editor rather than assuming every Return-related interaction should insert a row. Apple: hasMarkedText().
For focus, Apple explicitly instructs callers to use the containing window’s makeFirstResponder(_:), rather than directly calling the text view’s becomeFirstResponder(). TextKit layout access also has a version-specific consequence: on macOS 12 and later, explicitly accessing a text view’s layoutManager switches it to compatibility mode using NSLayoutManager. Row insertion itself does not require that access. Apple: NSTextView.
For the single-line task, begin with the complete SwiftUI example and verify the insertion, visibility, and typing sequence in your Mac app. Stable identity, an explicit insertion position, and a targeted focus request provide the core behavior; move to an AppKit text view when the editing task needs its command and selection controls.
Sources
- onSubmit(of:_:) — Apple
- FocusState — Apple
- TextField — Apple
- TextEditor — Apple
- ForEach — Apple
- onAppear(perform:) — Apple
- insert(_:at:) — Apple
- scrollTo(_:anchor:) — Apple
- submitLabel(_:) — Apple
- NSViewRepresentable — Apple
- insertNewline(_:) — Apple
- textView(_:doCommandBy:) — Apple
- hasMarkedText() — Apple
- NSTextView — Apple