🥊

TabViewのonAppearバグに立ち向かう

に公開

ベースのコード

唐突ですがSwiftUIで、ログインの有無でログイン画面とタブの画面を行き来するようなアプリを作るとします。

struct AppView: View {
    @State var isLoggedIn = false

    var body: some View {
        if isLoggedIn {
            TabView {
                Tab("Home", systemImage: "house") {
                    HomeView()
                }
                Tab("MyPage", systemImage: "person") {
                    // この画面でログアウトできる
                    MyPageView(isLoggedIn: $isLoggedIn)
                }
            }
        } else {
            // この画面でログインできる
            LogInView(isLoggedIn: $isLoggedIn)
        }
    }
}
MyPageViewとLogInView
struct MyPageView: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        Button("logOut") {
            isLoggedIn = false
        }
    }
}

struct LogInView: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        Button("logIn") {
            isLoggedIn = true
        }
    }
}

ビルドすると一見うまく動いているように見えます。

onAppearのバグ

ここでそれぞれのViewのonAppearをログに出力してみると…

struct AppView: View {
    @State var isLoggedIn = false

    var body: some View {
        if isLoggedIn {
            TabView {
                Tab("Home", systemImage: "house") {
                    HomeView()
+                        .onAppear { print("Home") }
                }
                Tab("MyPage", systemImage: "person") {
                    MyPageView(isLoggedIn: $isLoggedIn)
+                        .onAppear { print("MyPage") }
                }
            }
        } else {
            LogInView(isLoggedIn: $isLoggedIn)
+                .onAppear { print("LogIn") }
        }
    }
}

// ログイン画面→ホーム→マイページ→ログイン画面 の遷移になるように操作
// LogIn
// Home
// MyPage
// LogIn
// Home 👈‼️

ログアウト時になぜかHomeのログが出力され、LogInよりも後に表示されています…🤔

このバグは古くから存在しているようで、検索するとそこそこヒットします。が、いまだに修正されていないようです。
https://developer.apple.com/forums/thread/719521
https://fatbobman.com/en/posts/traps-and-countermeasures-for-abnormal-onappear-calls-in-swiftui/

onAppearで処理を行っている場合には、アプリのバグへ直結してしまう可能性が高いです。

対応策1 TabViewを取り除かない

そもそもTabViewが消えないような構成にすればいいよね!という考えです。LogInViewの表示はTabViewに重ねることにします。

struct AppView: View {
    @State var isLoggedIn = false

    var body: some View {
        ZStack {
            TabView {
                Tab("Home", systemImage: "house") {
                    HomeView()
                }
                Tab("MyPage", systemImage: "person") {
                    MyPageView(isLoggedIn: $isLoggedIn)
                }
            }
            if !isLoggedIn {
                LogInView(isLoggedIn: $isLoggedIn)
            }
        }
    }
}

この方法ではTabViewを裏に置かなければならないため、TabViewのonAppearなどがログインしていない状態でも発火します。TabViewのライフサイクルをロジックにする場合には少し苦しい方法です。

対応策2 onAppearを一度だけ呼ぶ

このバグ実は一度表示した画面のみ再度onAppearがコールされます。ということで、一度だけ呼ばれるようなonAppearを作成することで解決を試みます。

struct OnFirstAppearModifier: ViewModifier {
    @State var isAppeared = false
    let action: () -> Void
    
    func body(content: Content) -> some View {
        content
            .onAppear {
                guard !isAppeared else { return }
                isAppeared = true
                action()
            }
    }
}

extension View {
    /// 一度だけ呼び出されるonAppear
    func onFirstAppear(perform action: @escaping () -> Void) -> some View {
        modifier(OnFirstAppearModifier(action: action))
    }
}

これで不要なonAppearは呼び出されなくなります。が、もちろんonAppearの処理も一度しか行えなくなります。

対応策3 タブの選択状態をチェックする

このバグのもう一つの特性としてバグったonAppearとTabViewの選択状態の整合性が取れていないというものがあります。これを利用して不正なonAppearの時には処理を弾くことができます。

enum TabValue: Hashable {
    case home
    case myPage
}

struct AppView: View {
    @State var selectedTab: TabValue = .home
    @State var isLoggedIn = false

    var body: some View {
        if isLoggedIn {
            TabView(selection: $selectedTab) {
                Tab("Home", systemImage: "house", value: .home) {
                    HomeView()
                        .onAppear {
                            guard selectedTab == .home else { return }
                            print("Home")
                        }
                }
                Tab("MyPage", systemImage: "person", value: .myPage) {
                    MyPageView(isLoggedIn: $isLoggedIn)
                        .onAppear {
                            guard selectedTab == .myPage else { return }
                            print("MyPage")
                        }
                }
            }
        } else {
            LogInView(isLoggedIn: $isLoggedIn)
        }
    }
}

1番無理していないのはこのやり方かなと思います。が、onAppearをHomeView・MyPageViewの内部に書く場合には何らかの方法で選択状態を伝搬する必要があるため少し面倒です。

おわり

はよ直して〜

Discussion