20 Aug 2021SwiftUI导航和路由
发布者: Adrian Stoyles
在WWDC 2019上,苹果向苹果开发社区宣布了SwiftUI。SwiftUI见证了苹果平台上应用开发的未来,从UIKit的命令式风格转向越来越流行的数据驱动的声明式风格。在这篇文章中,我们将讨论和探索SwiftUI中的导航流和视图构建方法。特别是,我们将探索一种将这两种活动从视图层中抽象出来的方法。
在我们开始之前
在我们开始之前,让我们回顾一下UIKit和SwiftUI在导航方面的一些区别。
UIKit是一个强制性的UI框架,提供了多种管理导航的方法。下面列举几个:
- A
[ViewController](https://developer.apple.com/documentation/uikit/view_controllers)可以在内部呈现或推送另一个ViewController。 ViewController的外部对象可以请求它进行导航。- A
[NavigationController](https://developer.apple.com/documentation/uikit/uinavigationcontroller)的堆栈可以通过各种方式进行突变(popToRoot,setViewControllers, 等等) - 故事板片段
有了这样的自由度,在一个代码库中发现混合的导航做法是很正常的。各种架构和框架(Coordinator、VIPER、RIBs)通过将导航和流控制整合到一个专门的组件(如路由器或协调器)中来解决这个问题。将导航从ViewController 层中抽象出来,可以提高ViewController 的可重用性,并为整个代码库提供更大的可测试性。
另一方面,SwiftUI是一个声明式的UI框架,其中导航和流程控制被严格限制在视图层中。虽然这种方法确实给应用程序的导航灌输了更高层次的可预测性,但在View 可重用性和可测试性方面存在一些缺陷。苹果公司的导航例子倾向于将呈现的View 与呈现的View 。
一个例子
下面是SwiftUI中一个常见的程序化导航的例子,其中ViewA ,将ViewB 推到导航栈中:
struct ViewA: View {
@State var navigateToViewB: Bool = false
var body: some View {
NavigationView {
VStack {
Button("Go to ViewB") {
doSomethingAsync() {
self.navigateToViewB = true
}
}
NavigationLink(
destination: ViewB(
viewModel: .init(
userRepo: .init()
)
),
isActive: $navigateToViewB,
label: {
EmptyView()
}
)
}
}
}
}
我们马上就能看到这种方法的一些问题:
ViewA需要与依赖关系一起构建 。ViewBViewA绑定后只能导航到 ,不能在另一个上下文中重复使用。ViewBViewB不能在 测试中被模拟。ViewViewA管理导航目的地的状态。
已经有一些框架在SwiftUI的导航系统之上工作,以提供一种类似于UIKit的方法(例如SwiftUIRouter的基于路径的路由)。在这篇文章中,我们想要实现的是创建一个模式,在以类型和null-safe的方式保留SwiftUI的声明式导航方法的同时,还能实现以下目标:
- 从
ViewA移除ViewB结构和相关的依赖关系。 - 在
ViewA中注入一个路由对象,它可以在导航被触发时提供一个View。 - 允许路由对象的范围从应用/全局域下放到本地域,以实现模块化。
- 启用业务逻辑层来决定导航流。
为了让大家熟悉,我们将看一下使用MVVM架构的解决方案。然而,这些原则也可以很好地适用于其他模式。
视图构建
ViewB 第一个目标是移除构建ViewA 的责任,并将此任务委托给另一个对象。我们将这个对象称为Router 。为了将Router 的具体实现从View ,我们将创建一个协议,它将符合这个协议。我们还将添加一个名为Route 的相关类型,这样我们就可以在其他类型上创建其他的Router:
protocol Routing {
associatedtype Route
associatedtype View: SwiftUI.View
@ViewBuilder func view(for route: Route) -> Self.View
}
在这种情况下,Route 将是一个枚举,我们的两个屏幕作为案例viewA 和viewB:
enum AppRoute {
case viewA
case viewB
}
在具体实现中,称为AppRouter ,我们需要提供一个Environment 对象。这包含了建立一个View 所需的依赖关系。同时注意,我们需要更新我们的View ,以需要一个Router 的属性:
struct AppRouter: Routing {
let environment: Environment
func view(for route: AppRoute) -> some View {
switch route {
case .viewA:
ViewA(router: self)
case .viewB:
ViewB(
router: self,
viewModel: .init(
userRepo: environment.userRepo
)
)
}
}
}
现在我们的Router 已经创建,我们可以把它注入到ViewA ,并委托构建ViewB 。鉴于Routing 包含相关的类型,我们需要使我们的视图在协议类型上通用,并将Route 类型限制在AppRoute:
struct ViewA<Router: Routing>: View where Router.Route == AppRoute {
let router: Router
@State var navigateToViewB: Bool = false
var body: some View {
NavigationView {
VStack {
Button("Go to ViewB") {
doSomethingAsync() {
self.navigateToViewB = true
}
}
NavigationLink(
destination: router.view(for: .viewB),
isActive: $navigateToViewB,
label: {
EmptyView()
}
)
}
}
}
}
尽管对ViewA 结构的改变不大,但我们在这里实现了一些事情:
ViewA不再需要知道如何构建 或它的任何依赖关系。ViewBViewA不受约束地只呈现 。ViewBRouter是通用的,因此ViewA可以通过不同的Router实现进行初始化。ViewA可以在其他情况下使用,并在完成 时展示其他 。doSomethingAsyncView
创建和分发路由器
正如上面的例子所展示的,Router 是通过视图层次结构从视图到视图向下传递的。在一个应用程序的上下文中,Router 可以在@main View 上初始化,并从该点开始向下传递视图层次结构。根据应用程序的规模,将Router分成更多的模块化组件可能是有益的。不是每个视图都需要知道每个Route 。例如,你可能有一个专门用于认证模块的路由器:
enum AuthRoute {
case signIn
case signUp
case forgotPassword
}
struct AuthRouter: Routing {
let environment: AuthEnvironment
func view(for route: AuthRoute) -> some View {
switch route {
case .signIn:
SignInView(
viewModel: AuthViewModel(
userRepo: environment.userRepo
),
router: self
)
case .signUp:
SignUpView(
viewModel: AuthViewModel(
userRepo: environment.userRepo),
router: self
)
case .forgotPassword:
ForgotPasswordView(
viewModel: AuthViewModel(
userRepo: environment.userRepo),
router: self
)
}
}
}
导航流程
到目前为止,这个例子只说明了从ViewA 到ViewB 的单一导航情况。如果我们要添加另一个NavigationLink 到ViewA ,我们应该避免进一步添加@State Bool 的变量来表示导航状态。例如,在一个时间点上,应该只有一个导航是活跃的。
如果我们以某种方式结束了一个不止一个导航处于激活状态,会发生什么?幸好还有一个NavigationLink ,我们可以使用。它需要一个与任何Optional Hashable 类型的绑定。当绑定的值被设置为tag ,导航就被激活了:
public init<V>(
destination: Destination,
tag: V,
selection: Binding<V?>,
@ViewBuilder label: () -> Label
) where V : Hashable
使用这种激活方法,navigateToViewB 可以用一个可选的AppRoute 来代替,并改名为更通用的activeNavigation 。在这种情况下,适应性很简单,因为我们的枚举AppRoute 是自动的Hashable 。请注意,包含有关联类型的枚举将需要进一步的工作来使类型符合Hashable:
struct ViewA<Router: Routing>: View where Router.Route == AppRoute {
let router: Router
@State var activeNavigation: AppRoute?
var body: some View {
NavigationView {
VStack {
Button("Go to ViewB") {
doSomethingAsync() {
self.activeNavigation = .viewB
}
}
NavigationLink(
destination: router.view(for: .viewB),
tag: .viewB,
selection: $activeNavigation,
label: { EmptyView() }
)
}
}
}
}
现在,ViewA 可以指定AppRoute 类型的活动导航,为新的案例添加更多的NavigationLinks 也很简单。此外,如果路线是CaseIterable ,就有可能在一个ForEach 视图中创建多个NavigationLinks。例如,如果你在整个应用中使用一个单一的Router :
ForEach(
Array(Router.Route.allCases),
id: \.self
) {
NavigationLink(
destination: router.view(for: $0),
tag: $0,
selection: $activeNavigation,
label: { EmptyView() }
)
}
然而,这在很多情况下可能并不理想。
接下来,我们可以看看如何将activeNavigation 从视图层移到ViewModel 。
为了做到这一点,我们定义一个基本的ViewModel 协议。
protocol ViewModel: ObservableObject {
associatedtype Route
var activeNavigation: Route? { get set }
}
在一个ViewModel 的实现中,activeNavigation 可以被设置为任何Route 的类型之一。当一个流程需要偏离时,这是很有用的,也许是由于服务请求的特殊响应或其他一些状态。
把它绑在一起
现在我们已经定义了模式中每个对象的角色和职责,我们可以指定一些规则和协议,以便为应用程序执行一致的导航模式:
View:注册并执行导航ViewModel:通过发布一个活动的导航属性来驱动流状态Router:为给定的环境构建具有依赖性的视图
Routing 已经被定义为:
protocol Routing {
associatedtype Route: RouteType
associatedtype Body: View
@ViewBuilder func view(for route: Route) -> Self.Body
}
我们将Route 限制在以下符合性上:
typealias RouteType = Hashable & Identifiable & CaseIterable
接下来,我们可以为我们的ViewModels定义一个协议来遵循。请注意,对于ViewModel 的实现,navigationRoute 将需要包裹在@Published 属性包装器中:
protocol ViewModel: ObservableObject {
associatedtype Route: RouteType
var navigationRoute: Route? { get set }
}
最后,我们为我们的Views 定义一个协议。这就把我们所有的部分整合在一起,并强制要求ViewModel 、View 和Router 都是同一个Route 类型上的泛型。
protocol AppView: View {
associatedtype VM: ViewModel
associatedtype Router: Routing where Router.Route == VM.Route
var viewModel: VM { get }
var router: Router { get }
}
结语
在这篇文章中,我介绍了一种模式,用于在整个SwiftUI应用中实现一致的导航方法。该模式需要依赖性注入,并且是可测试的。它还支持下探到更小的领域,这意味着功能可以模块化,不需要绑定到应用程序领域。
虽然如此,但一如既往地有进一步的考虑。这些例子是孤立的、简单的,并不涉及复杂的流程,其中View 和ViewModel 的外部状态可能会影响流程。我们将继续探索更高级的用例,并报告其进展情况。然而,到目前为止,这个模式是朝着正确方向迈出的令人鼓舞的一步。