SwiftUI基础篇Navigation

3,069 阅读6分钟

Navigation

概述

文章主要分享SwiftUI Modifier的学习过程,将使用案例的方式进行说明。内容浅显易懂,Navigation展示部分调试结果,不过测试代码是齐全的。如果想要运行结果,可以移步Github下载code -> github案例链接

1、Navigation概述

导航是许多应用程序的核心,SwiftUI的简单、易用的方面在Navigation上做的非常好,这意味着使用NavigationLink、使用NavigationPath完全控制导航,使用NavigationSplitcView支持第二视图和第三视图布局等。 这意味着很多时候可以忽略Navigation相关的场景,因为非常简单,专注于需要更多思考的部分,例如:

  • 制作具有合理默认值的可自定义的toolbar
  • 通过状态恢复保存和加载用户的导航
  • 决定如何显示侧边栏,或在需要时添加额外的Content视图
  • 结合NavigationSplitView和NavigationStack获得想要的结构
struct FFNavigation: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("最简单的Navigation") {
                    Text("So easy")
                }
            }
        }
    }
}

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.13.06.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.13.09.png

2、如何在Navigation中嵌入View

SwiftUI的NavigationStack或多或少的映射到UIKit的UINavigationController,因为它显示内容,它能够处理视图之间的导航。

2.1、基础样式

以最简单的方式,将Text放入NavigationStack中

struct FFNavigationEmbedView: View {
    var body: some View {
        NavigationStack {
            Text("This is a great app")
        }
    }
}

2.2、.navigationTitle("Welcome")

然而,顶部导航栏是空的。因此,通常会在嵌入的任何内容上使用navigationTitle()修饰符,因此可以在屏幕顶部添加标题。

struct FFNavigationEmbedView: View {
    var body: some View {
        NavigationStack {
            Text("Swift")
                .navigationTitle("Welcome")
        }
    }
}

2.3、navigationBarTitleDisplayMode

还有第二个修饰符,navigationBarTitleDisplayMode(),可以控制是否使用大标题还是小标题的内联标题。例如,默认情况下,任何视图以继承其大标题的显示模式呈现的,或者如果是初始视图,则使用大标题。但是,如果更愿意手动启用或禁用大标题,应该使用.navigationBarTitleDisoplayMode()

struct FFNavigationEmbedView: View {
    var body: some View {
        NavigationStack {
            Text("SwiftUI")
                .navigationTitle("Meta BBlv")
                .navigationBarTitleDisplayMode(.inline)
        }
    }
}

这将产生小的导航标题,但可以使用.large来设置一个大标题

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.26.09.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.26.29.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.27.26.png

3、Navigation的可编辑状态

SwiftUI的NavigationStack可以使用navigationTitle()显示一个简单的字符串,但同一修饰符也可接受字符串绑定,以便可以通过点击来编辑标题

struct FFNavigationEdit: View {
    @State private var title = "Wecome"
    var body: some View {
        //例如,显示“欢迎”的默认标题,可以点击该标题进行更改。
        NavigationStack {
            Text("Hello, World!")
                .navigationTitle($title)
                .navigationBarTitleDisplayMode(.inline)
            //正在编辑某些内容的名称,我建议添加.toolvarRole(.editor)让SwiftUI知道你即将要做的的事情,以便动态更改的标题更准确。
                .toolbarRole(.editor)
        }
    }
}

只有当导航栏以内联模式运行时,导航标题编辑才有效。仅适用于iOS和iPadOS

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.40.13.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.40.18.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.40.30.png

4、Navigation添加bar-Items

toolbar()修饰符可以将单个或多个item按钮添加到navigationStack的前面和后面,以及视图的其他部分。这些可能是可点击的按钮,但没有限制--可以添加任何类型的视图。

4.1、在导航条的后面添加了两个按钮

struct FFNavigationAddBehind: View {
    var body: some View {
        NavigationStack {
            Text("SwiftUI")
                .navigationTitle("Welcome")
                .toolbar(content: {
                    Button("About") {
                        print("About tapped!")
                    }
                    
                    Button("Help") {
                        print("Help tapped!")
                    }
                })
        }
    }
}

4.2、在导航条的前面添加了一个按钮

如果想控制按钮的确切位置,可以通过将其包装在ToolBarItem并制定所需的位置来做到这一点。例如,创建一个按钮,并强制将其放在导航栏前面。

