iOS26からライフサイクルの変化でSwiftUIのフォーカス状態がバグった
こんにちは、T-KAWAです。
iOS 26にアップデートした端末で突如発生した、しつこすぎるTextFieldのフォーカス状態を巡る、不可解なバグとの戦いの記録です。
フォーカス系の動きって便利だけど、制御できないとそれはそれで恐ろしい感じするんですよね。
1. 👻 問題の現象:帰宅しても電気を消さないTextField
問題が発生したのは、ごく一般的なSwiftUIの画面遷移のシーケンスでした。
【再現手順】
-
SwiftUIの
TextFieldが存在している画面Aを用意します。 - 画面Aで
@FocusState private var isFocused: Boolを定義しておいて、onAppearでisFocusedをfalseにします。 -
TextFieldをタップし、フォーカスを当ててキーボードを表示させます。 - キーボードが表示されたままの状態で、**別の画面Bへナビゲーション(遷移)**します。
- 画面Bから画面Aへ戻ります。
【iOS 18 以前(正常な挙動)】
画面Aに戻ると、TextFieldのフォーカスは自動的に解除され、キーボードは非表示になっています。(普通ですよね?)
【iOS 26 アップデート後(異常な挙動)】
画面Aに戻ると、一瞬何も起こらないかに見えますが、TextFieldがフォーカス状態で復活し、キーボードが再表示されてしまうのです。
ファーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
2. 🤯 全力で試した「効かない」おまじない
要は最初はこういうコードになっているわけです
struct ContentView: View {
@FocusState private var isFocused: Bool // フォーカス状態を管理するState
var body: some View {
VStack {
TextField("入力してください", text: .constant("テスト"))
.focused($isFocused)
// 遷移ボタンなど...
}
// 画面が再表示されたタイミングでフォーカスを確実に解除したい!
.onAppear {
print("onAppear: フォーカス解除を試みる...")
// 期待を込めて...
isFocused = false // 👈 効かない!!
}
}
}
しかし、無情にもonAppear内でisFocused = falseとしても、キーボードは画面が表示し終わった後に、再びニョキッと出てきてしまうのです。
このことから、以下の推測が立ちました。
- 画面Aが再びメモリ上に展開され、
onAppearが呼ばれる。 -
onAppear内でisFocused = falseが実行される。 - しかし、その直後、iOS 26の新しい(そして余計な)UIKit側のライフサイクル処理が走る。
- その処理が、なぜか「以前のフォーカス状態を復元」してしまい、
isFocused = trueに上書きされる。
つまり、onAppearはUIKitのライフサイクルよりも少し早く呼ばれすぎていたのです。
3. 💡 解決の鍵は「ViewControllterの視点」を借りること
SwiftUIのライフサイクル(onAppear)では間に合わないのなら、UIKitの最も確実な「画面が表示されきった」タイミング、すなわちviewDidAppearを使えばいいじゃないか!
ということで、SwiftUIの世界でUIKitのviewDidAppearをフックする定番のテクニックを導入しました。
ステップ1: ViewControllerのライフサイクルをラップする
まず、UIKitのUIViewControllerのviewDidAppearが呼ばれたことを通知するヘルパークラスを作成します。
import SwiftUI
import UIKit
// ライフサイクルをフックするための専用ViewController
struct ViewDidAppearHandler: UIViewControllerRepresentable {
let action: () -> Void
func makeUIViewController(context: Context) -> UIViewController {
let vc = UIViewController()
// viewDidAppearが呼ばれたら、指定されたactionを実行するカスタムクラスを注入
vc.view.addSubview(HostingView(action: action))
return vc
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
// UIHostingControllerを継承し、viewDidAppearをオーバーライド
private class HostingView: UIView {
let action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// ❗️ ここが肝心: ViewControllerのviewDidAppearのタイミングで実行
override func didMoveToWindow() {
super.didMoveToWindow()
// windowに移動したタイミング(≒viewDidAppear)で実行
if self.window != nil {
DispatchQueue.main.async { // 念のため非同期で
self.action()
}
}
}
}
}
💡 Note: 厳密には
UIViewControllerRepresentableを使ってviewDidAppearをオーバーライドするのが正攻法ですが、ここではSwiftUIのViewに直接modifierとして適用するためにdidMoveToWindowをフックする手法を使っています。
ステップ2: カスタムモディファイアとして適用する
次に、このロジックをSwiftUIのViewに簡単に適用できるように、View拡張を作成します。
extension View {
func onViewDidAppear(perform action: @escaping () -> Void) -> some View {
self.background(ViewDidAppearHandler(action: action))
}
}
### ステップ3: 勝利のフォーカス解除!
そして、問題の`TextField`を含むViewに対し、`onAppear`ではなく、この新しい`onViewDidAppear`を使ってフォーカスを解除します。
```swift
struct ContentView: View {
@FocusState private var isFocused: Bool
var body: some View {
VStack {
TextField("入力してください", text: .constant("テスト"))
.focused($isFocused)
NavigationLink("次へ", destination: DetailView())
}
// 💥 onAppearではなく、より遅いviewDidAppearのタイミングで解除!
.onViewDidAppear {
print("onViewDidAppear: フォーカス解除成功!")
isFocused = false // 👈 解・決!
}
}
}
結果:大成功😀
画面Aに戻った瞬間、UIKit側がフォーカスを復元しようとする直後(あるいは同じタイミング)で、私たちが仕込んだisFocused = falseが実行され、しつこいキーボードは二度と表示されなくなりました。
4. まとめ:SwiftUIとUIKitの「時間差」を埋める
今回のバグは、iOS 26でのUIKit側のフォーカス復元ロジックと、SwiftUIのonAppearの呼び出しタイミングとの「時間差」が生んだ悲劇でした。
SwiftUIだけで完結するはずの機能でも、一歩踏み込んで**UIKitのライフサイクル(viewDidAppear)**を理解し、それを応用する知識が、予期せぬバージョンアップの挙動変化からアプリを守ってくれることを痛感しました。
この知見が、同じ沼にハマった同志の皆さんの救いになれば幸いです。
Discussion