決済認証システム開発事業部の冨永です。 普段の業務ではiOSアプリ・iPadアプリ開発を担当しております。
iOS 16の登場でSwiftUIには新しいNavigationStackが追加され、従来のNavigationViewに比べて、より柔軟なナビゲーション体験を提供するようになりました。
しかし、新しい技術には時に問題も伴います。今回NavigationStackを使って実装した際に、TabViewと組み合わせるとiOS 16ではアニメーションが上手く動作しないという問題が発生しました。この記事では、この現象の詳細と対処方法について掘り下げます。
目次
- 目次
- NavigationStackとは
- NavigationPathの活用
- TabViewと併用することで、iOS16でアニメーションが効かなくなる不具合
- 現時点での回避策
- まとめ
- テコテックの採用活動について
NavigationStackとは
SwiftUIで画面遷移を実装する際には、iOS 16以前ではNavigationViewが使用されていました。 NavigationViewの問題として、次画面への遷移をフラグで制御する必要があるため、 ルート画面に戻る挙動や複数画面を跨いだ遷移を実装する際に工夫が必要でした。 しかし、iOS 16から登場したNavigationStackにより、画面遷移の履歴を保持し、スタックとして管理することができました。 これにより、複数画面を跨いだ遷移やルートの画面に戻る実装が楽になりました。
NavigationPathの活用
遷移先の画面情報を配列で保有することができる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") } } }
動作イメージ
参考
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では再現しない
- ContentViewにて、PushToChildView押下し、ChildViewに遷移
- ChildViewにて、再度ChildViewボタンを押下し、ChildViewに遷移 ※2回以上繰り返す
- ChildeViewにて、ReturnToTopボタンを押下し、ContentViewに戻る
- ContentViewにてPushToChildViewを押下した際に画面遷移のアニメーションが効かない
再現動画
参考
Developer ForumにAnimationが効かない場合の対応策があったのですが、うまくいかず、、、
現時点での回避策
明示的にアニメーションを付与する
現時点での回避策として明示的にアニメーションを付与することでアニメーションが効かない不具合を解消することができました。
- 遷移元の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) } } }
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() } } }
修正後の動画
まとめ
本記事ではNavigationStackとTabViewを併用したい際に生じるアニメーションの不具合、その解決方法についてまとめました。
最低OSバージョンが上がるにつれて、SwiftUIベースで実装しているアプリなどはNavigationStackへの移行を検討しているかと思います。
iOS16でのアニメーションの不具合に遭遇した際の解決方法の参考となればと思います。
テコテックの採用活動について
テコテックでは新卒採用、中途採用共に積極的に募集をしています。 採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。 ご興味を持っていただけましたら是非ご覧ください。