数値フィールドのオプショナル値がnilになった後も編集できるようにするには、編集可能なテキストを数値とは別に保持します。まずパース、無効化状態、フォーカスを確認し、アプリで明示的な復旧動作が必要な場合は、文字列ベースのドラフトを使います。

このガイドでは、開発中のmacOSアプリ内の数値フィールドで、合成値Tile countを入力し、消去してから別の数値を入力します。目的は、意味のある空値を保ちながら、次の編集を可能にすることです。

オプショナルの数値がnilになったのはなぜですか?

Appleは、オプショナル値専用のSwiftUIイニシャライザTextField(value:format:prompt:label:)を提供しています。ドキュメントに記載された動作では、フォーマットスタイルで入力をパースできない場合、バインディングにnilが設定されます。したがって、変換の失敗はこのAPIで想定されている状態です。Appleは、この状態によってフィールドが無効になるとは説明していません。このオプショナル値向けのフォーマットスタイルイニシャライザには、macOS 12以降が必要です。Appleのオプショナル値向けTextFieldイニシャライザ

アプリが実際にどのイニシャライザを使っているか確認してください。古いformatter:イニシャライザでは、ドキュメントに記載された失敗時の動作が異なります。変換に失敗すると、バインドされた値は変更されません。formatter:をformat:に置き換えると、その契約が変わるため、汎用的な修正ではなく意図的な動作変更として扱ってください。AppleのフォーマッターベースのTextFieldイニシャライザ

根本的な設計上の問題は、オプショナルの数値ではあらゆる編集中の状態を表現できないことです。空のフィールド、入力途中のマイナス記号、無効な文字列はいずれも有効な整数を持たないことがありますが、テキストのドラフトとしてはそれぞれ異なります。そこで別のドラフトが役立ちます。アプリが数値をいつ確定するか判断する間、フィールドにはユーザーが編集中の内容をそのまま保持できます。

フィールドを変更する前に問題を診断する

自分のアプリのウィンドウで、次の手順を実行してください。特定のmacOS実装の不具合を前提とせず、変換の問題と操作上の問題を切り分けられます。

  1. テキストが実際に変化するか確認します。 フィールドをクリックし、内容を選択して24と入力します。文字が変わるのに数値モデルが変わらない場合は、変換と確定処理を調べてください。
  2. 操作可能な状態か確認します。 フィールドとそのコンテナに.disabled(tileCount == nil)のような条件がないか検索します。この条件があると、空値から復旧するために必要な編集自体ができません。
  3. フィールドがまだ存在するか確認します。 if let分岐の中だけでエディタを生成している場合、値をnilにするとエディタが取り除かれることがあります。ドラフトとエディタは、オプショナルの数値とは独立して保持してください。
  4. フォーカスは別に確認します。 フィールドをクリックでき、その後の入力が機能するなら、適切な公開APIでフォーカスを戻してください。
  5. 例を簡略化します。 カスタムバインディング変換とモデル同期を一時的に取り除きます。小さなビューで12から空値、さらに24へ変わる動作を再現してください。

無効化された祖先ビューも影響します。Appleのドキュメントでは、コンテナの.disabled(true)は子ビューの.disabled(false)より優先されます。フィールドだけを繰り返し有効にするのではなく、祖先ビューの条件を修正してください。Appleのdisabled(_:)のドキュメント

カスタムバインディングのセッターも確認してください。既存の数値がnilのときに常に早期リターンするセッターでは、新しい数値を受け付けられません。同様に、変換失敗をすべて0に対応付けると、値がない状態と実際のゼロの区別が失われます。これらはアプリのロジックに関する判断であり、フォーカスを変更しても修正できません。

編集可能なドラフトを保持し、数値を明示的に確定する

文字列バインディングのフィールドを使うと、現在のテキスト、確定済みのオプショナル数値、検証メッセージという3つの状態を制御できます。Appleのドキュメントによると、文字列にバインドされたTextFieldは編集中にバインディングを継続的に更新します。そのため、入力途中の内容を保持するのに適しています。AppleのTextField概要

