31
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

NavigationStackを一番活かす人(SwiftUI)

Last updated at Posted at 2023-07-27

こんにちは。フエルマネー開発者です。

この度コードを1から書き直すという暴挙に出た次第ですが、前回に引き続いて次に行うことはズバリ、画面遷移設計ですね。

NavigationStackとは

iOS16時点で推奨されている、画面遷移のためのViewです。
以前のNavigationViewを使っていて、なかなか移行に腰が上がらない人もいるでしょう。僕も当初はNavigationViewの機能だけで済ませようと思っていました。

でもここでついていかないと、時代に取り残されて未だにObjective-Cで開発している人の二の舞になると思ったので移行しました。

意外と楽しいので、ここで使いこなせるようになっておきましょう。

何ができるの?

NavigationStackは、pathに格納した情報をもとに、画面遷移状態すなわち現在地を把握することができます。

ContentVIew.swift
struct ContentView: View {
    @State var path = NavigationPath()
    
    var body: some View{
        NavigationStack(path: $path){
            Text("hoge")
                .navigationTitle("ホーム")
                .navigationBarTitleDisplayMode(.inline)
        }
    }
}

とりあえず、土台はこんな感じです。

.navigationBarTitleDisplayMode(.inline)は僕の好みなので、なくてもいいです。何が違うかは実際に変えて試してみてください。Navigationタイトルの見た目が変わるだけです。

NavigationStackの引数pathにBindingした値が、画面遷移状態を把握するためのStateとなります。このpathの値は、条件を満たすコレクションならなんでも(Int配列、String配列などでも)良いらしいですが、

@State var path = NavigationPath()

NavigationPathというコレクションはこれのためだけ(?)に生まれたなんでも入る配列です。
結論、これを使えば良いという話です。

基本的な画面遷移

まずは以下をご覧ください。

ContentView.swift
struct ContentView: View {
    @State var path = NavigationPath()
    
    var body: some View{
        NavigationStack(path: $path){
            Button("遷移する"){
                path.append(0)
            }
            .navigationTitle("ホーム")
            .navigationBarTitleDisplayMode(.inline)
            .navigationDestination(for: Int.self, destination: { _ in
                Text("hoge")
            })
        }
    }
}

結果
1-2.gif
以下のモディファイアにて、pathにIntがappendされた時に遷移するDestination(=View)を定義します。

.navigationDestination(for: Int.self, destination: { _ in
    Text("hoge")
})

Intであればなんでも反応します。つまりIntの中身によって遷移先の情報を変えられます。

ContentView.swift
struct ContentView: View {
    @State var path = NavigationPath()
    
    var body: some View{
        NavigationStack(path: $path){
            VStack{
                Button("hoge0に遷移する"){
                    path.append(0)
                }
                Button("hoge1に遷移する"){
                    path.append(1)
                }
                Button("hoge2に遷移する"){
                    path.append(2)
                }
                Button("hoge3に遷移する"){
                    path.append(3)
                }
                Button("hoge4に遷移する"){
                    path.append(4)
                }
            }
            .navigationTitle("ホーム")
            .navigationBarTitleDisplayMode(.inline)
            .navigationDestination(for: Int.self, destination: { appended in
                Text("hoge\(appended)")
            })
        }
    }
}

これはIntでなくても、Stringでも良いですし、オリジナルのクラスでも構いません。
ただし注意して欲しいのは、遷移先(子画面としましょう)で同様にIntをappendすると、親画面で定義した上記のNavigationDestinationが呼ばれます。複数定義するとエラーの元となるので好き勝手につけるのは危険です。
他のViewと絶対に干渉したくない方は、このView内に専用の列挙体を作ることをお勧めします。

ContentView.swift
struct ContentView: View {
    @State var path = NavigationPath()
    
+    enum InnerPath: Int{
+        case hoge0, hoge1, hoge2, hoge3, hoge4
+    }
    
