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 中,我们有一些视图来管理导航:NavigationView
和 NavigationLink
。目前这些视图存在一些限制
- 我们无法禁用过渡动画;
- 我们无法自定义过渡动画;
- 我们无法导航到根视图(即第一个应用视图),或到特定视图;
- 我们无法使用视图进行程序化推入;
NavigationStackView
是一个视图,它模拟了标准 NavigationView
所有的行为,但它添加了上述所列的功能。您必须把视图层次结构包裹在 NavigationStackView
中
import NavigationStack
struct RootView: View {
var body: some View {
NavigationStackView {
MyHome()
}
}
}
您甚至可以以不同的方式自定义过渡和动画。此 NavigationStackView
将将它们应用于层次结构
- 您可以选择不使用任何过渡,通过这种方式创建导航堆栈
NavigationStackView(transitionType: .none)
; - 您可以使用自定义过渡创建导航堆栈
import NavigationStack
struct RootView: View {
var body: some View {
NavigationStackView(transitionType: .custom(.scale)) {
MyHome()
}
}
}
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)
}
}
结果如下
过渡动画只使用最小的空间,使视图能够进入/退出屏幕(即在此示例中为视图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)
}
}
}
这次过渡动画涉及整个屏幕
问题
- 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尚未文档化。请汇报任何可能出现的问题,并且自由地提出对导航栈第一次实现的改进或更改建议。