以下の完全なサンプルでは、次の方針を使います。空の入力はnilを意味し、有効な10進整数なら確定済みの数値を置き換えます。無効な入力は表示に残し、確定済みの数値は変更しません。Clearボタンは両方の状態を空にし、次の数値を入力できるようフォーカスを要求します。

XcodeでmacOS SwiftUI Appプロジェクトを作成し、デプロイメントターゲットをmacOS 12以降に設定します。生成されたアプリとコンテンツビューの宣言をこの単一ファイルに置き換え、ターゲット内の@main宣言は1つだけ残してください。このサンプルでは、独自のウィンドウと合成ラベルのみを使います。調査環境ではSwiftとmacOS SDKが利用できなかったため、このプログラムは確認しましたが、コンパイルも実行もしていません。

import SwiftUI

@main
struct NumericEditingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            NumericEditingView()
        }
    }
}

struct NumericEditingView: View {
    @State private var tileCount: Int? = 12
    @State private var draft = "12"
    @State private var message: String? = nil
    @FocusState private var fieldIsFocused: Bool

    private var acceptedDescription: String {
        if let value = tileCount {
            return String(value)
        }
        return "None"
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Tile count", text: $draft)
                .textFieldStyle(.roundedBorder)
                .focused($fieldIsFocused)
                .onSubmit {
                    applyDraft()
                }

            HStack {
                Button("Apply") {
                    applyDraft()
                }

                Button("Clear") {
                    tileCount = nil
                    draft = ""
                    message = nil
                    fieldIsFocused = true
                }

                Button("Edit") {
                    fieldIsFocused = true
                }
            }

            Text("Accepted count: \(acceptedDescription)")

            if let message = message {
                Text(message)
                    .foregroundColor(.secondary)
            }
        }
        .padding(20)
        .frame(width: 380)
    }

    private func applyDraft() {
        if draft.isEmpty {
            tileCount = nil
            message = nil
            return
        }

        guard let parsed = Int(draft, radix: 10) else {
            message = "Enter a whole number without spaces, or clear the field."
            fieldIsFocused = true
            return
        }

        tileCount = parsed
        draft = String(parsed)
        message = nil
    }
}

フィールドは常にdraftにバインドされるため、tileCountをnilに設定しても、編集可能なテキスト状態は取り除かれません。変換は、ユーザーがApplyをクリックするかReturnキーを押したときだけ行われます。無効な内容を送信した後、メッセージは次の送信またはClearまで残り、ドラフトは修正できる状態で保持されます。

guardによって、変換に失敗しても確定済みの数値は置き換わりません。送信が成功すると、String(parsed)によって表記が正規化されます。たとえば、+024を入力すると、結果は24になります。これはサンプルで明示的に定めた方針です。送信時の表記を保持したい場合は、この正規化の代入を削除してください。

数値の文法を意図的に選ぶ

この例では、符号付きで桁区切りのない10進整数を受け付けます。空白、小数、桁区切り記号は拒否します。Swiftのドキュメントによると、Int(_:radix:)は、無効な入力または整数型で表現できない値に対してnilを返すため、範囲を超える数値を貼り付けた場合も検証処理に進みます。SwiftのInt文字列イニシャライザ

ローカライズされた数値表記が必要なフィールドでも、同じドラフトと確定の構成を保ちつつ、変換を適切なFoundationのパース戦略に置き換えます。ParseableFormatStyleはparseStrategyを公開し、戦略のparse(_:)メソッドが変換を実行して、エラーをスローすることがあります。ドラフトを保持したまま失敗を処理してください。書式設定とパースの規則を一致させ、想定するロケールでテストしてください。AppleのParseableFormatStyleのドキュメント、AppleのParseStrategyのドキュメント

アプリの別の部分から確定済みの数値を置き換えられる場合、その更新が編集中のドラフトにどう影響するかを決めてください。たとえば、明示的なLoad value操作で両方の状態を更新できます。編集中に確定済みの数値からドラフトを絶えず再生成しないでください。そうすると、この設計で保持している入力途中のテキストが失われます。

SwiftUIまたはAppKitでフォーカスを戻す

