【SwiftUI】NavigationStackで画面遷移を維持する(NavigationPathをSceneStorage等で保存する)

SwiftUI

NavigationStackで画面遷移状態を保持し、アプリを起動し直してもセッションが残っていれば元の画面を表示できるようにする方法です。
NavigationPathのデータをSceneStorageに保存し、起動時にリストアします。

注意
現状のXcode14.0.1とiOS16では不具合が発生します。
Xcode14.1 beta2とiOS16.1の組み合わせで正常に動作ました。
リリース版に使う場合はiOS、Xcodeの更新を待ち、
更新後は正常に使えるかどうかをしっかり確認してください。

サンプルのNavigationStack

まずはサンプルとして用意したNavigationStackを掲載します。

enum FirstItem:Int,CaseIterable{
    case item1 = 1
    case item2
    case item3
}

enum SecondItem:String,CaseIterable{
    case item1 = "Item1"
    case item2 = "Item2"
    case item3 = "Item3"
}
struct NavigationStackView: View {
    
    @State var path = NavigationPath()
    @SceneStorage("path") var pathData:Data?
    
    var body: some View {
        NavigationStack(path:$path){
            FirstListView()
                .navigationDestination(for: FirstItem.self) { item in
                    SecondListView()
                }
                .navigationDestination(for: SecondItem.self) { item in
                    Text("ThirdView")
                }
            
        }
    }
}
struct FirstListView: View {
    var body: some View {
        List(FirstItem.allCases ,id:\.hashValue) { item in
             NavigationLink("\(item.rawValue)", value: item)
        }.navigationTitle("FirstList")
    }
}

struct SecondListView: View {
    var body: some View {
        List(SecondItem.allCases ,id:\.hashValue) { item in
            NavigationLink("\(item.rawValue)", value: item)
        }.navigationTitle("SecondList")
    }
}

単純なNavigationStackです。
データがenumなのは取り扱いが簡単で、異なる型にし易いからです。
実際は構造体を使う事が多いでしょう。

NavigationPathを保存する

保存するデータの下準備

NavigationPathを保存する場合は、取り扱うデータにCodableを実装する必要があります。

enum FirstItem:Int,CaseIterable,Codable{
    case item1 = 1
    case item2
    case item3
}

enum SecondItem:String,CaseIterable,Codable{
    case item1 = "Item1"
    case item2 = "Item2"
    case item3 = "Item3"
}

今回はenumかつrawValueがIntやStringなので宣言に加えるだけです。
複雑な構造体の場合は適宜必要な実装をしてください。

データの保存

NavigationPathのデータを保存していきます。

今回はSceneStorageを使って保存します。
永続化させたいのであればAppStoregeなどでも良いですが、
画面遷移状態の保持はSession単位で行う場合が多いのでSceneStorageを使います。

NavigationPathにはcodableメンバがあり、ここにCodableな形式でpathの中身が保持されています。
これをJSONEncoderでSceneStorageで扱えるData型にして保存します。

@State var path = NavigationPath()
@SceneStorage("path") var pathData:Data?

func save(){
    guard let representation = path.codable else { return }
    do {
        let encoder = JSONEncoder()
        let data = try encoder.encode(representation)
        pathData = data
    } catch {
        // Handle error.
    }
}

データのリストア

リストアする時はまずJSONDecoderでSceneStorageのデータをデコードします。
デコードしたデータはNavigationPathイニシャライザに渡すだけでOKです。

@State var path = NavigationPath()
@SceneStorage("path") var pathData:Data?

func load(){
    if let data = pathData {
        do {
            let representation = try JSONDecoder().decode(
                NavigationPath.CodableRepresentation.self,
                from: data)
            self.path = NavigationPath(representation)
        } catch {
            // Handle error.
        }
    } 
}

アプリのSceneに応じて呼び出す

ScenePhaseを使ってsave/loadを呼び出します。

「case .inactive:」だけだと.activeからも.backgroundからでも動作します。
scenePhaseが直前のものでクロージャのphase現在のものです。
その為、「scenePhase == .active && phase == .inactive」でactiveからのみになります。

とりあえずactiveでloadしてしまっていますが、
activeの度に毎回する処理ではないので、適宜タイミングを限定する処理を加えてください。
ただし、ViewのイニシャライザではSceneStorageは必ず初期値となる為、使用できない事に注意してください。

@Environment(\.scenePhase) var scenePhase

NavigationStack(path:$path){
    //略
}.onChange(of: scenePhase) { phase in
    switch phase {
    case .background:break
    case .inactive:
        if scenePhase == .active {
            save()
        }
    case .active:
        load()
    @unknown default:break
    }
}

最後に

NavigationStackとNavigationPathで画面遷移を維持する方法を紹介しました。
NavigationViewの頃はselectionを保持するだけだった為、比較すると多少面倒になりましたが、
NavigationStackの利便性やデータをそのまま保存出来る事を考えると、以前より良くなったように感じます。

最初にも述べたように、Xcode14.0.1とiOS16では不具合が発生します。
アップデートが配信されて実際に使用出来るのが待ち遠しいですね。

サンプルコード全体

import SwiftUI

struct NavigationStackView: View {
    
    @Environment(\.scenePhase) var scenePhase
    
    @State var path = NavigationPath()
    @SceneStorage("path") var pathData:Data?
    
    var body: some View {
        NavigationStack(path:$path){
            FirstListView()
                .navigationDestination(for: FirstItem.self) { item in
                    SecondListView()
                }
                .navigationDestination(for: SecondItem.self) { item in
                    DetailView()
                }
            
        }.onChange(of: scenePhase) { phase in
            switch phase {
            case .background:break
            case .inactive:
                if scenePhase == .active {
                    save()
                }
            case .active:
                load()
            @unknown default:break
            }
        }
    }
    
    func save(){
        guard let representation = path.codable else { return }
        do {
            let encoder = JSONEncoder()
            let data = try encoder.encode(representation)
            pathData = data
        } catch {
            // Handle error.
        }
    }
    
    func load(){
        if let data = pathData {
            do {
                let representation = try JSONDecoder().decode(
                    NavigationPath.CodableRepresentation.self,
                    from: data)
                self.path = NavigationPath(representation)
            } catch {
                // Handle error.
            }
        }
    }

}
enum FirstItem:Int,Codable,CaseIterable{
    case item1 = 1
    case item2
    case item3
}

struct FirstListView: View {
    var body: some View {
        List(FirstItem.allCases ,id:\.hashValue) { item in 
            NavigationLink("\(item.rawValue)", value: item)
        }.navigationTitle("FirstList")
    }
}

enum SecondItem:String,Codable,CaseIterable{
    case item1 = "Item1"
    case item2 = "Item2"
    case item3 = "Item3"
}

struct SecondListView: View {
    var body: some View {
        List(SecondItem.allCases ,id:\.hashValue) { item in
            NavigationLink("\(item.rawValue)", value: item)
        }.navigationTitle("SecondList")
    }
}

コメント

  1. […] […]

  2. […] […]

タイトルとURLをコピーしました