struct FFNavigationAddFront: View {
    var body: some View {
        NavigationStack {
            Text("SwiftUI")
                .navigationTitle("Welcome")
                .toolbar(content: {
                    ToolbarItem(placement: .topBarLeading) {
                        Button("Help") {
                            print("Help tapped!")
                        }
                    }
                })
        }
    }
}

4.3、多个item放在相同的位置(ToolbarItenmGroup)

如果想在不同位置放置多个item-button,只需要根据多次重复ToolBarItem,并每次指定不同的位置。要将多个item放在相同的位置,需要将他们包装在ToolbarItenmGroup

struct FFNavigationToolBarItem: View {
    var body: some View {
        NavigationStack {
            Text("SwiftUI")
                .navigationTitle("Welcome")
                .toolbar(content: {
                    ToolbarItemGroup(placement: .primaryAction) {
                        Button("About") {
                            print("About tapped!")
                        }
                        
                        Button("Help") {
                            print("About tapped!")
                        }
                    }
                })
        }
    }
}

这使用.primaryAction,将根据平台(iOS等)认为最重要的按钮的位置来定位按钮。

4.4、primaryAction与secondaryAction优先级设置item

还有一个.secondaryAction放置,专为有用但不需要操作而设计,在iOS上,将导致该组中的按钮折叠为单个详细信息按钮

struct FFNavigationViewPlacement: View {
    var body: some View {
        Text("SwiftUI")
            .navigationTitle("Welcome")
            .toolbar(content: {
                ToolbarItemGroup(placement: .primaryAction) {
                    Button("About") {
                        print("About tapped!")
                    }
                    Button("Help") {
                        print("About tapped!")
                    }
                }
                ToolbarItemGroup(placement: .secondaryAction) {
                    Button("Settings") {
                        print("Credits tapped!")
                    }
                    Button("Email Me") {
                        print("Email tapped!")
                    }
                }
            })
    }
}

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.53.54.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.53.59.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 09.54.13.png

5、如何将新视图push到NavigationStack上

SwiftUI可以使用NavigationLink将任何视图推送到navigationStack上,在其最简单的形式中,可以为其标题提供字符串和目标视图作为尾随闭包。

//创建一个简单的DetailView结构,然后通过NavigationStack显示它
struct DetailView_013: View {
    var body: some View {
        Text("This is the detail view")
    }
}

struct FFnavigationPushView: View {
    var body: some View {
        NavigationStack {
            VStack {
                NavigationLink("Show Detail View") {
                    DetailView_013()
                }
                
                //如果需要对Label进行更多的自定义,那么使用尾随闭包。例如,使用Label视图而不是简单的字符串
                NavigationLink {
                    DetailView_013()
                } label: {
                    Label("Show Detail View - Label", systemImage: "globe")
                }
                //SwiftUI将自动将链接样式设置为按钮,以便用户知道他们是交互式的。
                //可以通过将.buttonStule(.plain)应用在NavigationLink禁用此行为
            }
            .navigationTitle("navigation")
        }
    }
}

6、点击List-cellpush新的视图

SwiftUI的NavigationLink可在List-cell中使用,以便在点击cell时显示新的视图。如果NavigationLink包含整个cell,系统会自动理解为整个cell可点击,以显示新视图。有两种方法可以指定导航目的地,通过明确链接,或者如果目标是iOS16以及更高版本,可以使用navigationDestination()

struct PlayerView_013: View {
    let name: String
    var body: some View {
        Text("Select player: \(name)")
            .font(.largeTitle)
    }
}

struct FFNavigationForList: View {
    let players = [
        "Roy Kent",
        "Richard Montlaur",
        "Dani Rojas",
        "Jamie Tartt"
    ]
    var body: some View {
        //创建一个包含List的NavigationStack,允许用户选择Player
        NavigationStack {
            List(players, id: \.self) { player in
                NavigationLink(player, value: player)
            }
            .navigationDestination(for: String.self, destination: PlayerView_013.init)
            .navigationTitle("Select a Player")
            Divider()
            //可以通过附加navigationDestination()来监视多种不同的数据类型
            List {
                NavigationLink("Show an integer", value: 29)
                NavigationLink("show a String", value: "Meta BBLv")
                NavigationLink("Show a Double", value: Double.pi)
            }
            .navigationDestination(for: Int.self) { Text("Received Int: \($0)") }
            .navigationDestination(for: String.self) { Text("Received String \($0)") }
            .navigationDestination(for: Double.self) { Text("Receicved Double \($0)") }
        }
    }
}

如果不需要高度自定义的导航,并且可以在iOS16以及更高的版本运行,强烈建议使用navigationDestination(),因为SwiftUI会懒加载目标视图。

