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
よりも後に表示されています…🤔
このバグは古くから存在しているようで、検索するとそこそこヒットします。が、いまだに修正されていないようです。
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