フォーム部品のバリデーションの実装パターン

Single Source of Truth の題材としてログインフォームが例にあげられることがあります。

struct ContentView: View {
    @State var username: String = ""
    @State var password: String = ""

    var body: some View {
        VStack {
            TextField("Username", text: $username)
            TextField("Password", text: $password)

            Button("Login") {
            }
            // バリデーションが NG なら非活性
            .disabled(username.isEmpty || password.isEmpty)
        }
        .textFieldStyle(.roundedBorder)
        .padding()
    }
}


ボタンの活性状態は「ユーザ名」と「パスワード」という2つの状態が源泉(Source)となって自動的に算出されるというもので、RxSwift などの Reactive Programming でも同様の例があげられることが多いように感じます。

ViewModel などに状態やロジックを持たせたい場合は以下のように書けます。

@MainActor
final class ContentViewModel: ObservableObject {
    @Published var username: String = ""
    @Published var password: String = ""
    
    // @Published な値に依存するためこのプロパティも View から安全に参照できる
    var isValid: Bool {
        username.isEmpty == false && password.isEmpty == false
    }
    
    func submit() {
        guard isValid else { return }
        // 送信処理など...
    }
}

struct ContentView: View {
    @StateObject var viewModel = ContentViewModel()
    
    var body: some View {
        VStack {
            TextField("Username", text: $viewModel.username)
            TextField("Password", text: $viewModel.password)

            Button("Login") {
                viewModel.submit()
            }
            .disabled(viewModel.isValid == false)
        }
        .textFieldStyle(.roundedBorder)
        .padding()
    }
}

こうしたフォームの例としては以下のようなものがあり、

  • 住所登録フォーム
  • クレジットカード登録フォーム

アプリで利用される他の多くの View ほどの頻度ではないものの、再利用性(やあるいは単なる可読性)の観点からフォーム部品だけを共通の View として切り出し、バリデーションは View の外から参照したいというケースがあります。

/// 共通のログインフォーム
struct LoginForm: View {
    @State var username: String = ""
    @State var password: String = ""

    var body: some View {
        VStack {
            TextField("Username", text: $username)
            TextField("Password", text: $password)
        }
        // ログインボタンは View の利用側で定義
    }
}

こうした場合、バリデーションの結果をどうやって外に伝えるべきでしょうか。

struct ContentView: View {
    var body: some View {
        VStack {
            LoginForm()

            Button("Login") {
                //
            }
            .disabled(/* バリデーション結果を参照したい */)
        }
        .padding()
    }
}

ぱっと思いつく(というより当時の私の頭に最初に浮かんだ)のは、TextField のように Binding を使って参照する方法です。

/// 利用側
struct ContentView: View {
    // バリデーション状態を共通 View の外で持つ
    @State var isValid: Bool = false
    
    var body: some View {
        VStack {
            LoginForm(isValid: $isValid)

            Button("Login") {
            }
            .disabled(isValid == false)
        }
        .padding()
    }
}

/// 共通 View
struct LoginForm: View {
    @Binding var isValid: Bool
    
    @State var username: String = ""
    @State var password: String = ""
    
    var body: some View {
        VStack {
            TextField("Username", text: $username)
            TextField("Password", text: $password)
        }
        .textFieldStyle(.roundedBorder)
        // ユーザ名かパスワードが変更されたらバリデーション結果を更新
        .onChange(of: username, initial: true) { _, newValue in
            isValid = valid
        }
        .onChange(of: password, initial: true) { _, newValue in
            isValid = valid
        }
    }

    // バリデーション処理は共通化
    var valid: Bool {
        username.isEmpty == false && password.isEmpty == false
    }
}

これは問題なく機能(it works)しますが、コメントでも記述しているとおり、これは Single Source of Truth の原則からは離れていますし、利用側のコードはスッキリして共通部品として使いやすくはあるものの、共通部品側の内部コードはごちゃごちゃ感があって、読むのに頭のリソースが消費される感じがします。

これを解決するアイデアの1つは「ユーザ名」と「パスワード」を1つの構造体に包むことです。

/// ログインフォームの入力内容
struct LoginFormData {
    var username: String = ""
    var password: String = ""
    
    var isValid: Bool {
        username.isEmpty == false && password.isEmpty == false
    }
}

/// 利用側
struct ContentView: View {
    @State var data: LoginFormData = .init()
    
    var body: some View {
        VStack {
            LoginForm(data: $data)

            Button("Login") {
            }
            .disabled(data.isValid == false)
        }
        .padding()
    }
}

/// 共通 View
struct LoginForm: View {
    @Binding var data: LoginFormData
    
    var body: some View {
        VStack {
            TextField("Username", text: $data.username)
            TextField("Password", text: $data.password)
        }
        .textFieldStyle(.roundedBorder)
    }
}

これは Single Source of Truth の原則を守っていますし、バリデーションのロジックがデータ型にきれいに閉じて(カプセル化されて)いますし、何よりコード全体がスッキリして自明に感じられ、私はこの実装パターンを好んで使用しています。

あとがき

最近、あるプロジェクトでこの実装パターンを使用したのですが、ふと「この実装パターンをどこで学んだのだろう?」というのが頭をよぎりました。

文中でも言及しているとおり、私が最初にこの問題にあたった際は Binding による解決策を真っ先に思いついた経緯があり、同じように「これってどうやって実装するのがいいんだろう?」と疑問を持つ人が一定数いそうだと感じる一方で、このテクニックと呼ぶにはいささか大げさなこの実装パターンを WWDC や技術記事で学んだ記憶を見つけられませんでした。

おそらく同様の記事はあるでしょうし、もしかすると私が知らないだけであまりに一般的に知られたものかもしれませんが、たまには初心に帰って備忘録のような感じでブログ記事でも書いてみようかと筆をとってみた次第です。

たまに書くならこんな技術記事、というわけです。