NavigationSplitView

iOS 17中,以前的 NavigationView 已经被 NavigationStack 和 NavigationSplitView 所替代。

NavigationSplitView 允许我们在较大的设备(iPadOS、macOS 和横向的大型 iPhone)上创建多列布局,但当空间有限时会自动折叠为 NavigationStack 样式的布局。即在大多数 iPhone 上,导航标题将缩小为小文本,以便占用更少的空间;但在最大尺寸的 iPhone 上,您会看到标题变成了左上角的蓝色按钮,点击该按钮主视图会从边缘滑入。


两栏布局 (sidebar: , detail: )

// 以下代码,您看到的内容取决于设备和方向。在最大尺寸的手机和 iPad 上,会先看到“Secondary”,而主工具栏按钮会显示“Hello,world!”
NavigationSplitView(
	sidebar: {
			List(1..<50) { i in
        NavigationLink("Row \\(i)", value: i)
	    }
	    // 通常情况下,需要向侧边栏添加某种选择机制,用户点击后,会在 detail 视图中加载选择的目标视图
	    .navigationDestination(for: Int.self) {
	        Text("Selected row \\($0)")
	    }
	    .navigationTitle("Split View")
	}, 
	detail: {
			// 这个算是默认视图,如果用户还没有点任何链接,这个是默认显示在 detail 辅助视图上的
			Text("Secondary")
	}
)

截止到现在,以上特性都有一些缺点,希望这些缺点能够在未来得到修复:

  1. 即使有足够的空间,也无法使主视图保持可见。(默认都是隐藏的,要点一下按钮才推出来)
  2. 默认情况下,您无法将主视图设置为横向显示; SwiftUI 总是选择细节视图
  3. 无论是否需要,详细信息视图始终都会有一个导航栏,因此需要使用 .navigationBarHidden(true) 来隐藏它
  4. 还可以使用 .toolbar(.hidden, for: .navigationBar) 在详细视图中隐藏工具栏,但要小心,因为它会隐藏切换侧边栏的按钮!

三栏布局 (sidebar: , content: , detail: )

struct UltimatePortfolioApp: App {
    var body: some Scene {
        WindowGroup {
		        //第一个闭包可以省略参数名
            NavigationSplitView {
                SidebarView()
            } content: {
                ContentView()
            } detail: {
                DetailView()
            }
        }
    }
}

参数:columnVisibility

columnVisibility 实际上是作为绑定提供的,因此您可以将选项存储在某种状态并动态更新。

//例如希望在空间受限时,也保留主工具栏视图
NavigationSplitView(columnVisibility: .constant(.all)) {
    NavigationLink("Primary") {
        Text("New view")
    }
} detail: {
    Text("Content")
        .navigationTitle("Content View")
}
.navigationSplitViewStyle(.balanced)
//这要求主副视图以 balance 策略显示,结果是纵向模式下的 iPad 现在也将显示主工具视图

参数:preferredCompactColumn

其次,您可以告诉系统【默认情况】下更喜欢详细视图,这对于选择主视图作为标准的 iPhone 很有帮助:

NavigationSplitView(preferredCompactColumn: .constant(.detail)) {...}

// 强制选择详细视图
// 如果您提供的值不存在,例如您要求它选择内容视图(但实际上只有侧边栏和详细视图),那么 SwiftUI 将只选择侧边栏
struct ContentView: View {
    @State private var preferredColumn = NavigationSplitViewColumn.detail
    var body: some View {
        NavigationSplitView(preferredCompactColumn: $preferredColumn) {
            Text("Sidebar View")
        } detail: {
            Text("Detail View")
        }
    }
}

修饰符:inspector

inspector() 修饰符可以在任何需要的地方添加检查器视图。这就像 Xcode 一样:检查器从 UI 的末端出现,并且可以根据需要与 NavigationStack 或 NavigationSplitView 一起工作。

struct ContentView: View {
    @State private var isShowingInspector = false
    var body: some View {
        Button("Hello, world!") {
            isShowingInspector.toggle()
        }
        .font(.largeTitle)
        .inspector(isPresented: $isShowingInspector) {
            Text("Inspector View")
        }
    }
}