7、关于Navigation programmatic

可以使用SwiftUI的NavigationLink以编程的方式将新视图推送到NavigationStack,这意味着可以在准备好时触发导航,而不是仅当点击了button或cell时。从iOS16以及更高的版本,我们可以将一系列的Hashable数据直接传递给NavigationStack,以控制那些数据当前在stack上。

7.1、绑定Path,跟踪显示

struct FFNavigationProgrammatic: View {
    @State private var presentedNumbers = [1, 4, 8]
    
    var body: some View {
        //跟踪正在显示的数字,并首先将1、4、8push到stack上。
        NavigationStack(path: $presentedNumbers) {
            List(1..<50) { i in
                NavigationLink(value: i) {
                    Label("Row \(i)", systemImage: "\(i).circle")
                }
            }
            .navigationDestination(for: Int.self) { i in
                Text("Detail \(i)")
            }
            //当代码运行时,将看到“Detail8”,点返回看到“Detail 4”,点返回看到“Detail 1”,点返回看到List
        }
        .navigationTitle("NavigationForPath")
    }
}

7.2、在detail页面直接跳转下一个detail(驾考答题需求)

这种方法很强大,因为可以在任何时候修改NavigationStack来push一个自定义视图。他是一个简单的数组,所以可以add、insert、delete item或者根据显示需求做任务操作。路径数组开始为空,在点击的detail中添加了一个button,可以直接在当前detailpush(n+1)个detail

struct FFNavigationProgrammaticForNext: View {
    @State private var presentedNumbers = [Int]()
    var body: some View {
        NavigationStack(path: $presentedNumbers) {
            List(1..<50) { i in
                NavigationLink(value: i) {
                    Label("Row \(i)", systemImage: "\(i).circle")
                }
                .navigationDestination(for: Int.self) { i in
                    VStack {
                        Text("Detail \(i)")
                        
                        Button("Go to Next") {
                            presentedNumbers.append(i + 1)
                        }
                    }
                }
                .navigationTitle("NavigationForNext")
            }
        }
    }
}

7.3、NavigationPath

如果将一种很数据类型push到Stack,则可以使用简单数组作为navigation path,但如果需要heterogeneous数据,则可以使用名为NavigationPath的特殊类型擦除包装器。这可以处理任何可hash的数据,所以可以添加一些Staing、Int和custom struct,只要符合hashable都可以

struct FFNavigationProgrammaticForHash: View {
    @State private var navPath = NavigationPath()
    var body: some View {
        NavigationStack(path: $navPath) {
            Button("Jump to random") {
                navPath.append(Int.random(in: 1..<50))
            }
            
            List(1..<50) { i in
                NavigationLink(value: "Row \(i)") {
                    Label("row \(i)", systemImage: "\(i).circle")
                }
                .navigationDestination(for: Int.self) { i in
                    Text("Int detail \(i)")
                }
                .navigationDestination(for: String.self) { o in
                    Text("String detail \(i)")
                }
                .navigationTitle("NavigationForHashable")
            }
        }
    }
}

可以随便的调整path--在哪里附加了一个value,但如果需要的话,可以一次附加多个值,像旧的UIKit的pop到根视图控制器这样的东西,就像从你的路径中清楚所有东西一样的简单-navPath.removeLast(navpath.count)这样就可以做到。

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 10.41.53.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 10.42.43.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 10.42.46.png

8、如何使用Codable协议保存和加载NavigationStach Paths

当使用NavigationPath对象管理SwiftUI的NavigationStack路径时,使用Codable协议保存和加载整个路径--可以存储完整的NavigationStack并在需要的时间恢复它,这样用户就能准确的回到离开的地方。最好的处理方法是将存储封装在一个单独的ObservableObject类中,这个类可以负责从视图中加载和保存路径数据。

构建PathStore

例如,这个类在创建时加载一个保存的路径,并在他的NavigationPath属性改变时保存路径

class PathStore: ObservableObject {
    @Published var path = NavigationPath() {
        didSet {
            save()
        }
    }
    
    private let savePath = URL.documentsDirectory.appending(path: "SavedPathStore")
    
    init() {
        if let data = try? Data(contentsOf: savePath) {
            if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
                path = NavigationPath(decoded)
            }
        }
    }
    
    func save() {
        guard let representation = path.codable else { return }
        
        do {
            let data = try JSONEncoder().encode(representation)
            try data.write(to: savePath)
        } catch {
            print("Failed to save navigation data")
        }
    }
}

这是一个整洁的可重用类,只要写入NavigationPath的数据的Codable类型,就可以了。

