[SwiftUI] ViewThatFits で Focus をキープする

SwiftUI2021

     

TAGS:

iOS16/macOS13 で導入された ViewThatFits を使いつつ、View の状態を擬似的に維持する方法を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Ventura 13.1 Beta 2
  • Xcode 14.1
  • iOS 16.0

ViewThatFits

iOS16/macOS13 で導入された 与えられた領域にフィットするような View を選択してくれる View です。

与えられる領域にフィットするような View を選んで表示してくれるので、便利です。

以下のコードでは、幅に応じて適切なビュー を (HStack と VStack から)選んで表示しています。

struct ContentView: View {
    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack {
                Color.yellow.frame(width: 100, height: 100)
                Color.red.frame(width: 100, height: 100)
            }
            VStack {
                Color.yellow.frame(width: 100, height: 100)
                Color.red.frame(width: 100, height: 100)
            }
        }
    }
}

ViewThatFits での困りごと

外側からみると、ViewThatFits の行ってくれていることは、レイアウトを切り替えてくれているように見えますが、実際には、レイアウトの変更ではなく、View の切り替えです。

例では、VStack と HStack を必要に応じて切り替えていますが、それぞれの内部にまったく 別の View が含まれていても問題ありません。そういう意味でも、View が切り替えられているのです。

この振る舞いは便利なのですが、困ることもあります。

その1つがフォーカスの管理です。

ViewThatFits で配置が変わる要素は、配置が変わっただけに見えますが、実際には、別の View が表示されています。
ですので、View がフォーカスを持っていた時に ViewThatFits によって View が切り替えられてしまうと、フォーカスは失われます。

例えばユーザーがフィールドに入力している途中でレイアウトが変わると、フォーカスが失われる (ユーザーは、再度、フィールドにフォーカスを移して、入力を継続する必要がある) という状態が起こってしまいます。

# 「ユーザーの入力操作等で ViewThatFits がその選択を切り替えない」のであれば問題は、なさそうです。

フォーカスが失われる例

例えば、最初の例の Color を TextField に変更すると、フォーカスが失われるケースになります。

以下はそのコードです。
# フォーカスを持っていることも画面に表示するように要素を追加しています。

struct ContentView: View {
    @State private var fieldText = "Hello"
    @FocusState var field1: Bool
    @FocusState var field2: Bool
    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack {
                Color.yellow.frame(width: 100, height: 100)
                Color.red.frame(width: 100, height: 100)
                VStack {
                    TextField(text: $fieldText, label: {Text("Field")}).focused($field1)
                    Text("Field1 has Focus \(field1.description)")
                }
            }
            VStack {
                Color.yellow.frame(width: 100, height: 100)
                Color.red.frame(width: 100, height: 100)
                VStack {
                    TextField(text: $fieldText, label: {Text("Field")}).focused($field2)
                    Text("Field2 has Focus \(field2.description)")
                }
            }
        }
    }
}

フォーカスをキープする

フォーカスをキープするためには、切り替えごとにフォーカスを制御することが必要となります。

フォーカスを管理するための FocusState の使い方は、以下の記事で説明しています。
SwiftUI2021[SwiftUI] @FocusState の使い方

フォーカスを制御するためには、以下の振る舞いを利用して実装していきます。
・ViewThatFits でビューが切り替えられた時に、該当ビューの onAppear が呼び出される
・onAppear が呼び出されたタイミングでは、その前に表示されていたビューの FocusState は保持されている

具体的には、切り替わる前の TextField がフォーカスを持っているなら、自分の TextField にフォーカスを設定するようにしています。

実装したコードは以下です。

struct ContentView: View {
    @State private var fieldText = "Hello"
    @FocusState var field1: Bool
    @FocusState var field2: Bool
    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack {
                Color.yellow.frame(width: 100, height: 100)
                Color.red.frame(width: 100, height: 100)
                VStack {
                    TextField(text: $fieldText, label: {Text("Field")}).focused($field1)
                    Text("Field1 has Focus \(field1.description)")
                }
            }
            .onAppear {
                if field2 {
                    field1 = true
                }
            }
            VStack {
                Color.yellow.frame(width: 100, height: 100)
                Color.red.frame(width: 100, height: 100)
                VStack {
                    TextField(text: $fieldText, label: {Text("Field")}).focused($field2)
                    Text("Field2 has Focus \(field2.description)")
                }
            }
            .onAppear {
                if field1 {
                    field2 = true
                }
            }
        }
    }
}

# View によっては、少し時間を遅らせて フォーカスを設定しないと動作しないかもしれません。

不明点・注意点

提示したコードでの動作は確認していますが、以下のように手探りで実装している点がありますので、気をつける必要があります。

onAppear の実行と内部状態切り替えのタイミング

onAppear の実行と、内部状態切り替えのタイミング(前後関係) は、実装依存に思えます。

例えば、HStack から VStack に切り替わるケースで、HStack で使用されていた TextField は、表示されなくなることで、FocusState が false にセットされます。
上のコードでは VStack の onAppear 実行時点では、HStack の TextField の FocusState が (まだ) true であることを利用して、VStack の TextField の Focus を設定しています。

簡単なテストコードを書いて確認したところ、onAppear 時には、直前の状態を参照できるようだったので、そのようなコードにしていますが、このタイミングが 意図したタイミングであるのかどうかは、わかりません。
(ほとんどありませんが)ドキュメントにも、記述はないようです。

「onAppear は View 表示前に実行される」と書かれているので、妥当にも思えますが、「View 表示前」と「直前の View が非表示になる」とは、同一タイミングではないことも考えられます。

ですので、SwiftUI 2023 では振る舞いが変わっているかもしれません(変わっていないかもしれませんが・・・)

まとめ

ViewThatFits は、実際はビューを切り替えるので、状態を保持するには工夫が必要

ViewThatFits で 状態を保持するには工夫が必要
  • ViewThatFits が View を切り替える時には、該当 View の onAppear が呼ばれる
  • onAppear が呼ばれた段階では 直前の View の状態を参照できる (できている as of 2022.Nov.14)

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です