🐢

@FocusStateによるフォーカスの挙動をテストする方法

に公開

こんにちは。株式会社PREVENTでiOSエンジニアをしている佐藤です。
弊社で開発しているiOS版のMystarアプリは、UIの実装をUIKitからSwiftUIへ移行中で、新規の画面なんかはSwiftUIで実装しています。
テキストフィールドのフォーカスの挙動を制御するために@FocusStateを使用しているのですが、この挙動をテストするために工夫したので工夫した内容を記事にしようと思います。

@FocusStateについてや、@FocusStateのテストを行う上での課題感を前置きとしてまず説明していきますが、その辺りはわかっていて具体的な方法だけ知りたい方はこちらから見てもらえればOKです

前置き(ちょっと長いです)

@FocusStateについて

iOSアプリでキーボードによる入力を扱う場合、インタラクションによってフォーカスする位置を変更したりするUXはよくあると思います。
例えば、画面を表示したときに、画面にあるテキストフィールドに自動でフォーカスが当てられたり、キーボードでReturnキーをタップすると、もう一つのキーボードにフォーカスが遷移したりといったことですね。

SwiftUIでのキーボードのフォーカス操作は、iOS15から使用できる@FocusStateによって簡単に実装できるようになっています。

具体的には以下のように@FocusStateを使用して、キーボードのフォーカス操作が行えます。
@FocusStateの値を変更すると、対応したテキストフィールドにフォーカスが遷移するという挙動です。直感的ですね!

struct SampleView: View {
    @State var email = ""
    @State var password = ""
    @FocusState var textFieldFocusState: TextFieldFocusState?

    enum TextFieldFocusState {
        case email
        case password
    }

    var body: some View {
        VStack {
            TextField("Email", text: $email)
                .textFieldStyle(.roundedBorder)
                .focused($textFieldFocusState, equals: .email)
                .onSubmit {
                    textFieldFocusState = .password
                }
            TextField("Password", text: $password)
                .textFieldStyle(.roundedBorder)
                .focused($textFieldFocusState, equals: .password)
        }
        .padding(.horizontal, 16)
        .onAppear {
            textFieldFocusState = .email
        }
    }
}

実際にシミュレーターでこの画面を動かしてみると、
画面表示時に、"Email"のテキストフィールドにフォーカスが当てられ、
"Email"のテキストフィールドで"Return"キーをタップすると"Password"のテキストフィールドにフォーカスが移動する動作になることが確認できます。

@FocusStateの課題

上記例のように、@FocusStateによってフォーカスする箇所を制御する方法はざっくりを伝えられたかと思います。
ただ、この挙動をテストしようと思った時に問題が発生します。
この挙動をテストするにはtextFieldFocusStateの値を変更するといったロジックをViewから切り出す必要があります。
MVVMアーキテクチャを例にすると、ViewにあるプロジックをViewModelに切り出し、ViewModelをテストすることでViewの挙動をある程度テスト可能な状態にしていると思います。

しかし、@FocusStateなプロパティはView内でしか定義できないため、ViewModelといったViewのロジックを切り出した別の型内では使えないのです。
つまり以下のようにViewModelで@FocusStateのプロパティを定義できないということです。

ObservableObjectの例
final class SampleViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published @FocusState var textFieldFocusState: SampleView.TextFieldFocusState?
}
Observationの例
@Observable
final class SampleViewModel {
    var email = ""
    var password = ""
    @FocusState var textFieldFocusState: SampleView.TextFieldFocusState?
}

そうなると、フォーカスを制御する@FocusStateのプロパティはView側で定義する都合上、フォーカスの制御に関するロジックはテストを行うことが困難になります。

というところまでが前置きで、以降でフォーカスの挙動をテストする方法を紹介します。

フォーカスの挙動をテストする

課題として、View以外に@FocusStateのプロパティを定義できないことで、ViewModel内で@FocusStateの状態が変更される挙動がテストできないということです。

View以外に@FocusStateのプロパティを定義できないという制約はどうしようもないので、以下のようにViewに定義した@FocusStateのプロパティとViewModelの状態を同期することでこの課題を回避することができます。

@Observable
final class SampleViewModel {
    var email = ""
    var password = ""
    var textFieldFocusState: SampleView.TextFieldFocusState?
    
