inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

SwiftUIの@FocusStateをViewと分離したい

こんにちは、スマートバンクでアプリエンジニアをしているロクネムです。

みなさんはiOSアプリを開発する上でテキストフィールド等へのフォーカスをどのように設計していますか?

例えば弊社の開発しているアプリ B/43 では、住所入力のフォームにおいてフォーカスを以下のように設計しています:

  1. 画面表示時に先頭のテキストフィールドへフォーカス
  2. キーボードの「次へ」押下時に次に入力してほしいテキストフィールドへフォーカスを移動
  3. これを繰り返す
  4. 最後のテキストフィールドのキーボードの「次へ」押下時にフォーカスを外してキーボードを隠す

このようにフォーカスを設計することで、ユーザーはシームレスな入力が可能となります。

本記事では、このフォーカスをSwiftUIで実装するにあたって扱う @FocusState をいかにしてViewと分離してテスト可能な状態に持っていくかについて、具体的なコードを示しながら説明します。

SwiftUIにおけるフォーカスの実装

先に述べた通り、SwiftUIでフォーカスを実装する場合 @FocusState のproperty wrapperを使用します。

developer.apple.com

ユーザーの姓名およびメールアドレスを入力して登録するユーザー登録画面を例に具体的なコードを示します。

// 📝 フォーカス可能なフィールドを表す
enum FocusableField: String, Hashable {
    case firstName
    case lastName
    case email
}

struct UserRegistrationScreen: View {
    @State private var firstName = ""
    @State private var lastName = ""
    @State private var email = ""
    // 📝 どのフィールドに現在フォーカスが当たっているかを表す
    @FocusState private var focusedField: FocusableField?

    var body: some View {
        Form {
            TextField(
                "First Name",
                text: $firstName
            )
            // 📝 `focusedField` の値が `firstName` と等しい場合にフォーカスが当たる
            // 📝 テキストフィールド押下時に `focusedfield` の値が `firstName` に更新される
            .focused($focusedField, equals: .firstName)

            TextField(
                "Last Name",
                text: $lastName
            )
            .focused($focusedField, equals: .lastName)

            TextField(
                "Email",
                text: $email
            )
            .focused($focusedField, equals: .email)
        }
    }
}

画面表示時にフォーカスを先頭のフィールドに当てたい場合は onAppear にて focusedField を更新します。

struct UserRegistrationScreen: View {
    ...
    var body: some View {
        Form {
            ...
        }
        .onAppear {
            focusedField = .firstName
        }
    }
}

キーボードのサブミットボタンのテキストやアクションをフィールドごとに変えたい場合、 .submitLabel および .onSubmit modifierを使用します。

struct UserRegistrationScreen: View {
    ...
    var body: some View {
        Form {
            TextField(
                "First Name",
                text: $firstName
            )
            .focused($focusedField, equals: .firstName)
            // 📝 キーボードのサブミットボタンのテキストを「次へ」に
            .submitLabel(.next)
            .onSubmit {
                // 📝 「次へ」押下時に次のフィールドである `lastName` へフォーカスを当てる
                focusedField = .lastName
            }

            ...

            TextField(
                "Email",
                text: $email
            )
            .focused($focusedField, equals: .email)
            // 📝 キーボードのサブミットボタンのテキストを「完了」に
            .submitLabel(.done)
            .onSubmit {
                // 📝 「完了」押下時にフォーカスを解除してキーボードを非表示に
                focusedField = nil
            }
        }
    }
}

以上がSwiftUIにおける基本的なフォーカスまわりの実装です。

例: バリデーション

例えば、入力されたユーザー情報をサーバーへのリクエストを持って登録する際に、バリデーションでエラーが発生したとします。

その際に良いUXを提供する上で、どのフィールドに対するバリデーションエラーであるかを表示しながら該当フィールドにフォーカスを当てられると親切でしょう。

具体的な実装は以下のようになりそうです。

