导航器 (Navigator)

ci workflow swift v5.5 Platforms: iOS, watchOS deployment target iOS 14, watchOS 7

一个导航库,它将 SwiftUI 中的导航逻辑与视图分离,并允许您在应用程序中以编程方式动态导航到其他视图。

特性

  1. 导航堆栈 (Navigation Stack): 存储应用程序的 SwiftUI 导航状态
  2. 动态导航 (Dynamic Navigation): 运行时完全编程化的导航。无需静态硬编码的视图目的地
  3. 开箱即用的简单导航 API: navigate(to:) pop() popToRoot()
struct DetailScreen: ScreenView {
    @EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>    
    @State var showNextScreen: Bool = false
    var screenId: ScreenID
    
    var body: some View {
        Button("Next") {
            navigator.navigate(to: anotherScreen())
        }
        .navigationTitle("Detail Screen")
        .bindNavigation(self, binding: $showNextScreen)
    }
}

设置

1. 创建一个 ViewFactory

创建一个枚举,将应用程序中的屏幕映射到相应的屏幕类型,并包含任何所需的值。确保它符合 Hashable 协议

enum ScreenID: Hashable {
    case rootScreen
    case detailScreen(detailID: String)
}

然后创建一个符合 ViewFactory 协议的类,并实现 makeView(screenType:) 方法,以便给定的 ScreenID 枚举返回关联的 View

class AppViewFactory: ViewFactory {
    
    @ViewBuilder
    func makeView(screenType: ScreenWrapper<ScreenID>) -> some View {
        switch screenType {
        case .screenWrapper(let screenId):
            switch screenId {
            case .rootScreen:
                RootScreen()
            case .detailScreen:
                DetailScreen(screenId: screenId!)
            case .none:
            // EmptyView is fine in the exhaustive case
            // as this is just a placeholder until the 
            // Navigation state is instantiated. The
            // app will never need to navigate to this
                EmptyView()
            }
        }
    }
}

2. 初始化库

@main 应用程序声明中,将您的根视图作为导航堆栈的顶层视图(或者您想放置带有编程导航的 NavigationView 的任何位置),使用 with(_:) 方法初始化 NavigationView,以注入您的 Navigator 实例。

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView.with(Navigator(rootScreen: ScreenID.rootScreen, viewFactory: AppViewFactory()) {
                RootScreen()
            }    
        }
    }
}

3. 使应用程序顶层视图符合 ScreenView 协议

对于每个顶层视图,使其符合 ScreenView 协议,并应用 bindNavigation(_:binding:) 视图修饰符

struct RootScreen: ScreenView {
    @EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>    
    @State var showNextScreen: Bool = false
    var screenId = .rootScreen
    
    var body: some View {
        List {
            Button("Navigate to Deatil Screen") {
                // TODO
            }
        }
        .navigationTitle("Root Screen")
        .bindNavigation(self, binding: $showNextScreen)
    }
}
struct DetailScreen: ScreenView {
    @EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>    
    @State var showNextScreen: Bool = false
    var screenId: ScreenID
    
    var body: some View {
        VStack {
            Text("Here is some amazing detail.")
            
            Button("Go Back") {
                // TODO
            }
            .tint(.red)
            .buttonStyle(.borderedProminent)
            .buttonBorderShape(.automatic)
            .controlSize(.large)
        }
        .navigationTitle("Detail Screen")
        .bindNavigation(self, binding: $showNextScreen)
    }
}

请注意,除了 RootScreen 之外,所有其他屏幕都应该在其 ScreenID 枚举值中具有一个 id,以便区分导航堆栈中的相同屏幕。 这可以是业务逻辑 ID detailID 的形式,也可以是一些没有业务逻辑但仅用于识别屏幕的默认 ID

enum ScreenID: Hashable {
    // ...
    case anotherScreen(id: UUID = UUID())
}

4. 使用 Navigator 实例进行导航

完成所有设置后,我们可以在 ScreenView 视图中使用 Navigator 实例来执行系统级别的视图导航

struct RootScreen: ScreenView {
    @EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>
    @State var showNextScreen: Bool = false
    var screenId = .rootScreen
    