    var body: some View{
        NavigationStack(path: $path){
            VStack{
                Button("hoge0に遷移する"){
+                    path.append(InnerPath.hoge0)
                }
                Button("hoge1に遷移する"){
+                    path.append(InnerPath.hoge1)
                }
                Button("hoge2に遷移する"){
+                    path.append(InnerPath.hoge2)
                }
                Button("hoge3に遷移する"){
+                    path.append(InnerPath.hoge3)
                }
                Button("hoge4に遷移する"){
+                    path.append(InnerPath.hoge4)
                }
            }
            .navigationTitle("ホーム")
            .navigationBarTitleDisplayMode(.inline)
+            .navigationDestination(for: InnerPath.self, destination: { appended in
+                Text("hoge\(appended.rawValue)")
+            })
        }
    }
}

NavigationLinkを用いた遷移

NavigationLinkは以前からありますが、馴染みのない引数「value」があるかと思います。これが先ほどpathにappendしてた値に対応します。
1-1.png
つまり先ほどの基本的な遷移では、コードベースでいつでもpathにappendすれば遷移できましたが、簡単な遷移であればNavigationLinkで事足ります。タップしたらvalueに設定した値がpathにappendされます。

ContentView.swift
struct ContentView: View {
    @State var path = NavigationPath()
    
    enum InnerPath: Int{
        case hoge0, hoge1, hoge2, hoge3, hoge4
    }
    
    var body: some View{
        NavigationStack(path: $path){
            VStack{
                NavigationLink("hoge0に遷移する", value: InnerPath.hoge0)
                NavigationLink("hoge1に遷移する", value: InnerPath.hoge1)
                NavigationLink("hoge2に遷移する", value: InnerPath.hoge2)
                NavigationLink("hoge3に遷移する", value: InnerPath.hoge3)
                NavigationLink("hoge4に遷移する", value: InnerPath.hoge4)
            }
            .navigationTitle("ホーム")
            .navigationBarTitleDisplayMode(.inline)
            .navigationDestination(for: InnerPath.self, destination: { appended in
                Text("hoge\(appended.rawValue)")
            })
        }
    }
}

一気にスッキリしましたね。
このようにNavigationStackを使うと、似たような内容の遷移を一挙に書けるのでコードがコンパクトになります。Destinationを一個ずつ書く必要はありません。

上級者編

さて、ここからが本題です。ここまでは基本的なNavigationStackの説明でしたが、ここからは応用編で、さらにコードを簡潔にするアイデアを共有します。

まずは以下をご覧ください。

HomePath
enum HomePath: Int{
    case root, setting, account, variable, fixed, premium, explain, information
    
    var toString: String{
        ["ホーム", "設定", "資産を管理", "変動収支を管理", "固定収支を管理", "プレミアム会員になる", "操作説明", "お知らせ"][self.rawValue]
    }
    
    @ViewBuilder
    func Destination() -> some View{
        switch self {
        case .root: HomeView()
        case .setting: SettingView()
        case .account: AccountView()
        case .variable: VariableView()
        case .fixed: FixedView()
        case .premium: PremiumView()
        case .explain: ExplainView()
        case .information: InformationView()
        }
    }
}

これは、フエルマネーのホーム画面以下の画面遷移設計です。

ホーム画面(ContentView)からは設定画面(SettingView)、操作説明画面(ExplainView)、お知らせ画面(InformationView)の3つに遷移できます。

設定画面(SettingView)からは、資産管理(AccountView)、変動収支管理(VariableView)、固定収支管理(FixedView)、プレミアム登録画面(PremiumView)に遷移できます。

実際の画面遷移は階層構造になっているのですが、この列挙体ではそれをガン無視して全画面を列挙しています。

これの何が良いかというと、、、

ContentView.swift
struct ContentView: View {
    @State var path = NavigationPath()
    