struct UserRegistrationScreen: View {
    ...
    var body: some View {
        Form {
            ...
        }
        .safeAreaInset(edge: .bottom, spacing: 0) {
            // 📝 ユーザー情報の登録を行うボタン
            Button(
                action: { Task { await registerUserInformation() } },
                label: { Text("Register").frame(maxWidth: .infinity) }
            )
            .buttonStyle(.borderedProminent)
            .padding()
        }
    }
    
    private func registerUserInformation() async {
        let repository = UserRepository()
        // 📝 ユーザー情報の登録を行うメソッドを呼び出す
        let result = await repository.register(firstName: firstName, lastName: lastName, email: email)
        // 📝 Result<User, ValidationError>型を処理
        switch result {
        case .success:
            ...
        case .failure(let validationError):
            guard let validationResult = validationError.results.first else { return }
            
            // 📝 ユーザー情報の登録時にバリデーションエラーが返った際、該当フィールドへフォーカスを当てる
            focusedField = FocusableField(rawValue: validationResult.field)
            ...
        }
    }
}

Viewとロジックの分離

このバリデーション結果を反映するロジックは、関心の分離の観点でViewと分けて実装したいところです。

その上でテストまで書けるとデグレーションを防ぐことができて良いでしょう。

そこで、ViewModelにそのロジックを書くこととし、フォーカスの状態および姓名,メールアドレスの入力状態はViewからViewModelへ吸い上げることを考えます。

B/43におけるViewとロジックの分離周りの実装方針について、詳細は下記記事をご覧ください。
blog.smartbank.co.jp

コードは以下のようになります。

@MainActor
@Observable
final class UserRegistrationViewModel {
    private(set) var firstName: String = ""
    private(set) var lastName: String = ""
    private(set) var email: String = ""

    @ObservationIgnored
    @FocusState private(set) var focusedField: FocusableField?
    
    private let userRepository: UserRepository
    
    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }

    // 📝 ユーザーの入力状態を同期する
    func onFirstNameChanged(firstName: String) {
        self.firstName = firstName
    }
    func onLastNameChanged(lastName: String) {
        self.lastName = lastName
    }
    func onEmailChanged(email: String) {
        self.email = email
    }

    // 📝 キーボードのサブミットボタン押下時に呼び出される
    func onSubmit() {
        // 📝 現在の `focusedField` の状態に応じてフォーカスを切り替える
        switch focusedField {
        case .firstName: focusedField = .lastName
        case .lastName: focusedField = .email
        case .email: focusedField = nil
        case nil: break
        }
    }
    
    func onRegisterButtonTapped() async {
        let result = await userRepository.register(firstName: firstName, lastName: lastName, email: email)
        switch result {
        case .success:
            ...
        case .failure(let validationError):
            guard let validationResult = validationError.results.first else { return }
            
            // 📝 ユーザー情報の登録時にバリデーションエラーが返った際、該当フィールドへフォーカスを当てる
            focusedField = FocusableField(rawValue: validationResult.field)
            ...
        }
    }
}

struct UserRegistrationScreen: View {
    let viewModel: UserRegistrationViewModel

    var body: some View {
        UserRegistrationContent(
            firstName: viewModel.firstName,
            lastName: viewModel.lastName,
            email: viewModel.email,
            // 📝 子Viewへは `FocusState<FocusableField?>.Binding` として値を渡す
            focusedField: viewModel.$focusedField,
            onFirstNameChanged: viewModel.onFirstNameChanged,
            onLastNameChanged: viewModel.onLastNameChanged,
            onEmailChanged: viewModel.onEmailChanged,
            onSubmit: viewModel.onSubmit,
            onRegisterButtonTapped: {
                Task { await viewModel.onRegisterButtonTapped() }
            }
        )
    }
}

struct UserRegistrationContent: View {
    let firstName: String
    let lastName: String
    let email: String
    let focusedField: FocusState<FocusableField?>.Binding
    let onFirstNameChanged: (String) -> Void
    let onLastNameChanged: (String) -> Void
    let onEmailChanged: (String) -> Void
    let onSubmit: () -> Void
    let onRegisterButtonTapped: () -> Void