构建DetailViewCodable_013

创建一个简单的Detail视图,能够显示用户选择的数字,同时通过选择另一个数字进行更深的导航,然后将其与PathStore类一起使用,以便自动加载和保存导航

struct DetailViewCodable_013: View {
    var id: Int
    
    var body: some View {
        VStack {
            Text("View \(id)")
                .font(.largeTitle)
            NavigationLink("Junp to random", value: Int.random(in: 1...100))
        }
    }
}

调试代码

如果运行哪个代码,会看到可以浏览任意多的DetailView级别,数据会自动被存储--可以退出App并返回,导航历史将保持完整。

struct FFNavigationCodable: View {
    @StateObject private var pathStore = PathStore()
    
    var body: some View {
        NavigationStack(path: $pathStore.path) {
            DetailViewCodable_013(id: 0)
                .navigationDestination(for: Int.self, destination: DetailViewCodable_013.init)
                .navigationTitle("Navigation")
        }
    }
}

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 10.54.02.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 10.54.30.png Simulator Screenshot - iPhone 14 Pro - 2023-08-16 at 10.54.20.png

9、创建一个两列或三列布局用NavigatinSplitView

SwiftUI的NavigationSplitView允许我们在更大的设备上创建多列布局(iPad macOS和大屏幕的iPhone上),但是当空间有限时,将自动折叠成NavigationStack风格的布局。在最简单的形式中,应该提供侧边栏作为它的第一个尾随闭包,的细节视图作为他的第二个闭包。

9.1、Sidebar + Detail

struct FFNavigationSplitView: View {
    var body: some View {
        NavigationSplitView {
            List(1..<50) { i in
                NavigationLink("Row \(i)", value: i)
            }
            .navigationDestination(for: Int.self) {
                Text("Select row \($0)")
            }
            .navigationTitle("Split View")
        } detail: {
            Text("Please select a row")
        }
    }
}

在该代码中,“Please select a row”文本仅在用户尚未在侧边栏中进行选择时显示,但是当用户进行选择时,将自动被替换。navigationDestination()修饰符将自动在详细信息区域中显示其目的地视图。更棒的是,当空间有限时,会看到整个东西被平展成一个常规的NavigationStack。

调试结果

iPad调试,这图上传一直failure,太大了,想看调试结果,移步GitHub吧,下面的NavigationSplitView都是一样。

9.2、Sidebar + Content + Detail

NavigationSplitView可以在布局中添加第三个视图,它可以通过点击Button来显示。

struct FFNavigationSplitViewContent: View {
    var body: some View {
        NavigationSplitView {
            Text("Sidebar")
        } content: {
            Text("Primary")
        } detail: {
            Text("Detail View")
        }
    }
}

SwiftUI将自动显示一个按钮滑动在你的栏从屏幕的一边。

10、Sidebar的显示和隐藏

当在macOS上和iPad上使用NavigationSplitView时,SwiftUI使用NavigationSplitViewVisibility的enum来切换显示侧边栏、内容视图和详细视图。有四种模式可以选择:

  • 在.detailonly模式下,详细视图将占用应用程序的所有可用屏幕空间
  • 在.doublecolumn模式下,将同时看到内容视图和详细视图
  • 在.all模式下,如果存在,系统将尝试显示所有三个视图。在没有内容视图(中间视图)的情况下,它将只显示两个。
  • 在.automatic,系统将自动根据当前设备和方向做最好的UI布局
struct FFNavigationSplitViewHideAndShow: View {
    @State private var columnVisibility = NavigationSplitViewVisibility.detailOnly
    
    var body: some View {
        NavigationSplitView(columnVisibility: $columnVisibility) {
            Text("Sidebar")
        } content: {
            Text("Content")
        } detail: {
            VStack {
                Button("Detail Only") {
                    columnVisibility = .detailOnly
                }
                Button("Content and Detail") {
                    columnVisibility = .doubleColumn
                }
                Button("Show All") {
                    columnVisibility = .all
                }
            }
        }
    }
}

提供colunmVisibility是使用绑定完成的,因为当用户与UI交互时,value将自动更新。 虽然SwiftUI对分屏视图界面的这三个部分使用了不同的名称,但他们与UIKit的对应部分直接匹配:在UIKit中,侧sidebar时”主要的“,content时“补充的”,detail是“次要的”

11、在NavigationSplitView中自定义一个宽度的视图

