NavigationStack 1.0.4

NavigationStack 1.0.4

Matteo Puccinelli 维护。



  • 作者:
  • Matteo Puccinelli

swiftui-navigation-stack

一个替代的SwiftUI NavigationView,实现基于经典的堆栈导航,同时也提供对动画和编程导航的一些更多控制。

NavigationStack

安装

Swift Package Manager

Swift Package Manager是一个自动分发Swift代码的工具,它已集成到swift编译器中:打开Xcode,点击文件 -> Swift包 -> 添加包依赖...并使用仓库URL(https://github.com/matteopuc/swiftui-navigation-stack.git)下载包。

在Xcode中,当被提示要选择版本或分支时,建议使用分支:master。

然后在您的View中简单地包括import NavigationStack并遵循下面的使用示例。

CocoaPods

CocoaPods 是 Cocoa 项目的依赖管理器。有关使用和安装说明,请访问他们的网站。要使用 CocoaPods 在 Xcode 项目中集成 NavigationStack,请在您的 Podfile 中指定它

pod 'NavigationStack', '~> 1.0.2'

然后在您的View中简单地包括import NavigationStack并遵循下面的使用示例。

使用方法

在 SwiftUI 中,我们有一些视图来管理导航:NavigationViewNavigationLink。目前这些视图存在一些限制

  • 我们无法禁用过渡动画;
  • 我们无法自定义过渡动画;
  • 我们无法导航到根视图(即第一个应用视图),或到特定视图;
  • 我们无法使用视图进行程序化推入;

NavigationStackView 是一个视图,它模拟了标准 NavigationView 所有的行为,但它添加了上述所列的功能。您必须把视图层次结构包裹在 NavigationStackView

import NavigationStack

struct RootView: View {
    var body: some View {
        NavigationStackView {
            MyHome()
        }
    }
}

Jan-07-2020 15-40-35

您甚至可以以不同的方式自定义过渡和动画。此 NavigationStackView 将将它们应用于层次结构

  • 您可以选择不使用任何过渡,通过这种方式创建导航堆栈 NavigationStackView(transitionType: .none);
  • 您可以使用自定义过渡创建导航堆栈
import NavigationStack

struct RootView: View {
    var body: some View {
        NavigationStackView(transitionType: .custom(.scale)) {
            MyHome()
        }
    }
}

Jan-10-2020 15-31-40

  • NavigationStackView 对于过渡有默认的缓动。缓动可以在初始化时自定义
struct RootView: View {
    var body: some View {
        NavigationStackView(transitionType: .custom(.scale), easing: .spring(response: 0.5, dampingFraction: 0.25, blendDuration: 0.5)) {
            MyHome()
        }
    }
}

重要: 上述是推荐的方式来自定义缓动函数用于过渡。请注意,您甚至可以使用这种方式指定缓动

NavigationStackView(transitionType: .custom(AnyTransition.scale.animation(.spring(response: 0.5, dampingFraction: 0.25, blendDuration: 0.5))))

将缓动直接附加到过渡。 不要这样做。SwiftUI 对于附加到过渡的隐式动画仍然存在问题,因此可能不起作用。例如,附加到 .slide 过渡的隐式动画将不起作用。

推入

要前进导航,您有两个选择

  • 使用 PushView;
  • 程序化推入,直接访问导航堆栈;

PushView

PushView 的基本用法

PushView(destination: ChildView()) {
    Text("PUSH")
}

创建一个可点击的视图(在这种情况下是一个简单的Text)以导航到目的地。还有其他方式使用PushView触发导航。

struct MyHome: View {
    @State private var isActive = false
    
    var body: some View {
        VStack {
            PushView(destination: ChildView(), isActive: $isActive) {
                Text("PUSH")
            }
            
            Button(action: {
                self.isActive.toggle()
            }, label: {
                Text("Trigger push")
            })
        }
    }
}

这种情况下,您仍然拥有一个可点击的视图,甚至可以利用isActive布尔值来触发导航(在这种情况下,导航仍然是通过PushView触发的)。

如果您有多个目的地并且想避免使用太多的@State布尔值,可以使用另一种方法。

enum ViewDestinations {
    case noDestination
    case child1
    case child2
    case child3
}

struct MyHome: View {
    @ObservedObject private var viewModel = ViewModel()
    @State private var isSelected: ViewDestinations? = .noDestination

    var body: some View {
        VStack {
            PushView(destination: ChildView1(), tag: ViewDestinations.child1, selection: $isSelected) {
                Text("PUSH TO CHILD 1")
            }

            PushView(destination: ChildView2(), tag: ViewDestinations.child2, selection: $isSelected) {
                Text("PUSH TO CHILD 2")
            }

            PushView(destination: ChildView3(), tag: ViewDestinations.child3, selection: $isSelected) {
                Text("PUSH TO CHILD 3")
            }

            Button(action: {
                self.isSelected = self.viewModel.getDestination()
            }, label: {
                Text("Trigger push")
            })
        }
    }
}

现在您有三个可点击的视图,并有机会通过一个tag(导航始终通过PushView触发)来触发导航。

程序性推送

NavigationStackView内部,您可以通过EnvironmentObject访问导航堆栈。如果您需要在不依赖于PushView的情况下程序性触发导航(即没有可点击视图),可以这样做。

struct MyHome: View {
    @ObservedObject private var viewModel = ViewModel()
    @EnvironmentObject private var navigationStack: NavigationStack

    var body: some View {
        Button(action: {
            self.viewModel.performBackgroundActivities(withCallback: {
                DispatchQueue.main.async {
                    self.navigationStack.push(ChildView())
                }
            })
        }, label: {
            Text("START BG ACTIVITY")
        })
    }
}

指定一个ID

这并非强制性的,但如果您想稍后返回到特定的视图,则需要为该视图指定一个ID。无论是PushView还是程序性推送,都允许您这样做。

struct MyHome: View {
    private static let childID = "childID"
    @ObservedObject private var viewModel = ViewModel()
    @EnvironmentObject private var navigationStack: NavigationStack

    var body: some View {
        VStack {
            PushView(destination: ChildView(), destinationId: Self.childID) {
                Text("PUSH")
            }
            Button(action: {
                self.viewModel.performBackgroundActivities(withCallback: {
                    DispatchQueue.main.async {
                        self.navigationStack.push(ChildView(), withId: Self.childID)
                    }
                })
            }, label: {
                Text("START BG ACTIVITY")
            })
        }
    }
}

弹出

弹出操作类似于推送操作。我们有相同的选择

  • 使用PopView
  • 程序化弹出,直接访问导航堆栈;

PopView

PopView的基本用法是

struct ChildView: View {
    var body: some View {
        PopView {
            Text("POP")
        }        
    }
}

弹出至上一个视图。您甚至可以为您的弹出操作指定一个目的地

struct ChildView: View {
    var body: some View {
        VStack {
            PopView(destination: .root) {
                Text("POP TO ROOT")
            }
            PopView(destination: .view(withId: "aViewId")) {
                Text("POP TO THE SPECIFIED VIEW")
            }
            PopView {
                Text("POP")
            }
        }
    }
}

PopView具有与PushView相同的功能。您可以创建一个通过isActive布尔值或tag触发的PopView。您还可以直接访问导航堆栈来程序性触发导航,而不依赖于PopView本身。

struct ChildView: View {
    @ObservedObject private var viewModel = ViewModel()
    @EnvironmentObject private var navigationStack: NavigationStack

    var body: some View {
        Button(action: {
            self.viewModel.performBackgroundActivities(withCallback: {
                self.navigationStack.pop()
            })
        }, label: {
            Text("START BG ACTIVITY")
        })
    }
}

NavigationStack注入

默认情况下,您只能在NavigationStackView层次结构内(通过访问NavigationStack环境对象)以编程方式推送和弹出。如果您想在NavigationStackView外部使用NavigationStack,则需要创建自己的NavigationStack(无论您在哪里),然后将它作为参数传递给NavigationStackView

重要:每个NavigationStack都必须与一个NavigationStackView相关联。一个NavigationStack不能在多个NavigationStackView之间共享。

这在您想要使用自己的路由类(例如)通过解耦路由逻辑与视图时很有用。

class MyRouter {
    private let navStack: NavigationStack

    init(navStack: NavigationStack) {
        self.navStack = navStack
    }

    func rootView() -> some View {
        RootView()
    }

    func toLogin() {
        self.navStack.push(LoginScreen())
    }

    func toSignUp() {
        self.navStack.push(SignUpScreen())
    }
}

struct RootView: View {
    let navStack: NavigationStack
    let router: MyRouter

    var body: some View {
        NavigationStackView(navigationStack: navStack) {
            router.rootView()
        }
    }
}

重要

请注意,NavigationStackView在视图之间进行导航,并且两个视图可能小于整个屏幕。在这种情况下,过渡动画不会涉及整个屏幕,而只是两个视图。让我们来看一个例子。

struct Root: View {
    var body: some View {
        NavigationStackView {
            A()
        }
    }
}

struct A: View {
    var body: some View {
        VStack(spacing: 50) {
            Text("Hello World")
            PushView(destination: B()) {
                Text("PUSH")
            }
        }
        .background(Color.green)
    }
}

struct B: View {
    var body: some View {
        PopView {
            Text("POP")
        }
        .background(Color.yellow)
    }
}

结果如下

Jan-10-2020 15-47-43

过渡动画只使用最小的空间,使视图能够进入/退出屏幕(即在此示例中为视图1和视图2之间的最大宽度),这正是预期的效果。

另一方面,您可能还希望使用NavgationStackView导航屏幕。由于在SwiftUI中,屏幕(旧的UIKit ViewController)只是一个View,因此我建议您创建一个方便且简单的自定义视图,称为Screen,如下所示

extension Color {
    static let myAppBgColor = Color.white
}

struct Screen<Content>: View where Content: View {
    let content: () -> Content

    var body: some View {
        ZStack {
            Color.myAppBgColor.edgesIgnoringSafeArea(.all)
            content()
        }
    }
}

现在我们可以使用Screen视图重写上面的例子

struct Root: View {
    var body: some View {
        NavigationStackView {
            A()
        }
    }
}

struct A: View {
    var body: some View {
        Screen {
            VStack(spacing: 50) {
                Text("Hello World")
                PushView(destination: B()) {
                    Text("PUSH")
                }
            }
            .background(Color.green)
        }
    }
}

struct B: View {
    var body: some View {
        Screen {
            PopView {
                Text("POP")
            }
            .background(Color.yellow)
        }
    }
}

这次过渡动画涉及整个屏幕

Jan-10-2020 16-10-59

问题

  • SwiftUI会在每次视图从视图层次结构中被移除时重置标记有@State的所有视图属性。对于NavigationStackView来说,这是一个问题,因为当您从之前的视图返回(执行弹出操作)时,您希望所有的视图控制都像您离开之前那样(例如,您希望您的TextField包含之前输入的文本)。似乎解决这个问题的解决方案是使用指定id的.id修饰符,这样SwiftUI就不会重置我想要的视图。根据Apple文档中的描述,.id修饰符

摘要:生成一个唯一标识的视图,可以在其中插入或删除。

但是,这个API似乎目前还没有按预期工作(看看这个有趣的帖子:https://swiftui-lab.com/swiftui-id/)。为了解决这个问题,因此,当您需要使一些状态在推送/弹出操作之间保持持久时,您必须使用@ObservableObject。例如

class ViewModel: ObservableObject {
    @Published var text = ""
}

struct MyView: View {
    @ObservedObject var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            TextField("Type something...", text: $viewModel.text)
            PushView(destination: MyView2()) {
                Text("PUSH")
            }
        }
    }
}

其他

SwiftUI真正是新颖的,框架(或意外行为)中有一些错误,并且有几个API尚未文档化。请汇报任何可能出现的问题,并且自由地提出对导航栈第一次实现的改进或更改建议。