在支持它的平台上,可以通过为其提供固定大小 ( .inspectorColumnWidth(500) ) 或通过为其提供大小范围 ( .inspectorColumnWidth(min: 50, ideal: 150, max: 200) ) 来调整检查器占用的空间大小。该修饰符应该应用于检查器的内容,如下所示:

struct ContentView: View {
    @State private var isShowingInspector = false
    var body: some View {
        Button("Hello, world!") {
            isShowingInspector.toggle()
        }
        .font(.largeTitle)
        .inspector(isPresented: $isShowingInspector) {
            Text("Inspector View")
                .inspectorColumnWidth(min: 50, ideal: 150, max: 200)
        }
    }
}

<aside> 💡 提醒:ideal 理想尺寸将用于首次显示时检查器的尺寸,但系统会记住用户的更改。

</aside>

模拟四栏布局

在以下示例中,我们利用 inspector() 修饰符来模拟四栏布局。请确保您的 SwiftUI 版本支持该修饰符:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationSplitView {
            SidebarView()
        } content: {
            ContentView()
        } detail: {
            DetailView()
        }
        .inspector(isPresented: .constant(true)) {
            InspectorView()
        }
    }
}

struct SidebarView: View {
    var body: some View {
        List {
            Text("Category 1")
            Text("Category 2")
            Text("Category 3")
        }
        .navigationTitle("Sidebar")
    }
}

struct ContentView: View {
    var body: some View {
        List {
            Text("Article 1")
            Text("Article 2")
            Text("Article 3")
        }
        .navigationTitle("Content")
    }
}

struct DetailView: View {
    var body: some View {
        Text("Detail View")
            .navigationTitle("Detail")
    }
}