SwiftUI的NavigationSplitView为它呈现的视图使用系统标准宽度,但是通过navigationSPlitViewColumnWidth()修饰符,可以尝试自定义它。 系统可以选择忽略指定的宽度。在编写文本时,此修饰符在iPhone上被忽略,而在iPad上仅适用于小于默认大小的值。

11.1、navigationSplitViewColumnWidth固定大小

在其最简单的形式,传入一个固定的值给navigationSplitViewColumnWidth()导致它使用一个固定的大小,未使用更小或更大。

struct FFNavigationSplitViewCustomize: View {
    var body: some View {
        NavigationSplitView {
            Text("Sidebar")
                .navigationSplitViewColumnWidth(100)
        } content: {
            Text("Content")
                .navigationSplitViewColumnWidth(200)
        } detail: {
            Text("Detail")
        }
        
    }
}

11.2、navigationSplitViewColumnWidth范围设定大小

然而,如果想通过最大值、最小值来设定范围,在macOS上,可以提供最小,最理想和最大的size

struct FFNavigationSplitViewCustomizeMax: View {
    var body: some View {
        NavigationSplitView {
            Text("Sidebar")
                .navigationSplitViewColumnWidth(min:100, ideal: 200, max: 300)
        } content: {
            Text("Content")
                .navigationSplitViewColumnWidth(min:100, ideal: 200, max: 300)
        } detail: {
            Text("Detail")
        }

    }
}

12、自定义NavigationSplitView的显示模式

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

12.1、.prominentDetail

第一个是.prominentDetail,它告诉SwiftUI你想让细节视图一直保持完整的大小--侧边栏和内容视图会滑动到Detail视图上,而不是把他推到一边或把它挤得更小。

struct FFNavigationSplitViewDisplayMode: View {
    var body: some View {
        
        NavigationSplitView {
            Text("Sidebar")
        } content: {
            Text("Content")
        } detail: {
            Text("Detail")
        }
        .navigationSplitViewStyle(.prominentDetail)
    }
}

12.2、.balanced

第二个选项是.balanced,这将在显示侧边栏或Content栏时减少Detail视图的大小--只需将.prominentDetail切换为.balance

struct FFNavigationSplitViewDisplayModeBalance: View {
    var body: some View {
        NavigationSplitView {
            Text("Sidebar")
        } content: {
            Text("Content")
        } detail: {
            Text("Detail")
        }
        .navigationSplitViewStyle(.balanced)
    }
}

默认设置是.automatic,这将根据平台的不同而有所不同,在写Text时,在iPhone上变成了.prominentDetail,在iPad上变成.balance

13、NavigationSplitView主动选择视图优先级

当有一个NavigationSplitView运行在一个紧凑的size class中。SwiftUI试图推断分屏视图列中哪一个是最优先的显示,这种推断通常是正确的,但可以通过分屏视图设置首选紧凑列来控制它。

struct FFNavigationSplitViewCompact: View {
    @State private var preferredColumn = NavigationSplitViewColumn.detail
    var body: some View {
        //例如:这段代码强制将detail视图列为首选,从而覆盖了SwiftUI的默认选择
        NavigationSplitView(preferredCompactColumn: $preferredColumn) {
            Text("Sidebar View")
        } detail: {
            Text("Detail View")
        }
    }
}

如果提供了一个不存在的值,比如,当只有sidebar和detail,选择content,那么SwiftUI就会选择侧边栏。

14、在View上创建Inspector

SwiftUi的inspector()修饰符可以在任何需要的地方添加检查器视图。这就像Xcode一样,检查器添加到UI的后面,并且可以根据需要与NavigationSplitView和NavigationStack一起使用。

14.1、 基本样式

当按钮被按下时,将显示一个检查器视图。

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

当空间很大时,比如使用全屏的iPad应用程序或macOS时,检查器就放在按钮旁边。然而,当空间有限时,例如在iPhone上,检查器作为一个页面向上滑动(present mode)。

14.1、动态调整width

在支持他的平台上,你可以通过提供一个 (.inspectorColumnWidth(500)) 固定的大小来占用空间,或者通过提供一个.inspectorColumnWidth(min: 50, ideal: 150, max: 200)范围来调整检查器的占用空间。

struct FFInspectorIdeal: View {
    @State private var isShowingInspector1 = false
    
    var body: some View {
        Button("Hi, metaBBLv") {
            isShowingInspector1.toggle()
        }
        .font(.largeTitle)
        .inspector(isPresented: $isShowingInspector1) {
            Text("Insepctor View BBlv")
                .inspectorColumnWidth(min:50, ideal: 150, max: 200)
        }
    }
}