SwiftUIでネイティブに感じるフォーム検証:フォーカスとエラー
SwiftUIでネイティブに感じるフォーム検証: フォーカスを適切に扱い、適切なタイミングでインラインエラーを表示し、サーバーメッセージをユーザーに苛立たせずに示す方法。

SwiftUIで「ネイティブに感じる」検証がどうあるべきか
ネイティブに感じるiOSフォームは落ち着いています。タイピング中にユーザーと口論せず、必要なときに明確なフィードバックを与え、何が間違っているかを探し回らせません。
主要な期待は予測可能性です。同じ操作は常に同じ種類のフィードバックをもたらすべきです。フィールドが無効な場合、フォームは一貫した場所で、一貫したトーンで、明確な次のステップとともにそれを示すべきです。
ほとんどのフォームは三種類のルールを必要とします:
- フィールドルール: 単一の値が有効か(空、形式、長さ)?
- クロスフィールドルール: 値が互いに一致するか依存するか(パスワードと確認パスワード)?
- サーバールール: バックエンドは受け入れるか(メールが既に使われている、招待が必要)?
タイミングは巧妙な言い回しより重要です。良い検証は意味のある瞬間まで待ち、その時に一度だけ明確に伝えます。実用的なリズムは次のようになります:
- ユーザーが入力している間は静かにしておく。特に形式チェックでは。\n- フィールドを離れた後、またはユーザーが送信をタップした後にフィードバックを表示する。\n- エラーは修正されるまで表示し、修正されたらすぐに消す。
ユーザーがまだ答えを形成している間(メールやパスワードを入力中など)は検証は静かであるべきです。最初の文字でエラーを表示するのは、技術的に正しくてもつきまといのように感じられます。
検証はユーザーが終わったことを示したときに可視化されるべきです: フォーカスが離れたとき、または送信を試みたとき。それが彼らがガイダンスを求める瞬間であり、そのときにどのフィールドに注意が必要かを正確に示す手助けができます。
タイミングを正しくすれば、他は簡単になります。インラインメッセージは短く保て、フォーカス移動は有益に感じられ、サーバー側のエラーは罰ではなく通常のフィードバックに感じられます。
シンプルな検証ステートモデルを設定する
ネイティブに感じるフォームは、ユーザーが入力したテキストとアプリのそのテキストに対する意見をきれいに分離することから始まります。混在させると、エラーを早く表示しすぎたり、UIがリフレッシュされたときにサーバーメッセージを失ったりします。
単純なアプローチは、各フィールドに4つの要素を持たせることです: 現在の値、ユーザーがそのフィールドに触れたかどうか、ローカル(デバイス上)でのエラー、サーバーエラー(ある場合)。UIは touched と submitted に基づいて何を表示するかを決められ、毎キー入力に反応する必要がなくなります。
struct FieldState {
var value: String = ""
var touched: Bool = false
var localError: String? = nil
var serverError: String? = nil
// One source of truth for what the UI displays
func displayedError(submitted: Bool) -> String? {
guard touched || submitted else { return nil }
return localError ?? serverError
}
}
struct FormState {
var submitted: Bool = false
var email = FieldState()
var password = FieldState()
}
いくつかの小さなルールで予測可能性を保てます:
- ローカルエラーとサーバーエラーは分離しておく。ローカルルール(「必須」や「無効なメール」)が「メールは既に使われている」のようなサーバーメッセージを上書きしてはいけません。
- ユーザーがそのフィールドを再編集したときに
serverErrorをクリアして、古いメッセージを見続けることがないようにする。 touched = trueは最初の文字入力時ではなく、ユーザーがフィールドを離れたとき(またはインタラクトしようとしたと判定したとき)に設定する。
これがあれば、ビューは value に自由にバインドできます。検証は localError を更新し、API層は serverError を設定して互いに衝突しません。
ナビゲートするフォーカス処理(詰らせないための配慮)
良いSwiftUIの検証は、システムキーボードがユーザーのタスク完了を助けているように感じられるべきで、アプリが叱っているように見えてはいけません。フォーカスはその大きな要素です。
シンプルなパターンは、@FocusState を使ってフォーカスを単一の事実の源として扱うことです。フィールドごとのenumを定義し、それぞれをバインドして、キーボードのボタンをタップしたときに次に進めます。
enum Field: Hashable { case email, password, confirm }
@FocusState private var focused: Field?
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.submitLabel(.next)
.focused($focused, equals: .email)
.onSubmit { focused = .password }
SecureField("Password", text: $password)
.submitLabel(.next)
.focused($focused, equals: .password)
.onSubmit { focused = .confirm }
ネイティブに感じる決め手は節度です。フォーカスはユーザーの明確なアクション(Next、Done、主要ボタンのタップ)にのみ移動させてください。送信時には最初の無効なフィールドにフォーカスを移し(必要ならスクロールして)、入力中に値が無効だからといってフォーカスを奪わないでください。キーボードのラベルは一貫して使い分けます: 中間フィールドには Next、最終フィールドには Done。
よくある例はサインアップです。ユーザーが「Create Account」をタップします。一度検証を行い、エラーを表示してから最初に失敗したフィールド(多くの場合 Email)にフォーカスを移します。もしユーザーが Password を入力中なら、入力途中に戻してはいけません。その小さな配慮が「洗練されたiOSフォーム」と「イライラするフォーム」を分けます。
適切なタイミングで現れるインラインエラー
インラインエラーは小さなヒントのように感じられるべきで、叱責のようではいけません。ネイティブと煩わしいの最大の違いは、メッセージを表示するタイミングです。
タイミングのルール
ユーザーが入力を始めた瞬間にエラーを出すと中断になります。より良いルールは: ユーザーがそのフィールドを入力し終える猶予を与えること。
インラインエラーを表示する良い瞬間:
- フィールドのフォーカスが外れた後
- ユーザーが送信をタップした後
- タイピング中に短い間隔で止まったとき(メール形式の明らかなチェックのみ)
信頼できるアプローチは、フィールドが touched になったか送信が試みられたときだけメッセージを表示することです。新しいフォームは静かに保たれますが、ユーザーが操作すれば明確なガイドが得られます。
レイアウトとスタイル
エラーが出たときにレイアウトが跳ねるのはiOSらしくありません。メッセージのためのスペースを確保するか、表示をアニメーションして隣のフィールドが急に押し下げられないようにします。
エラー文は短く具体的に、1メッセージにつき1つの修正を示してください。「パスワードは最低8文字必要です」は実行可能ですが、「無効な入力」はそうではありません。
スタイルは控えめで一貫性を持たせます。フィールド下の小さめフォント(footnote相当)、一貫したエラーカラー、フィールドのやさしいハイライトは、濃い背景より読みやすいことが多いです。有効になったらメッセージはすぐに消します。
現実的な例: サインアップフォームでユーザーが name@ と入力している間に「Email is invalid」と表示しないでください。フィールドを離れた後、あるいは短いポーズの後に表示し、アドレスが有効になった瞬間に取り除きます。
ローカル検証のフロー: 入力中、フィールドを離れるとき、送信時
良いローカルフローには三つの速度があります: タイピング中のやさしいヒント、フィールド離脱時のより厳密なチェック、送信時の完全なルール。これが検証をネイティブに感じさせるリズムです。
入力中は軽めで静かな検証にします。「これは明らかにあり得ないか?」を考え、完璧かどうかは問わないでください。メールなら @ を含んでいるかとスペースがないかをチェックする程度。パスワードなら、入力が始まったら「8文字以上」の小さなヘルパーを見せても良いですが、最初のキー入力で赤いエラーを出すのは避けます。
ユーザーがフィールドを離れたときに、より厳しい単一フィールドのルールを走らせ、必要ならインラインエラーを表示します。ここに「必須」や「無効な形式」が入ります。同時に空白のトリミングや正規化(メールの小文字化など)を行うと、何が送信されるかユーザーに見せられます。
送信時には、クロスフィールドルールを含めてすべてを再検証します。典型例はパスワードと確認パスワードの一致です。失敗したら、修正が必要なフィールドにフォーカスを移し、そばに一つの明確なメッセージを表示します。
送信ボタンは慎重に扱ってください。ユーザーがフォームを埋めている間は有効に保ちます。タップしても何もしない状況(すでに送信中など)のときだけ無効にします。もし無効にするなら、何を直せばよいかは近くに示してください。
送信中はわかりやすいローディング状態を表示します。ボタンラベルを ProgressView に置き換え、二重タップを防ぎ、フォームを見えるままにして進行状況を理解させます。リクエストが1秒以上かかる場合は「アカウント作成中…」のような短いラベルを表示して不安を減らします。
ユーザーを苛立たせないサーバー側検証
サーバー側チェックは、ローカルのチェックが強力でも最終の真実の源です。パスワードはルールを通っても「よく使われているため弾かれる」ことがあるし、メールは既に使われているかもしれません。
最大のUX向上は「あなたの入力が受け入れられない」ことと「サーバーに到達できない」ことを分けることです。リクエストがタイムアウトしたりオフラインの場合は、フィールドを無効としてマークしないでください。「接続できません。もう一度試してください。」のような落ち着いたバナーやアラートを表示し、フォームはそのままにします。
サーバーが検証失敗を返したら、ユーザーの入力を保持し、正確なフィールドを指し示してください。フォームを消す、パスワードをクリアする、フォーカスを勝手に移すのは、試行したユーザーに罰を与えるように感じさせます。
単純なパターンは、構造化されたエラーレスポンスをフィールドエラーとフォームレベルエラーの二つに分けることです。そしてテキストのバインディングを変えずにUIステートを更新します。
struct ServerValidation: Decodable {
var fieldErrors: [String: String]
var formError: String?
}
// Map keys like "email" or "password" to your local field IDs.
ネイティブに感じられることが多い実践:
- フィールドメッセージはインラインで、可能ならサーバーの文言をそのまま使う。\n- サーバーの返答があっても、入力中に勝手にフォーカスを移さない。送信後のみ移動する。\n- サーバーが複数の問題を返す場合、各フィールドにつき最初の一つを表示して読みやすくする。\n- フィールドの詳細があるなら「何かがおかしい」だけに落とし込まない。
例: ユーザーがサインアップを送信し、サーバーが「email already in use」を返したら、入力したメールはそのまま保持して、Emailの下にメッセージを表示し、そのフィールドにフォーカスします。サーバーがダウンしている場合は単一のリトライメッセージを表示し、すべてのフィールドはそのままにします。
サーバーメッセージを適切な場所に表示する方法
サーバーエラーがランダムなバナーに表示されると「不公平」に感じられます。各メッセージは可能な限り問題を引き起こしたフィールドの近くに置いてください。本当に単一の入力に結び付けられない場合のみ、一般的なメッセージを使います。
まずサーバーのエラーペイロードをSwiftUIのフィールド識別子に変換します。バックエンドは email、password、profile.phone のようなキーを返すかもしれませんが、UIは Field.email や Field.password のenumを使っているでしょう。レスポンス直後に1回だけマッピングして、その後はビューを一貫して保ちます。
柔軟なモデリング方法は serverFieldErrors: [Field: [String]] と serverFormErrors: [String] を保持することです。通常は1つだけ表示しても配列として保持しておきます。インラインで表示する際は最も役立つメッセージを優先的に表示します。例えば「Email already in use」は「Invalid email」より有用です。
フィールドごとに複数のエラーがあるのは一般的ですが、すべて表示すると騒々しくなります。ほとんどの場合、インラインでは最初のメッセージだけを表示し、どうしても必要なら詳細ビューに残りを置きます。
フィールドに結び付かないエラー(セッション切れ、レートリミット、「後でやり直してください」など)は、ユーザーがアクションを起こす場所である送信ボタンの近くに置きます。成功時には古いエラーをクリアしてUIが「固まっている」ように見えないようにします。
最後に、ユーザーが関連フィールドを変更したときにサーバーエラーをクリアします。実装上は、email の onChange ハンドラで serverFieldErrors[.email] を削除すると、UIが「修正中だ」と即座に反映します。
アクセシビリティとトーン: ネイティブに感じさせる小さな選択
良い検証はロジックだけでなく、読みやすさやVoiceOverでの振る舞い、言語ごとの扱いにも配慮します。
色だけに頼らずエラーを読みやすくする
テキストが大きく表示される可能性を前提にしてください。Dynamic Typeに対応したスタイル(.font(.footnote) や .font(.caption) など)を使い、エラーラベルは折り返しを許可します。エラーが出たときにレイアウトが大きく跳ねないように間隔を一定に保ちます。
赤だけに頼らないでください。はっきりしたアイコンや「エラー:」のプレフィックス、あるいはその両方を追加します。これにより色覚に問題がある人にも見やすくなり、スキャンしやすくなります。
守るべきチェックリスト:
- Dynamic Typeに合わせてスケールする読みやすいテキストスタイルを使う。\n- エラーメッセージは折り返しを許可し、省略しない。\n- アイコンや「エラー:」のようなラベルを色と併用する。\n- ライトモード・ダークモードの両方でコントラストを十分に取る。
VoiceOverに正しい内容を読ませる
フィールドが無効なとき、VoiceOverはラベル、現在の値、エラーを一緒に読んでほしいです。フィールドの下に別の Text があると、文脈を外れて読まれるかスキップされる可能性があります。
役立つパターンが二つあります:
- フィールドとそのエラーを一つのアクセシビリティ要素に結合して、フィールドにフォーカスしたときにエラーもアナウンスされるようにする。\n- エラー文を含むアクセシビリティヒントやアクセシビリティの値を設定する(例: 「パスワード、必須、少なくとも8文字」)。
トーンも重要です。メッセージは明確でローカライズしやすいものにします。俗語や冗談、あいまいな「おっと」的な表現は避けます。「メールが見つかりません」や「パスワードは数字を含めてください」のような具体的な案内を優先します。
例: ローカルとサーバールールを両方持つサインアップフォーム
3つのフィールド(Email、Password、Confirm Password)を持つサインアップフォームを想像してください。目的は、ユーザーが入力している間は静かにし、先に進もうとしたときに役立つフォームです。
フォーカス順(Returnの挙動)
SwiftUI の FocusState を使うと、Returnキーの押下は自然なステップに感じられます。
- EmailのReturn: Passwordにフォーカスを移す。\n- PasswordのReturn: Confirm Passwordにフォーカスを移す。\n- Confirm PasswordのReturn: キーボードを閉じて送信を試みる。\n- 送信が失敗したら: 最初に問題のあるフィールドにフォーカスを移す。
最後のステップが重要です。Emailが無効なら、フォーカスはEmailに戻り、画面のどこかに赤いメッセージがあるだけ、という状態ではありません。
エラーが現れるとき
UIを落ち着かせる簡単なルール: フィールドが触られた(離れた)か、送信が試みられたときにメッセージを表示します。
- Email: フィールドを離れた後、または送信時に「有効なメールアドレスを入力してください」を表示。\n- Password: 離れた後、または送信時にルール(最低長など)を表示。\n- Confirm Password: 離れた後、または送信時に「パスワードが一致しません」を表示。
サーバー側を想定します。ユーザーが送信してAPIが次のようなJSONを返したとします:
{
"errors": {
"email": "That email is already in use.",
"password": "Password is too weak. Try 10+ characters."
}
}
ユーザーに見えるもの: Emailの下にサーバーメッセージ、Passwordの下にそのメッセージが表示されます。Confirm Passwordはローカルで失敗していない限り静かです。
次にすること: フォーカスは(最初のサーバーエラーである)Emailに移動します。ユーザーはEmailを変更し、ReturnでPasswordに移動し、パスワードを調整してもう一度送信します。メッセージがインラインでフォーカスが意図通りに移動するので、フォームは協力的に感じられ、叱責的ではありません。
検証が「iOSらしくない」と感じさせる一般的な落とし穴
フォームは技術的に正しくても、感じが悪ければ間違って見えます。ほとんどの「iOSらしくない」検証問題はタイミングに起因します: いつエラーを表示するか、いつフォーカスを移すか、サーバーにどう反応するか。
よくあるミスは早すぎる通知です。最初のキー入力でエラーを出すと、ユーザーは入力中に叱られているように感じます。フィールドが触られた(離れた)か送信が試みられたときまで待つと大抵解決します。
非同期のサーバーレスポンスもフローを壊します。サインアップのリクエストが返ってきて突然別のフィールドにフォーカスが移るとランダムに感じられます。ユーザーが最後にいた場所にフォーカスを保ち、送信処理をしているときだけ移動させます。
もう一つの罠は編集のたびに全てをクリアしてしまうことです。任意の文字が変わった瞬間に全エラーを消すと、特にサーバーメッセージで本当の問題を隠してしまいます。編集中のフィールドのエラーだけをクリアし、他は実際に修正されるまで残します。
「説明のない無効化」された送信ボタンも避けてください。送信を永遠に無効にすると何を直すか推測させてしまいます。無効にするなら具体的なヒントを付けるか、送信を許可して最初の問題へ誘導してください。
遅いリクエストや二重タップにも注意を。進行を表示して二重送信を防がないと、ユーザーは2回タップして二つのレスポンスを受け取り、混乱したエラーになります。
簡単なサニティチェック:
- エラーはぼかし(blur)か送信まで遅延させ、最初の文字で出さない。\n- サーバー応答の後にユーザーが要求しない限りフォーカスを移動しない。\n- フィールドごとにエラーをクリアし、一度に全ては消さない。\n- 送信がブロックされている理由を説明する(あるいはガイド付きで送信を許可する)。\n- 待機中はローディングを表示し、追加タップは無視する。
例: サーバーが「email already in use」と言った場合(例えばAppMasterを使って構築したバックエンドから)、Emailの下にメッセージを置き、Passwordは触らず、ユーザーがEmailを編集してもフォーム全体を再起動しないでください。
クイックチェックリストと次のステップ
ネイティブに感じる検証体験は主にタイミングと節度に関するものです。厳しいルールがあっても画面を落ち着かせることができます。
出荷前にこれを確認してください:
- 適切なタイミングで検証する。最初のキー入力でエラーを出さない。\n- 目的を持ってフォーカスを移す。送信時に最初の無効フィールドへジャンプし、何が悪いかを明示する。\n- 言い回しは短く具体的に。ユーザーが次に何をすべきかを示す。\n- ローディングとリトライを尊重する。送信中はボタンを無効にして、リクエストが失敗しても入力値は保持する。\n- サーバーエラーは可能ならフィールドフィードバックとして扱う。サーバーコードをフィールドにマッピングし、真にグローバルな問題だけでトップメッセージを使う。
その後は人間としてテストしてください。片手で小さな電話を持ち、親指でフォームを操作してみます。その後VoiceOverをオンにして、フォーカス順、エラーのアナウンス、ボタンラベルが意味を成すか確認してください。
デバッグとサポートのために、画面名とフィールド名と一緒にサーバー検証コード(生メッセージではなく)をログに残すと便利です。ユーザーが「サインアップできない」と言ったときに、email_taken、weak_password、あるいはネットワークタイムアウトかを素早く把握できます。
アプリ全体で一貫性を保つには、フィールドモデル(value、touched、local error、server error)、エラーの配置、フォーカスルールを標準化します。ネイティブなiOSフォームを手早く作りたいなら、手作業で全画面をコーディングする代わりにAppMaster (appmaster.io) のようなツールでSwiftUIアプリとバックエンドを生成することで、クライアントとサーバーの検証ルールを揃えやすくなります。


