STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

iOS 26 で SwiftUI の ナビゲーションが壊れた - 非推奨 API が抱えていたリスク -

はじめに

こんにちは、@marcy731 です。
STORES レジ のモバイルチームのマネージャー兼iOSエンジニアをしています。
この記事は STORES Advent Calendar 2025 の 19 日目の記事です。

stores.fun

STORES レジ は、2019/9/11 にファーストコミットされ、
2021/06/15 に正式リリースされた iPad 向け POS レジアプリです。
www.st.inc

実装初期から フル SwiftUI で開発されており、当時としてはかなり挑戦的な技術選択でした。
2019 年当時の SwiftUI は、まだ登場したばかりで、

  • API も頻繁に変わる
  • ドキュメントも少ない
  • 想定通りに動かないことも多い

といった状況で、
試行錯誤しながら実装を積み上げてきた歴史があります。
(私は途中から参画したため、当時の様子は主にコミット履歴やコードから想像しています。)

当時から NavigationView を使ったナビゲーション実装を行っており、そのまま長年にわたって運用されてきました。

その後、iOS 16 で NavigationView は非推奨となりましたが、

  • 実際には問題なく動いている
  • ナビゲーションを変更すると 全画面に影響する大工事 になる

といった理由から、
対応を後回しにしてきた というのが正直なところです。

しかし、iOS 26 をきっかけに、
この判断が抱えていたリスクが一気に表面化することになります。

本記事では、

  • iOS 26 を発端として SwiftUI のナビゲーションが壊れた経緯
  • 非推奨 API を使い続けたことで何が起きたのか
  • そして最終的にどのような設計判断に至ったのか

についてまとめます。

当時のナビゲーション実装

STORES レジ の初期実装では、
SwiftUI 標準の NavigationView を使ったナビゲーションを採用していました。

2019 年当時の SwiftUI では、選択肢自体がほとんどなく、

  • NavigationView
  • NavigationLink

を組み合わせて画面遷移を実装するのが、ほぼ唯一の方法でした。

プログラム的に画面遷移を制御するため、
NavigationLink(isActive:) を使った実装も早い段階から取り入れていました。

実際のコードを簡略化すると、以下のような構成です。

struct ContentView: View {
    @State private var isActive = false

    var body: some View {
        NavigationView {
            VStack {
                Button("Go to Detail") {
                    isActive = true
                }

                NavigationLink(
                    destination: DetailView(),
                    isActive: $isActive
                ) {
                    EmptyView()
                }
            }
            .navigationTitle("Home")
        }
    }
}

この実装には、当時としては十分に納得できる理由がありました。

  • 状態(Bool)で遷移を制御できる
  • ViewModel と組み合わせやすい
  • 条件付き遷移や分岐が書きやすい

実際、この構成は 長い間、特に問題なく動作 していました。

非推奨になっても残り続けた理由

iOS 16 で NavigationView が非推奨になり、
NavigationStack / NavigationSplitView への移行が推奨されるようになりました。

しかし当時は、

  • 既存の画面数が多い
  • ナビゲーションは全画面に影響する
  • 「今すぐ困っているわけではない」

といった理由から、
優先度を下げざるを得なかった というのが実情でした。

結果として、

非推奨ではあるが、動いている
いずれ時間を取って対応しよう

という判断を積み重ねることになりました。

iOS 26 で起きた変化

この状態が大きく変わったのが、iOS 26 への対応でした。

検証を進める中で、
これまで問題なく動いていたナビゲーションに、明らかな不具合が発生しました。

具体的には、

  • ボタンを押しても遷移しない
  • 遷移直後に元の画面に戻る
  • 一度戻ると再度遷移できない

といった挙動が、再現性をもって発生するようになりました。

ここで初めて、非推奨 API を使い続けたことによる歪み が、表面化するに至りました。

まず試みた移行:NavigationStack への置き換え

最初に行ったのは、
既存の実装を大きく崩さずに NavigationStack へ置き換えることでした。

SwiftUI のナビゲーションはアプリ全体に横断的に影響します。
そのため、理想を言えば NavigationPath を用いたルーティング設計まで踏み込んで整理したかった一方で、
iOS 26 対応という時間制約のある状況では、まず 「影響範囲を最小にして非推奨 API を脱出する」 ことを優先しました。

当時の実装は NavigationView + NavigationLink(isActive:) のように、
Bool 状態をトリガーにしたプログラム遷移 が広範囲に存在していました。

