1行編集可能な行を持つMacアプリでは、ReturnをonSubmitで処理し、現在の行の直下に新しいモデルを挿入して、FocusStateを使ってそのフィールドにフォーカスします。各行に安定したIDを付けることで、挿入後もテキスト、行、フォーカスの対応関係を維持できます。

このチュートリアルでは、Item Aなどのサンプル項目を使った小規模なローカルリストエディタを作成します。Returnを押すと、編集中のフィールドの下に空の行が作られ、既存のテキストは保持され、新しいフィールドにキーボードフォーカスが移ります。挿入位置でテキストを分割することはありません。

この動作をサポートするテキストコントロールは?

各行が短い項目を1つ表す場合は、1行のTextFieldを使います。Appleのドキュメントでは、ユーザーがハードウェアまたはソフトウェアのReturnキーを押すと、TextFieldのonSubmitアクションが実行されると明記されています。また、FocusStateに値を割り当てると、関連付けられたビューにフォーカスが移ることも説明されています。どちらのAPIもmacOS 12以降で利用できます。Apple: onSubmit(of:_:)、Apple: FocusState。

ユーザーが編集する内容に応じてコントロールを選びます。

編集タスク 出発点 実装するReturnキーの動作
1行につき短い項目を1つ 1行のTextField 送信し、下に挿入して、新しい行にフォーカス
各行の段落 TextEditorまたはAppKitのテキストビュー 行の挿入と改行の挿入をどう区別するかを定義
独自のテキストコマンドを持つドキュメント SwiftUIに埋め込んだNSTextView 該当するテキストコマンドを処理し、モデル変更を調整

AppleはTextEditorを複数行対応のスクロール可能なエディタとして説明しています。そのドキュメントには、TextFieldについて説明されているReturnキーによる送信と同じ契約が示されていません。そのため、この例をTextEditorに変更する場合は、コマンド処理を見直す必要があります。同じ修飾子を付けるだけで、ドキュメントに記載された同等の動作になるわけではありません。Apple: TextEditor、Apple: onSubmit(of:_:)。

行の識別子と位置を分ける

3つの値はそれぞれ役割が異なります。行のIDは項目を識別し、配列インデックスは挿入位置を決め、フォーカス中のIDはキーボード入力を受け取るフィールドを指定します。

AppleのForEachは、識別可能なデータからビューを構築します。行の作成時に保存するIDを持つIdentifiableモデルを使います。この設計では、同じIDを行のフォーカス値としても使います。この対応付けにより、別の項目が上に挿入されても、その項目の識別情報を維持できます。Apple: ForEach。

編集可能なテキストをIDにするのは避けてください。空の行が2つあると同じ識別子になり、編集すると識別子が変わってしまいます。この挿入タスクでは、配列の位置も適切な識別子ではありません。位置は挿入によってずれるためです。これらは提案するデータモデルから生じる問題であり、SwiftUIが追加で課す要件ではありません。

各フィールドには固有のフォーカス値が必要です。Appleは、同じフォーカス値を複数のビューにバインドすると、どのビューにフォーカスするか曖昧になると警告しています。行ごとに保存したUUIDを使えば、各フィールドを簡単に区別できます。Apple: FocusState。

macOS向けの完全な例を作る

XcodeでSwiftUIを使ったmacOS Appプロジェクトを作り、デプロイメントターゲットをmacOS 12以降に設定します。生成されたアプリのエントリポイントとコンテンツビューを次の単一ファイルに置き換え、ターゲット内に@mainエントリポイントが1つだけ残るよう、元のアプリ宣言を削除します。

この例では、スクロールビュー内に通常のVStackを使います。新しい行を保留中のフォーカスターゲットとして記録し、そのフィールドが表示されたときにフォーカスを要求します。これはAppleのフォーカスAPIと表示APIから導いた実装方法です。Appleは、表示コールバックが常にフォーカス可能な状態を保証するとは説明していません。onAppearの正確なタイミングはビューによって異なります。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
        }
    }
}

このモデルにはサンプルデータがすべて含まれており、このファイルで参照される型と関数もすべて定義しています。調査環境ではSwiftコンパイラとmacOSランタイムを利用できなかったため、プログラムを確認しましたが、ここではコンパイルも実行もしていません。

挿入とフォーカスの連携

フィールドの文字列バインディングは、ユーザーの編集内容に応じて該当する行を更新します。そのため、送信ハンドラが新しい行を挿入しても、現在の項目を置き換えたり消去したりすることはありません。Appleは、文字列にバインドされたテキストフィールドが入力中に継続して更新されることを説明しています。Apple: TextField。

挿入ハンドラは、送信元の行をIDで検索し、index + 1の位置に挿入します。途中の行なら、新しい項目は次の既存項目の前に置かれます。最後の行では、その位置は配列のendIndexと等しくなります。これは有効な挿入位置であり、要素が末尾に追加されます。Apple: insert(_:at:)。

新しいフィールドの表示コールバックは、そのIDが保留中の要求と一致するか確認します。ディスパッチされたブロック内でも再確認してからスクロールとフォーカスの割り当てを行います。これらの確認により、関係のない表示コールバックが要求を消費するのを防ぎます。また、この例ではフォーカス要求が保留中の間、追加の送信を無視します。

追加のディスパッチは、表示コールバックの処理を後回しにするためのものであり、描画が完了したことを保証するAPIではありません。このスケジューリング方法は、サポート対象のmacOSバージョンと実際のコンテナ階層で検証してください。

