iOS16固有のTabViewとNavigationStackを併用した際に生じるアニメーションの不具合と対応方法について

決済認証システム開発事業部の冨永です。 普段の業務ではiOSアプリ・iPadアプリ開発を担当しております。

iOS 16の登場でSwiftUIには新しいNavigationStackが追加され、従来のNavigationViewに比べて、より柔軟なナビゲーション体験を提供するようになりました。

しかし、新しい技術には時に問題も伴います。今回NavigationStackを使って実装した際に、TabViewと組み合わせるとiOS 16ではアニメーションが上手く動作しないという問題が発生しました。この記事では、この現象の詳細と対処方法について掘り下げます。

目次

SwiftUIで画面遷移を実装する際には、iOS 16以前ではNavigationViewが使用されていました。 NavigationViewの問題として、次画面への遷移をフラグで制御する必要があるため、 ルート画面に戻る挙動や複数画面を跨いだ遷移を実装する際に工夫が必要でした。 しかし、iOS 16から登場したNavigationStackにより、画面遷移の履歴を保持し、スタックとして管理することができました。 これにより、複数画面を跨いだ遷移やルートの画面に戻る実装が楽になりました。

遷移先の画面情報を配列で保有することができるNavigationPathを活用することで、 子Viewから自在に画面遷移をコントロールすることが可能になりました。 具体的には、以下のようなObservableObjectを作成し、子ViewにEnvironmentObjectとして渡すことで 子View側からでも自在に画面遷移が実装できます。 以下が実装例になります。

Routerの実装

/// Routerモデル
class Router: ObservableObject {
    /// 遷移先を定義
    enum Destination {
        case child
    }

    /// 遷移先を配列で持つ
    @Published var path: [Destination] = []

    /// 画面遷移
    func push(_ destinations: [Destination]) {
        path.append(contentsOf: destinations)
    }

    /// 前画面に戻る
    func pop() {
        path.removeLast()
    }

    /// ルート画面に戻る
    func popToTop() {
        path.removeAll()
    }
}

遷移元Viewの実装

// 遷移元の画面
struct ContentView: View {
    @StateObject var router: Router = .init()
    var body: some View {
        NavigationStack(path: $router.path) {
            VStack(spacing: 10) {
                Button {
                    // Routerから画面遷移処理を行う
                    router.push([.child])
                } label: {
                    Text("Push to ChildView")
                }
            }
            .navigationDestination(for: Router.Destination.self, destination: { destination in
                // 各種画面遷移はここで定義する
                switch destination {
                case .child:
                    ChildView()
                }
            })
        }
        .environmentObject(router) // 子Viewに対してEnvironmentObjectでRouterを渡す
    }
}

子Viewの実装

struct ChildView: View {
    @EnvironmentObject private var model: Router
    var body: some View {
        VStack {
            Button {
                model.push([.child])
            } label: {
                Text("Child View")
            }
            Button {
                model.popToTop()
            } label: {
                Text("Return to Top")
            }
            Text("\(model.path.count)Views are Stacked")
        }
    }
}

動作イメージ

NavigationStackの動作イメージ

参考

Routerを活用したNavigationStackの実装方法の参考記事 blorenzop.medium.com

TabViewと併用することで、iOS16でアニメーションが効かなくなる不具合

上記のNavigationPathを活用した実装ですが、 TabViewと併用するとiOS16でアニメーションが効かなくなる不具合がありました。(iOS17以降は不具合はなし) 具体的には複数画面からルート画面に戻った後の遷移でアニメーションが効かなくなります。 *2024/2/14 時点

struct ContentView: View {
    @StateObject var router: Router = .init()
    var body: some View {
        TabView { // ⭐️ TabViewを追加
            NavigationStack(path: $router.path) {
                VStack(spacing: 10) {
                    Button {
                        router.push([.child])
                    } label: {
                        Text("Push to ChildView")
                    }
                }
                .navigationDestination(for: Router.Destination.self, destination: { destination in
                    switch destination {
                    case .child:
                        ChildView()
                    }
                })
            }
            .environmentObject(router)
        }
    }
}

再現方法

スペック:iPhone SE(3rd) iOS16.4 ※iOS17では再現しない

  1. ContentViewにて、PushToChildView押下し、ChildViewに遷移
  2. ChildViewにて、再度ChildViewボタンを押下し、ChildViewに遷移 ※2回以上繰り返す
  3. ChildeViewにて、ReturnToTopボタンを押下し、ContentViewに戻る
  4. ContentViewにてPushToChildViewを押下した際に画面遷移のアニメーションが効かない

再現動画

TabViewを追加するとアニメーションが効かなくなる不具合

参考

Developer ForumにAnimationが効かない場合の対応策があったのですが、うまくいかず、、、

forums.developer.apple.com

現時点での回避策

明示的にアニメーションを付与する

現時点での回避策として明示的にアニメーションを付与することでアニメーションが効かない不具合を解消することができました。

  1. 遷移元のrouterのpathに対してanimationModifierを付与
struct ContentView: View {
    @StateObject var router: Router = .init()
    var body: some View {
        TabView {
            NavigationStack(path: $router.path) {
                VStack(spacing: 10) {
                    Button {
                        router.push([.child])
                    } label: {
                        Text("Push to ChildView")
                    }
                }
                .navigationDestination(for: Router.Destination.self, destination: { destination in
                    switch destination {
                    case .child:
                        ChildView()
                    }
                })
            }
            // ⭐️ Add animation modifier
            // because the animation of the next screen will not work after returning to the top on iOS17 and below.
            .animation(.default, value: router.path)
            .environmentObject(router)
        }
    }
}
  1. path.removeAll()をwithAnimationで囲み、明示的にアニメーションが実行されるように修正
class Router: ObservableObject {
    enum Destination {
        case child
    }

    @Published var path: [Destination] = []

    func push(_ destinations: [Destination]) {
        path.append(contentsOf: destinations)
    }

    func pop() {
        path.removeLast()
    }

    func popToTop() {
        // Add animation modifier
        // because the animation of the next screen will not work after returning to the top on iOS17 and below.
        if #unavailable(iOS 17) {
            withAnimation(.linear(duration: 0)) {
                path.removeAll()
            }
        } else {
            path.removeAll()
        }
    }
}

修正後の動画

iOS16以下に限りNavigationPathの挙動に明示的にAnimationを付与

まとめ

本記事ではNavigationStackとTabViewを併用したい際に生じるアニメーションの不具合、その解決方法についてまとめました。

最低OSバージョンが上がるにつれて、SwiftUIベースで実装しているアプリなどはNavigationStackへの移行を検討しているかと思います。

iOS16でのアニメーションの不具合に遭遇した際の解決方法の参考となればと思います。

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。 採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。 ご興味を持っていただけましたら是非ご覧ください。

www.tecotec.co.jp