SwiftUIでは、サンプルのようにエディタに.focused($fieldIsFocused)を付け、ローカルなアクションからfieldIsFocused = trueを設定します。Appleのドキュメントによると、フォーカス状態の値を割り当てると、関連付けられたビューにフォーカスが移ります。別々のフィールドには異なるフォーカスバインディングを割り当ててください。複数のフィールドに同じフォーカス値を割り当てると、曖昧さが生じます。AppleのFocusStateのドキュメント

フォーカスが制御するのは、編集の入力先です。パーサーを変更したり、無効化された祖先ビューを再び有効にしたり、条件付きビューのロジックで取り除かれたエディタを再生成したりはしません。フォーカスを戻す処理を追加する前に、これらの条件を解決してください。

アプリが所有するAppKitのフィールドでは、NSTextField.isEditableを確認してください。Appleによると、このプロパティはユーザーがフィールドの値を編集できるかどうかを制御します。自分のコードで誤ってfalseにした場合は、意図したアプリの制限を維持しながらtrueに戻してください。AppleのNSTextField.isEditableのドキュメント

AppKitのフォーカスを明示的に移動するときは、acceptsFirstResponderを確認し、所有するウィンドウのmakeFirstResponder(_:)を呼び出します。結果の扱いには注意が必要です。falseは現在のレスポンダーがフォーカスを譲ることを拒否した場合を意味することがあり、trueも、候補のレスポンダーが拒否してウィンドウがファーストレスポンダーになった場合に返されることがあります。実際に編集できる状態か確認してください。フィールド全体を選択して置き換えるには、selectText(_:)がドキュメントに記載された操作です。フィールドがウィンドウのビュー階層に属する前に呼び出しても効果はありません。AppleのmakeFirstResponder(_:)のドキュメント、AppleのselectText(_:)のドキュメント

フィールドエディタのライフサイクルに従う

AppKitのcurrentEditor()は、コントロールの現在のフィールドエディタを返し、存在しない場合はnilを返します。Appleによると、このエディタはウィンドウ内のコントロール間で共有され、必要に応じて作成されるNSTextViewです。そのため、アクティブな編集セッション外でnilのエディタがあっても、それだけで数値入力が壊れているとは言えません。AppleのcurrentEditor()のドキュメント

テキストシステムのレベルでは、フィールドエディタはTab、Shift-Tab、Returnを、編集終了とファーストレスポンダー変更の可能性を示す合図として扱います。数値変換の修正としてisFieldEditorを変更しないでください。このプロパティが制御するのは編集動作であり、オプショナル数値のパースではありません。これらのAppKitとTextKitの確認は、自分のアプリが所有するコントロールに限って行ってください。AppleのNSTextView.isFieldEditorのドキュメント

消去と再入力の両方ができることを確認する

対応するmacOSバージョンごとに、この完全なサンプルで次の手動手順を実行してください。これはサンプルのロジックから導かれる想定結果であり、テストを実行したという報告ではありません。

操作 想定される確定値 想定される編集動作
起動 12 ドラフトに12が入っている
Clearをクリック nil。表示はNone ドラフトが空になり、フォーカスが要求される
24を入力してApplyをクリック 24 ドラフトに24が入っている
-に置き換えてReturnを押す 以前の確定値 ドラフトは-のままで、メッセージが表示される
-8に置き換えてReturnを押す -8 メッセージが消える
0を入力してApplyをクリック 0 ゼロは値がない状態と区別される
2.5を入力してApplyをクリック 以前の確定値 小数のテキストは修正できる状態で残る
フィールドを空にしてApplyをクリック nil フィールドは次の編集に使える状態を保つ

Int.maxより大きい整数の貼り付け、フィールドからフォーカスを外して戻る操作、フィールドの繰り返し消去もテストしてください。統合済みのアプリでは、別の場所でモデルを変更する操作も含めてください。これらの確認では、変換失敗、フォーカスの復旧、ドラフトの同期をそれぞれ個別に検証します。

意味のある復旧条件は具体的です。確定済みの数値がnilになった後も、エディタが存在して操作でき、後から有効な値を送信すると新しい数値が設定されます。ドラフトを独立して保持すれば、SwiftUIの内部コントロールに関する未記載の変更に依存せず、この動作を明示できます。

参考資料