この構造のまま NavigationStack に移行する場合、最小の差分で置き換えられるのが
.navigationDestination(isPresented:) を使うパターンでした。

developer.apple.com

struct ContentView: View {
    @State private var isPresented = false

    var body: some View {
        NavigationStack {
            VStack {
                Button("Go to Detail") {
                    isPresented = true
                }
            }
            .navigationDestination(isPresented: $isPresented) {
                DetailView()
            }
        }
    }
}

この段階で、

  • iOS 26 で発生していた遷移不具合は再現しなくなった

という意味では、一定の改善が見られました。

つまりこの時点では、
問題の中心は NavigationView(および isActive を前提とした遷移)側にある可能性が高い と判断できました。

しかし、別の問題が並行して発生します。

dismiss と組み合わせた際の問題

NavigationStack への置き換え後、 次に直面したのが .navigationDestination(isPresented:)@Environment(\.dismiss) を組み合わせた際に、画面遷移しようとするとフリーズする問題 でした。

struct DetailView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Close") {
            dismiss()
        }
    }
}

この問題は、

  • iOS 26 では発生しない
  • iOS 16.4 以降の環境(特に iOS 17 / 18)で発生

という点で、非常に厄介でした。

developer.apple.com

  • dismiss → 再表示 → dismiss
  • CPU 使用率が上がり、最終的にアプリが固まる

といった挙動が確認され、
最終的には.navigationDestination(isPresented:)dismiss に依存する設計自体を見直す ことにしました。

最終的に採用した設計

冒頭で述べた通り当初は影響範囲を抑えた移行を想定していましたが、
結局最終的に採用したのは、NavigationPath + navigationDestination(for:) を用いたルーティング設計、
つまり、 遷移状態を Bool ではなく「値」として管理する設計でした。
これは現在の SwiftUI が推奨しているナビゲーションの考え方に沿った設計 でもあります。

developer.apple.com

enum Route: Hashable {
    case detail
}

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Button("Go to Detail") {
                    path.append(Route.detail)
                }
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .detail:
                    DetailView()
                }
            }
        }
    }
}

この構成では、

  • NavigationPath がナビゲーション状態の SSoT になっている
  • push / pop を明示的に制御できる
  • dismiss に依存しない

という形になります。

結果として、

  • iOS 17
  • iOS 18
  • iOS 26

いずれの環境でも、
画面遷移は安定して動作するようになりました。

なぜ Bool ベースの遷移をやめたのか

isActiveisPresented を使った遷移は、

  • 実装がシンプル
  • 小規模な画面構成では分かりやすい

というメリットがあります。

一方で、

  • 遷移の文脈が Bool に閉じる
  • 現在どの画面にいるのかが構造として表現されない
  • SwiftUI 内部の状態管理と衝突しやすい

といった問題も抱えています。
とくに、遷移の条件や履歴が増えてくると、
Bool だけでは状態の整合性を保つのが難しくなります。

非推奨 API が抱えていたリスク

NavigationView は、iOS 16 で非推奨になりました。

ただし非推奨は、

  • 即座に壊れる
  • すぐに使えなくなる

という意味ではありません。

今回の件で強く感じたのは、

  • 非推奨 API は「静かに壊れる」
  • OS の進化に伴って、ある日まとめて破綻する

という点でした。

「今は動いているから大丈夫」という判断は、
将来の変更コストを先送りしているだけ だったと感じています。

まとめ

今回の対応を振り返ると、

  • 非推奨 API を使い続けた歪みが、iOS 26 をきっかけに表面化した

という流れでした。
非推奨になった API は、

  • 今すぐ困っていなくても
  • 動いているように見えても

早めに対応するのが、結果的に最もコストが低い
ということを、今回あらためて学びました。

結果的には、

  • NavigationStack + NavigationPath
  • 値ベースの遷移管理

へ移行できたことで、
今後の OS アップデートに対しても耐性のある設計になったと感じています。

本記事が、

  • SwiftUI のナビゲーション設計を見直すきっかけ
  • 非推奨 API とどう向き合うべきかを考える材料

になれば幸いです。

さいごに

STORES レジ ではアプリを開発・運用するにあたって、品質の維持と向上への取り組みを行っています。
少しでも面白そうと感じていただけましたら、ぜひカジュアルに連絡をいただけますと泣いて喜びます。

また STORES では STORES レジ 以外のプロダクトでもエンジニアを絶賛募集中です。
ぜひ採用サイトにも遊びに来てください。

jobs.st.inc