18
12

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.

SwiftUI の NavigationLink(destination:,isActive:) を活用する[追記あり]

Last updated at Posted at 2022-01-10

はじめに

SwiftUI において画面遷移を行うための簡単な方法は NavigationViewNavigationLink(destination:) を使うものです。
なんらかの条件を満たしたときに強制的に画面遷移を行うなど、より細かい制御を行いたい場合は、NavigationLink(destination:, isActive:) を使います。

オリジナルアプリを個人開発している中で、この NavigationLink(destination:, isActive:) の使い方に関してかなりハマった経験があったので、備忘録がわりに記事としました。

追記(2023年8月11日)

NavigationViewNavigationLink(destination:, isActive:)は、iOS16から deprecated となりました。

NavigationStack(path:)NavigationLink(value:)、そして.navigationDestination(for:)モディファイアを使いましょう。
「隠れNavigationLink」というトリッキーな技を使う必要はなくなります。

余裕があれば、別途記事にしたいところですが...
`NavigationStackについて記事を書きました。「SwiftUI の NavigationStack を活用する」をご覧ください。(2023年8月21日)

開発環境等

  • MacBook Air (M1 2020)
  • macOS Monterey Version 12.1
  • Xcode Version 13.2.1 (13C100)
  • iOS 15.0

基本的な使い方

NavigationLink(destination: ,isActive:) の典型的な使い方は次のとおりです。

ContentView.swift
    struct ContentView: View {
        @State private var toFirstView = false // このフラグで画面遷移を制御
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(),
                                   isActive: $toFirstView) {
                        EmptyView()
                    }
                    
                    Button(action: { // このボタンをタップすると FirstView に遷移する。
                        toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
        }
    }

NavigationLink の label: には EmptyView() を入れて隠しておきます。
Button をタップして toFirstViewtrue にすると、NavigationLink に設定された画面遷移が行われます。

ここで1点補足しておきます。

上記では、toFirstViewNavigationLink による画面遷移を引き起こすスイッチのように使っていますが、逆に画面遷移が生じた場合、ToFirstViewtrueになります。
この部分は Toggle(isOn:) の挙動と似ています。
SwiftUI らしい機構だと思いますが、「isActive: フラグを false にしているのに画面遷移し、フラグが true になってしまう。なぜ?」という質問を見かけたりもしたので、念の為書き記しておきます。

画面遷移先から戻る

1階層戻る場合

NavigationView では、前の画面に戻るための "<Back" ボタンが自動的に表示されますが、これに依らずに前の画面に戻ることもできます。
遷移先の画面で isActive: でセットしたフラグを false にすると画面遷移が強制的にキャンセルされて、前の画面に戻ります。

ContentView.swift
    struct ContentView: View {
        @State private var toFirstView = false
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(isActive: $toFirstView),
                                   isActive: $toFirstView) {
                    }
                    
                    Button(action: {
                        toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
        }
    }

    struct FirstView: View {
        @Binding var isActive: Bool
        
        var body: some View {
            VStack {
                Button(action: { // このボタンをタップすると前画面に戻る。
                    isActive = false
                }) {
                    Text("Back To the ROOT")
                }
            }.navigationTitle("First View")
        }
    }

2階層以上戻る場合

上記方法は、多段階の画面遷移を行った後に2階層以上前の画面にジャンプする場合も有効です。
なんらかの方法で isActive: でセットしたフラグを持ち回して、false にすればよいのです。

上記のように引数で持ち回してもよいですが、コードが分かりにくくなるので、下記のように EnvironmentObject を使う方がスマートかと思います。

まずはこんな感じで EnvironmentObject を定義しておきます。

NavigationFlags.swift
    class NavigationFlags: ObservableObject {
        @Published public var toFirstView = false
    }

ROOTに近いViewでそのインスタンスを生成します。App生成時に生成するならこんな感じです。

HogeApp.swift
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(AppSettings())

        }
    }

その上で必要な View で呼び出します(下記サンプルコードでは、SecondView で呼び出してます)。

ContentView.swift
    struct ContentView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(),
                                   isActive: $navigationFlags.toFirstView) {
                    }
                    
                    Button(action: {
                        navigationFlags.toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
        }
    }


    struct FirstView: View {
        var body: some View {
            VStack {
                NavigationLink(destination: SecondView()) {
                    Text("Go To the SecondView")
                }
            }.navigationTitle("First View")
        }
    }


    struct SecondView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            VStack {
                Button(action: {
                    navigationFlags.toFirstView = false // 一気に ROOT に戻る。
                }) {
                    Text("Back To the ROOT")
                }
            }
            .navigationTitle("Second View")
        }
    }

戻りたい画面が複数ある場合

戻りたい画面が複数ある場合は、戻りたい画面遷移ごとに異なるフラグをセットすれば大丈夫です。

ここで重要なのが、NavigationView に、.navigationViewStyle(.stack) というモディファイアをセットすることです。
.stackというのは、「順番に画面遷移を積み重ねる」みたいな意味です。
これを設定していないとまったく想定外の画面遷移をします。
私がハマって2週間ぐらい悩みに悩んでしまったところですので、本稿で最も重要なポイントです!

NavigationFlags.swift
    class NavigationFlags: ObservableObject {
        @Published public var toFirstView = false
        @Published public var toSecondView = false
    }

インスタンス作成は上記と同じなので割愛。
必要な View ごとに下記のようにBool値をセットします。

ContentView.swift
    struct ContentView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            NavigationView {
                VStack {
                    NavigationLink(destination: FirstView(),
                                   isActive: $navigationFlags.toFirstView) {
                    }
                    
                    Button(action: {
                        navigationFlags.toFirstView = true
                    }) {
                        Text("Go To the FirstView")
                    }
                }.navigationTitle("ROOT")
            }
            .navigationViewStyle(.stack) // 重要!!
        }
    }


    struct FirstView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags

        var body: some View {
            VStack {
                NavigationLink(destination: SecondView(),
                               isActive: $navigationFlags.toSecondView) {
                    Text("Go To the SecondView")
                }
            }.navigationTitle("First View")
        }
    }


    struct SecondView: View {
        @EnvironmentObject var navigationFlags: NavigationFlags
        
        var body: some View {
            VStack {
                Button(action: {
                    navigationFlags.toFirstView = false // ROOT に戻る。
                }) {
                    Text("Back To the ROOT")
                }
                
                Button(action: {
                    navigationFlags.toSecondView = false // FirstView に戻る。
                }) {
                    Text("Back To the First View")
                }
            }
            .navigationTitle("Second View")
        }
    }

List と組み合わせる

List からアイテムを選択し、画面遷移したい、さらに遷移先画面から戻れるようにしたいという場合、上記の組み合わせでついつい下記のようのようなコードを書いてしまうと思います。

ContentView.swift
    // NG な例です!
    struct ContentView: View {
        @State private var toFirstView = false
        
        private var items = ["saru", "kiji", "inu"]

        var body: some View {
            NavigationView {
                List {
                    ForEach(items, id: \.self) { item in
                        VStack {
                            NavigationLink(destination: FirstView(item: item),
                                           isActive: $toFirstView) {
                                EmptyView()
                            }
                            
                            Button(action: {
                                toFirstView = true
                            }) {
                                Text(item)
                            }
                        }
                    }
                }
                .navigationTitle("ROOT")
            }
            .navigationViewStyle(.stack)
        }
    }

しかし、これはいけません。想定外のデタラメな挙動をします。
というのも、ForEachは同じViewを繰り返し生成するので、同一の isActive フラグが複数の NavigationLink に設定されていることになり、どれかひとつのセルを選択するだけで、全セルの画面遷移が発火することになるからです。

NavigationLink は List の外に追い出せ」が原則です。
選択されたセルを格納する変数を別に設けて(下記ではselectionとしています)、こう書き直しましょう。

ContentView.swift
    struct ContentView: View {
        @State private var toFirstView = false
        
        private var items = ["saru", "kiji", "inu"]
        @State private var selection: String?

        var body: some View {
            NavigationView {
                VStack {
                    if let selection = selection { // アイテムが選択されている時だけ有効
                        NavigationLink(destination: FirstView(item: selection),
                                       isActive: $toFirstView) {
                            EmptyView()
                        }
                    }
                    
                    List {
                        ForEach(items, id: \.self) { item in
                            VStack {
                                Button(action: {
                                    selection = item
                                    toFirstView = true
                                }) {
                                    Text(item)
                                }
                            }
                        }
                    }
                }
                .navigationTitle("ROOT")
            }
            .navigationViewStyle(.stack) // 念のため
        }
    }

最後に

約2年前に、iOS(iPadOS)アプリ簡単便利な階層型情報メモアプリ HiMemoを個人開発でリリースしました。
最近、このアプリを SwiftUI で書き直しています(→2022年4月にリリースしました。HiMemo2です。)。その中ではまさに本稿で述べた、リストからの画面遷移、多階層の画面遷移、階層を一気に戻るUIが必要となるのですが、いちいちハマってしまいました。
せっかく苦労して解決したのでまとめておこうと思いました。

この記事が少しでもお役に立てれば幸いです。

参考

18
12
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
18
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?