こんにちは、スマートバンクでアプリエンジニアをしているロクネムです。
みなさんはiOSアプリを開発する上でテキストフィールド等へのフォーカスをどのように設計していますか?
例えば弊社の開発しているアプリ B/43 では、住所入力のフォームにおいてフォーカスを以下のように設計しています:
- 画面表示時に先頭のテキストフィールドへフォーカス
- キーボードの「次へ」押下時に次に入力してほしいテキストフィールドへフォーカスを移動
- これを繰り返す
- 最後のテキストフィールドのキーボードの「次へ」押下時にフォーカスを外してキーボードを隠す
このようにフォーカスを設計することで、ユーザーはシームレスな入力が可能となります。
本記事では、このフォーカスをSwiftUIで実装するにあたって扱う @FocusState
をいかにしてViewと分離してテスト可能な状態に持っていくかについて、具体的なコードを示しながら説明します。
SwiftUIにおけるフォーカスの実装
先に述べた通り、SwiftUIでフォーカスを実装する場合 @FocusState
のproperty wrapperを使用します。
ユーザーの姓名およびメールアドレスを入力して登録するユーザー登録画面を例に具体的なコードを示します。
// 📝 フォーカス可能なフィールドを表す 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へ吸い上げることを考えます。
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.
@FocusState
は DynamicProperty protocolに準拠しており、Viewのbody外からのアクセスを意図していないからです。
つまり、 @FocusState
をViewから分離することは不可能なのです…
落とし所
実はこのような課題感はPoint-Freeの下記記事においても語られています。
そこでの解決策は、 「 @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のアプリを開発していくメンバーを募集しています! カジュアル面談も受け付けていますので、ぜひお気軽にご応募ください💪