    var body: some View {
        List {
            Button("Navigate to Deatil Screen") {
                // Use navigate(to:) to navigate to another ScreenView
                
                // This can be anywhere at the view level
                
                // This can also be called at an abstraction layer like a ViewModel
                
                navigator.navigate(to: ScreenID.detailScreen(detailID: "detail-123"))
            }
        }
        .navigationTitle("Root Screen")
        .bindNavigation(self, binding: $showNextScreen)
    }
}
struct DetailScreen: ScreenView {
    @EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>
    @State var showNextScreen: Bool = false
    var screenId: ScreenID
    
    var body: some View {
        VStack {
            Text("Here is some amazing detail.")
            
            Button("Go Back") {
                // Use pop() to navigate back to the
                // previous view by popping the current
                navigator.pop()
            }
            .tint(.red)
            .buttonStyle(.borderedProminent)
            .buttonBorderShape(.automatic)
            .controlSize(.large)
        }
        .navigationTitle("Detail Screen")
        .bindNavigation(self, binding: $showNextScreen)
    }
}

高级

如前所述,Navigator 类具有属性 navStack,它保留 ScreenView 关联的 ScreenID 枚举值的堆栈 (OrderedDictionary),这些值当前在 NavigationView 中处于活动状态。 客户端可以使用它来执行自定义导航逻辑。

    /// Holds the ordered uniqe set of screens that makes up the Navigation State of the client app,
    /// along with its associated Boolean subject, which toggles the NavigationLink.isActive
    var navStack: OrderedDictionary<ScreenIdentifer, CurrentValueSubject<Bool, Never>> { get }
    

例如,我们可以使用它在 Navigator 上创建一个扩展函数,名为 popToDetailWithSpecificIdOrRoot(id: String)

extension Navigator where ScreenIdentifer == ScreenID {
    func popToDetailWithSpecificIdOrRoot(id: String) {
    
        // Since all screens that have been pushed onto the NavigationView
        // are stored in the navStack as keys, we can simply search through
        // them for the specific detail screen with the called id
        
        if let detailScreen: ScreenID = navStack.keys.elements.first(where: {
            if case ScreenID.detailScreen(let detailID) = $0 { 
                return detailID == id 
            }
            return false
        }) {
        
            // Using the first(where:) collections function
            // if we find the detail screen with the called id
            // then the navStack Dictionary can be keyed
            // on the found detailScreen to return the Combine subject
            // that controls the NavigationLink.isActive binding
            // for that detailScreen
            let detailScreenIsActiveBinding = navStack[detailScreen]!
            
            // Sending the false flag to the NavigationLink.isActive binding
            // will dismiss all views off the NavigationView stack to reveal
            // the detailScreen
            detailScreenIsActiveBinding.send(false)
        } else {
        
            // if the detail screen is not found in the current stack
            // then we default to pop all views off the stack to reveal the
            // rootScreen view
            let rootScreenNavigationIsActiveBinding = navStack.elements[0]
            rootScreenNavigationIsActiveBinding.value.send(false)
            
            // could also call self.popToRoot()
        }
    }
}

由于 Navigator 类可以是一个单例,而不与任何特定视图耦合,因此我们可以在应用程序中的任何位置调用 popToDetailWithSpecificIdOrRoot(id:)

@main
struct MyApp: App {

    // Keep reference to the navigator, either as a local property or in an abstraction
    // layer, such as an AppCoordinator
    let navigator = Navigator(rootScreen: ScreenID.rootScreen, viewFactory: AppViewFactory())

    var body: some Scene {
        WindowGroup {
            NavigationView.with(navigator) {
                RootScreen()
            }          
            .task {
                // mimic a system event, such as a notification 
                    
                // create an artificial delay of 10 seconds after the app starts up
                try! await Task.sleep(nanoseconds: 10_000_000_000)
                 
                // User navigates to different views, adding to the navStack
                
                // However far along the user is, this will pop to the first 
                // detailScreen with called id, or pop to the root screen
                navigator.popToDetailWithSpecificIdOrRoot(id: "detail-123")
            }
        }
    }
}

鸣谢

非常感谢 Obsured Pixels' 的精彩文章,它启发了这个库。