    var body: some View {
        Form {
            TextField(
                "First Name",
                // 📝 Binding.init(get:set:) を用いて状態を吸い上げる
                text: .init(
                    get: { firstName },
                    set: onFirstNameChanged
                )
            )
            // 📝 FocusState.Bindingには .init(get:set:) が存在しないため双方向バインディングを許容
            .focused(focusedField, equals: .firstName)
            .submitLabel(.next)
            ...
        }
        .onSubmit(onSubmit)
        .safeAreaInset(edge: .bottom, spacing: 0) {
            Button(
                action: onRegisterButtonTapped,
                label: { Text("Register").frame(maxWidth: .infinity) }
            )
            .buttonStyle(.borderedProminent)
            .padding()
        }
    }
}

しかし、上記コードを実行してみると以下のようなエラーが表示されます。

Accessing FocusState's value outside of the body of a View. This will result in a constant Binding of the initial value and will not update.

@FocusStateDynamicProperty protocolに準拠しており、Viewのbody外からのアクセスを意図していないからです。

つまり、 @FocusState をViewから分離することは不可能なのです…

落とし所

実はこのような課題感はPoint-Freeの下記記事においても語られています。

www.pointfree.co

そこでの解決策は、 「 @FocusState はView側で定義し、ViewModel側にも同等のpropertyを定義した上で双方向に値を同期する」というものでした。

具体的な実装について説明します。

まずは、ViewModel側でフォーカスの状態を管理するpropertyを用意し、View側から参照します。

 final class UserRegistrationViewModel {
     ...
-    @FocusState private(set) var focusedField: FocusableField?
+    private(set) var focusingField: FocusableField?
     ...
 }

 struct UserRegistrationScreen: View {
     let viewModel: UserRegistrationViewModel

     var body: some View {
         UserRegistrationContent(
             ...
-            focusedField: viewModel.$focusedField,
+            focusingField: viewModel.focusingField,
             ...
         )
     }
 }
 
  struct UserRegistrationContent: View {
     ...
-    let focusedField: FocusState<FocusableField?>.Binding
+    let focusingField: FocusableField?
     ...
 }

次に、View側で @FocusState を用意し、 .focused modifierに渡します。

 struct UserRegistrationContent: View {
+    @FocusState private var focusedField: FocusableField?
     ...
     var body: some View {
         Form {
             TextField(
                 ...
             )
-            .focused(focusedField, equals: .firstName)
+            .focused($focusedField, equals: .firstName)
             ...
         }
         ...
     }
 }

最後に、ViewModelとView双方で持つフォーカスのpropertyの変化を onChange メソッドで監視し、相互に更新をかけます。

 final class UserRegistrationViewModel {
     ...
+    func onFocusedFieldChanged(focusedField: FocusableField?) {
+        self.focusingField = focusedField
+    }
     ...
 }

 struct UserRegistrationScreen: View {
     let viewModel: UserRegistrationViewModel

     var body: some View {
         UserRegistrationContent(
             ...
+            onFocusedFieldChanged: viewModel.onFocusedFieldChanged
         )
     }
 }
 
  struct UserRegistrationContent: View {
     ...
+    let onFocusedFieldChanged: (FocusableField?) -> Void

     var body: some View {
         Form {
             ...
         }
+        .onChange(of: focusingField) { _, newValue in
+            focusedField = newValue
+        }
+        .onChange(of: focusedField) { _, newValue in
+            onFocusedFieldChanged(newValue)
+        }
         ...
     }
 }

これにより、フォーカスの状態をViewModel側で管理してバリデーション結果反映のロジックをViewから分離し、テスト可能にすることができました。

まとめ

SwiftUIは双方向バインディングを採用したUIフレームワークであり、View側での状態管理を前提としたAPIも多く存在しています。 @FocusState もそのうちの一つです。

しかし我々は、アプリの規模や複雑度に応じて状態を適切に扱わなければならず、時にはこのような頑張りを受け入れる必要もあるでしょう。

その文脈で、本記事が @FocusState の管理について考える上での参考になりましたら幸いです。

最後に

スマートバンクでは一緒にB/43のアプリを開発していくメンバーを募集しています! カジュアル面談も受け付けていますので、ぜひお気軽にご応募ください💪

smartbank.co.jp

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.