    var body: some View{
        NavigationStack(path: $path){
            List{
                NavigationLink(HomePath.setting.toString, value: HomePath.setting)
                NavigationLink(HomePath.explain.toString, value: HomePath.explain)
                NavigationLink(HomePath.information.toString, value: HomePath.information)
            }
            .navigationTitle(HomePath.root.toString)
            .navigationBarTitleDisplayMode(.inline)
            .navigationDestination(for: HomePath.self, destination: { appended in
                appended.Destination()
                    .navigationTitle(appended.toString)
                    .navigationBarTitleDisplayMode(.inline)
            })
        }
    }
}

子階層含め全ての画面遷移がルートのホーム画面で定義できます。しかもNavigationTitleまで対応しています。
なぜかというと、遷移先の子画面で同じクラスのpath(ここでいうHomePath)を入れても、親画面の.navigationDestinationが呼ばれるからです。

SettingViewでもさらに孫階層へ画面遷移をするわけですが、その処理はHomeView上で完結しています。なのでSettingViewでやることといえば

SettingView.swift
struct SettingView: View{
    var body: some View{
        List{
            NavigationLink(HomePath.account.toString, value: HomePath.account)
            NavigationLink(HomePath.variable.toString, value: HomePath.variable)
            NavigationLink(HomePath.fixed.toString, value: HomePath.fixed)
            NavigationLink(HomePath.premium.toString, value: HomePath.premium)
        }
    }
}

たったのこれだけなんです。

他の画面も適当に(とりあえずTextだけで)作って、実行してみましょう。

結果
1-3.gif

これだけ豊富な遷移画面を作っているのに、なんとコード行数は100行を切ります。

皆さんもSwiftUIを使いこなしてスタイリッシュな開発を楽しんでください!

一応、全体のソースを貼っときます。

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State var path = NavigationPath()
    
    var body: some View{
        NavigationStack(path: $path){
            List{
                NavigationLink(HomePath.setting.toString, value: HomePath.setting)
                NavigationLink(HomePath.explain.toString, value: HomePath.explain)
                NavigationLink(HomePath.information.toString, value: HomePath.information)
            }
            .navigationTitle(HomePath.root.toString)
            .navigationBarTitleDisplayMode(.inline)
            .navigationDestination(for: HomePath.self, destination: { appended in
                appended.Destination()
                    .navigationTitle(appended.toString)
                    .navigationBarTitleDisplayMode(.inline)
            })
        }
    }
}

struct SettingView: View{
    var body: some View{
        List{
            NavigationLink(HomePath.account.toString, value: HomePath.account)
            NavigationLink(HomePath.variable.toString, value: HomePath.variable)
            NavigationLink(HomePath.fixed.toString, value: HomePath.fixed)
            NavigationLink(HomePath.premium.toString, value: HomePath.premium)
        }
    }
}

enum HomePath: Int{
    case root, setting, account, variable, fixed, premium, explain, information
    
    var toString: String{
        ["ホーム", "設定", "資産を管理", "変動収支を管理", "固定収支を管理", "プレミアム会員になる", "操作説明", "お知らせ"][self.rawValue]
    }
    
    @ViewBuilder
    func Destination() -> some View{
        switch self {
        case .root: ContentView()
        case .setting: SettingView()
        case .account: AccountView()
        case .variable: VariableView()
        case .fixed: FixedView()
        case .premium: PremiumView()
        case .explain: ExplainView()
        case .information: InformationView()
        }
    }
}

struct AccountView: View{
    var body: some View{
        Text("AccountView")
    }
}

struct VariableView: View{
    var body: some View{
        Text("VariableView")
    }
}

struct FixedView: View{
    var body: some View{
        Text("FixedView")
    }
}

struct PremiumView: View{
    var body: some View{
        Text("PremiumView")
    }
}

struct ExplainView: View{
    var body: some View{
        Text("ExplainView")
    }
}

struct InformationView: View{
    var body: some View{
        Text("InformationView")
    }
}
31
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?