    func onSubmit() {
        textFieldFocusState = .password
    }
    
    func onAppear() {
        textFieldFocusState = .email
    }
}

struct SampleView: View {
    @State var viewModel = SampleViewModel()
    @FocusState var textFieldFocusState: TextFieldFocusState?

    enum TextFieldFocusState {
        case email
        case password
    }

    var body: some View {
        VStack {
            TextField("Email", text: $viewModel.email)
                .textFieldStyle(.roundedBorder)
                .focused($textFieldFocusState, equals: .email)
                .onSubmit {
                    viewModel.onSubmit()
                }
            TextField("Password", text: $viewModel.password)
                .textFieldStyle(.roundedBorder)
                .focused($textFieldFocusState, equals: .password)
        }
        .padding(.horizontal, 16)
        .onAppear {
            viewModel.onAppear()
        }
        .onChange(of: textFieldFocusState) { _, newValue in
            viewModel.textFieldFocusState = newValue
        }
        .onChange(of: viewModel.textFieldFocusState) { _, newValue in
            textFieldFocusState = newValue
        }
    }
}

すると、以下のようにフォーカスの挙動をテストすることができます。

テストコード
struct SampleViewModelTest {
    @Test func onSubmit() {
        let viewModel = SampleViewModel()
        
        viewModel.onSubmit()
        
        #expect(viewModel.textFieldFocusState == .password)
    }
    
    @Test func onAppear() {
        let viewModel = SampleViewModel()
        
        viewModel.onAppear()
        
        #expect(viewModel.textFieldFocusState == .email)
    }
}

ポイントは、以下コードでView側で定義している@FocusStateのプロパティとViewModelで定義しているプロパティの値を同期しているところです。
@FocusStateのプロパティがUIの操作により変わった場合、.onChange(of: textFieldFocusState)が動作し、ViewModel側のプロパティも同じ値に更新します。
ViewModel側で更新したら同じく@FocusStateのプロパティが更新され、@FocusStateのプロパティとViewModel側の状態が常に同じになることが保証できます。

.onChange(of: textFieldFocusState) { _, newValue in
    viewModel.textFieldFocusState = newValue
}
.onChange(of: viewModel.textFieldFocusState) { _, newValue in
    textFieldFocusState = newValue
}

おまけ

@FocusStateを絡む画面で全て上記のようにViewModelと同期するコードを実装するのは冗長なので、以下のように自前のmodifierを定義しておくと便利です。

extension View {
    func syncFocusState<T: Equatable>(_ binding1: Binding<T>, with binding2: FocusState<T>.Binding) -> some View {
        self
            .onChange(of: binding1.wrappedValue) {
                binding2.wrappedValue = $0
            }
            .onChange(of: binding2.wrappedValue) {
                binding1.wrappedValue = $0
            }
    }
}

以下のようにsyncFocusStateを使用することができます。だいぶスッキリしますね!

struct SampleView: View {
    @State var viewModel = SampleViewModel()
    @FocusState var textFieldFocusState: TextFieldFocusState?

    enum TextFieldFocusState {
        case email
        case password
    }

    var body: some View {
        VStack {
            TextField("Email", text: $viewModel.email)
                .textFieldStyle(.roundedBorder)
                .focused($textFieldFocusState, equals: .email)
                .onSubmit {
                    viewModel.onSubmit()
                }
            TextField("Password", text: $viewModel.password)
                .textFieldStyle(.roundedBorder)
                .focused($textFieldFocusState, equals: .password)
        }
        .padding(.horizontal, 16)
        .onAppear {
            viewModel.onAppear()
        }
        .syncFocusState($viewModel.textFieldFocusState, with: $textFieldFocusState)
    }
}

おわり

以上が、@FocusStateによるフォーカスの挙動をテストする方法でした!
SwiftUIのAPIデザイン的に@FocusStateの挙動はテストするのに今回紹介したようなハックが必要です。
テストを行うために、SwiftUIというフレームワークと戦い、Testabilityを手に入れた代わりに複雑性が増すことにはなるとも思っています。
私の場合は、それでも実装機能の重要性からTestabilityを優先しましたが、デメリットも考慮して本当にテストが必要なのかは考えた方が良いなと感じました。
ともあれ、今回の内容がどなたかの参考になれば幸いです!

Discussion