スクロールとフォーカスは別々の操作です。scrollToはビュー識別子で子ビューを特定し、その方向へスクロールします。focusedRowIDの割り当てはキーボードフォーカスを要求します。明示的な.id(row.id)はスクロール対象を指定します。Apple: scrollTo(_:anchor:)。

結果を確認し、よくある問題を診断する

サンプル項目を使って、次の手順で動作を確認します。

  1. Item Aをクリックしてテキストを追加し、Returnを1回押します。
  2. 元の2つの項目の間に空のフィールドが1つ表示されることを確認します。
  3. すぐにItem Cと入力し、新しいフィールドに表示されることを確認します。
  4. 最後のフィールドでReturnを押し、末尾に新しい行が表示されることを確認します。
  5. 空のフィールドでReturnを押し、空の行をさらに作れることを確認します。
  6. スクロールが必要になるまで続け、新しく挿入したフィールドが表示され、入力できることを確認します。

これらはローカル環境で確認する想定結果です。この例では空の項目を許可し、挿入位置が文字列の途中であっても現在の文字列全体を保持します。文字列を2つの項目に分割する必要がある場合は、選択範囲とテキスト範囲の処理も必要になります。

行は表示されるのに入力が古いフィールドに残る場合は、フォーカス中のID、新しいフィールドの表示コールバック、ウィンドウがアクティブかどうかを確認してください。また、別のコールバックがフォーカスをリセットしていないかも確認します。ビューの計算中に行IDを再生成しないでください。

挿入が2回起きる場合は、ビュー階層の上位にある送信ハンドラを調べます。Appleによると、onSubmitは個々のコントロールとそれを含むビューに設定でき、submitScopeは送信が上位のハンドラへ伝播するのを止める方法です。挿入処理を担当するハンドラは1つにしてください。Apple: onSubmit(of:_:)。

送信ラベルを変更しても、移動や挿入が実装されるわけではありません。AppleはsubmitLabelを、送信の意味ラベルを設定するものと定義しています。モデルの変更とフォーカスの割り当てには、引き続き独自のコードが必要です。Apple: submitLabel(_:)。

項目数がはるかに多い場合は、全件を即時生成するスタックを見直してください。Appleは、ListやLazyVStackなどのコンテナがForEachの要素を遅延して取得すると説明しています。そのため、画面外のフィールドでは、フォーカス要求を完了するために、まず表示領域に入れる必要がある場合があります。この流れをテストせずにコンテナを置き換えないでください。Apple: ForEach。

AppKitとTextKitを使うのはどんな場合?

短く独立した項目には、SwiftUIの例を使います。行に複数行のテキストが含まれる場合や、テキストコマンドを正確に扱う必要がある場合は、NSViewRepresentableを通して独自のNSTextViewを埋め込む方法があります。Appleは、representableをSwiftUI内でAppKitビューを作成、更新するための橋渡しとして説明しており、コーディネーターがデリゲートとのやり取りを中継します。Apple: NSViewRepresentable。

この方法では、同じ行IDモデルを維持し、次の手順を実装します。

  1. テキストビューを作成し、コーディネーターをデリゲートとして設定します。
  2. テキストの変更を該当する行のモデルに反映します。
  3. textView(_:doCommandBy:)を実装し、エディタで選択した動作に応じてinsertNewline(_:)コマンドを認識します。
  4. そのコマンドを行の挿入として処理する場合は、現在のテキストを保持して新しい行を挿入し、コマンドを消費するためにtrueを返します。デリゲートが処理しないコマンドにはfalseを返します。
  5. 新しいテキストビューが親ウィンドウに接続されたら、そのウィンドウのmakeFirstResponder(_:)メソッドでフォーカスを要求します。

Appleは、改行コマンドとデリゲートのBoolean契約の両方を説明しています。trueはデリゲートがコマンドを処理したことを示し、falseはテキストビューによる処理を試みます。これにより、アプリが所有するエディタで明示的なコマンド処理経路を用意できます。Apple: insertNewline(_:)、Apple: textView(_:doCommandBy:)。

独自のコマンド処理を追加するときは、アクティブなテキスト入力変換を維持してください。AppKitは、変換中テキストの有無を報告するhasMarkedText()を公開しています。デリゲートがコマンドをテキストシステムに任せるべきか判断する際に、この状態を使います。Returnキーに関連する操作がすべて行の挿入であると決めつけず、完成したエディタで入力変換も確認してください。Apple: hasMarkedText()。

フォーカスについて、AppleはテキストビューのbecomeFirstResponder()を直接呼ぶのではなく、親ウィンドウのmakeFirstResponder(_:)を使うよう明示しています。TextKitのレイアウトへのアクセスには、バージョン固有の影響もあります。macOS 12以降では、テキストビューのlayoutManagerに明示的にアクセスすると、NSLayoutManagerを使う互換モードに切り替わります。行の挿入自体にこのアクセスは必要ありません。Apple: NSTextView。

1行編集のタスクでは、まずSwiftUIの完全な例を使い、Macアプリで挿入、表示、入力の流れを確認してください。安定した識別情報、明示的な挿入位置、対象を絞ったフォーカス要求が基本の動作を支えます。編集タスクでコマンドや選択範囲を細かく制御する必要がある場合は、AppKitのテキストビューに移行してください。

参照元