struct InspectorView: View {
    var body: some View {
        Text("Inspector View")
            .navigationTitle("Inspector")
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

样式设置

1. 自定义视图宽度

注意:系统可以选择忽略您指定的宽度。在撰写本文时,此修饰符在 iPhone 上被忽略,并且在 iPad 上仅适用于低于默认大小的值。

NavigationSplitView 使用系统标准宽度呈现它的视图;使用 navigationSplitViewColumnWidth() 修饰符则可以进行自定义。在最简单的形式中,向 navigationSplitViewColumnWidth() 发送单个值会导致它使用固定大小,不小于或大于。

NavigationSplitView {
    Text("Sidebar")
        .navigationSplitViewColumnWidth(100)
} content: {
   Text("Content")
        .navigationSplitViewColumnWidth(200)
} detail: {
    Text("Detail")
}

// 如果要支持灵活性(目前可能只是 macOS),您可以提供最小、理想和最大尺寸,如下所示:
NavigationSplitView {
    Text("Sidebar")
        .navigationSplitViewColumnWidth(min: 100, ideal: 200, max: 300)
} content: {
   Text("Content")
        .navigationSplitViewColumnWidth(min: 100, ideal: 200, max: 300)
} detail: {
    Text("Detail")
}

2. 以编程方式隐藏/显示侧边栏

在 macOS 和 iPadOS 上使用 NavigationSplitView 时,SwiftUI 允许使用 NavigationSplitViewVisibility 枚举,显示侧边栏、内容视图和详细视图。例如以下代码:

struct ContentView: View {

    @State private var columnVisibility = NavigationSplitViewVisibility.detailOnly

    var body: some View {
    
		    // 提供 columnVisibility 是使用绑定完成的,因为当用户与您的 UI 交互时,您的值将自动更新
        NavigationSplitView(columnVisibility: $columnVisibility) {
            Text("Sidebar")
        } content: {
           Text("Content")
        } detail: {
            VStack {
            
		            // 在 .detailOnly 模式下,detail 视图将占用应用程序的所有可用屏幕空间
                Button("Detail Only") {
                    columnVisibility = .detailOnly
                }
                
                // 在 .doubleColumn 模式下,您将看到 content 视图和 detail 视图
                Button("Content and Detail") {
                    columnVisibility = .doubleColumn
                }

								// 在 .all 模式下,系统将尝试显示所有三个视图(如果存在)。如果您没有 content 视图(中间视图),它只会显示两个
                Button("Show All") {
                    columnVisibility = .all
                }
                
                // 在 .automatic 模式下,系统将根据当前设备和方向执行其认为最好的操作
            }
        }
    }
}

3. 控制侧边栏的显示方式

NavigationSplitView 有三个选项来控制侧边栏的显示方式,每个选项都可以使用 navigationSplitViewStyle() 修饰符进行调整。

NavigationSplitView {
    Text("Sidebar")
} content: {
    Text("Content")
} detail: {
    Text("Detail")
}
.navigationSplitViewStyle(.prominentDetail)

其中参数值有以下几个选项:


NavigationLink 的表现

NavigationLink 指定的目标视图是刷新,不是跳转

NavigationSplitView 中使用 NavigationLink ,与在传统的 NavigationStack 中不同。NavigationSplitView 是为了在 iPad 或者Mac 这样的多栏界面中使用而设计的,它自然地支持在侧边栏(sidebar)、内容视图(content view)和详细视图(detail view)之间的导航。

例如当使用 NavigationLink 时,如果代码中指定了导航的目的视图。那在 NavigationSplitView 的结构中点击 NavigationLink 后 ,实际上是会导致内容视图(content view)的更新,在里面呈现前面指定的视图内容。而不是直接跳转到新的视图。这是 NavigationSplitView 的设计逻辑决定的。简单说就是跳转的视图永远是 contentView,只是在 contentView 容器里加载指定的目标视图内容。

//例如以下代码中,点击 NavigationLink "链接按钮1",会自动在二级视图中展示 “Text("新的二级视图")”
NavigationSplitView(
		sidebar: {
				NavigationLink{
						//点击后会在辅助视图窗口中展示以下视图
						Text("新的二级视图")
				} label: {
						Text("链接按钮1")
				}
				.navigationTitle("主视图")
    },
		detail: {
				//这个是辅助视图中的默认视图
				Text("二级视图")
		}
)

NavigationLink 没有指定目标视图

NavigationLink 没有指定目标视图,改用了 value 的写法,但我们又没有设置 navigationDestination 修饰符。这时无法跳转到下一级视图。这时我们如果在 List 列表中,使用了 selection 绑定和 value 同样类型的数据,就可以实现跳转。并且可以把 value 值传递给其他视图使用。

List(selection: $dataController.selectedFilter){
		ForEach(smartFilters){
				item in
				NavigationLink(value: Filter.all) {
						Text("链接按钮\\(item.name)")
				}
		}
}

//在 NavigationSplitView 中使用 NavigationLink:
//1. 如果 NavigationLink 是显性指定了目标视图,那跳转没有任何问题,会自动在下一级辅助页面打开指定的目标视图
//2. 如果 NavigationLink 没有指定目标视图,而是用了 value 的形式创建,那无法正常跳转到下一级视图,会提示没设置 navigationDestination
//解决这个问题,正确写法是在 List 上设定绑定值,将 selection 绑定到一个与 NavigationLink 的 value 同等类型的属性上

第一种情况:如果没有设置 List 绑定

第二种情况:设置 List(selection: $dataController.selectedFilter)

总的来说在使用 NavigationSplitView 时,需要注意以下几点:

  1. 确保 ContentView 能够感知导航栈中的值变化,可以使用 @Environment(\\.presentationMode) 或自定义的 @Published 属性
  2. 使用 List 的 selection 绑定属性可以更好地在视图之间传递状态
  3. 根据具体需求,选择合适的方式来管理视图之间的状态和导航

这个问题的关键在于确保 ContentView 能够正确地感知和响应导航栈中值的变化,从而能够正确地更新视图。使用 Listselection 绑定属性是一种很好的方式来实现这一点。

NavigationLink 附加值应用

//例如有一个三栏视图
NavigationSplitView(){
		SidebarView()
} content: {
		ContentView()
} detail: {
		DetailView()
}

//其中在 SidebarView 中定义了链接,链接中有一个附加值 filter,它是一个结构体
NavigationLink(value: filter) {
    Label(filter.name, systemImage: filter.icon)
}

在这里附加 filter 值有什么作用吗? 后面的视图可以怎么用上它吗?