はじめに
SwiftUI において画面遷移を行うための簡単な方法は NavigationView
と NavigationLink(destination:)
を使うものです。
なんらかの条件を満たしたときに強制的に画面遷移を行うなど、より細かい制御を行いたい場合は、NavigationLink(destination:, isActive:)
を使います。
オリジナルアプリを個人開発している中で、この NavigationLink(destination:, isActive:)
の使い方に関してかなりハマった経験があったので、備忘録がわりに記事としました。
追記(2023年8月11日)
NavigationView
とNavigationLink(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:)
の典型的な使い方は次のとおりです。
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 をタップして toFirstView
を true
にすると、NavigationLink に設定された画面遷移が行われます。
ここで1点補足しておきます。
上記では、toFirstView
を NavigationLink
による画面遷移を引き起こすスイッチのように使っていますが、逆に画面遷移が生じた場合、ToFirstView
は true
になります。
この部分は Toggle(isOn:)
の挙動と似ています。
SwiftUI らしい機構だと思いますが、「isActive:
フラグを false
にしているのに画面遷移し、フラグが true
になってしまう。なぜ?」という質問を見かけたりもしたので、念の為書き記しておきます。
画面遷移先から戻る
1階層戻る場合
NavigationView
では、前の画面に戻るための "<Back" ボタンが自動的に表示されますが、これに依らずに前の画面に戻ることもできます。
遷移先の画面で isActive:
でセットしたフラグを false
にすると画面遷移が強制的にキャンセルされて、前の画面に戻ります。
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
を定義しておきます。
class NavigationFlags: ObservableObject {
@Published public var toFirstView = false
}
ROOTに近いViewでそのインスタンスを生成します。App生成時に生成するならこんな感じです。
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(AppSettings())
}
}
その上で必要な View で呼び出します(下記サンプルコードでは、SecondView
で呼び出してます)。
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週間ぐらい悩みに悩んでしまったところですので、本稿で最も重要なポイントです!
class NavigationFlags: ObservableObject {
@Published public var toFirstView = false
@Published public var toSecondView = false
}
インスタンス作成は上記と同じなので割愛。
必要な View ごとに下記のようにBool値をセットします。
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 からアイテムを選択し、画面遷移したい、さらに遷移先画面から戻れるようにしたいという場合、上記の組み合わせでついつい下記のようのようなコードを書いてしまうと思います。
// 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
としています)、こう書き直しましょう。
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が必要となるのですが、いちいちハマってしまいました。
せっかく苦労して解決したのでまとめておこうと思いました。
この記事が少しでもお役に立てれば幸いです。