Skip to content

silkodenis/swiftui-navigation-coordinator

Repository files navigation

License IOS

SwiftUI Navigation Coordinator

Screenshot 1 Screenshot 2 Screenshot 3 Screenshot 4

About the Project

This project provides a lightweight Navigation Coordinator, using SwiftUI NavigationStack (available from iOS 16).

Core Features

The current implementation covers 6 main transitions:

Stack Navigation:
  • push — navigates forward to a new view.
  • pop — returns to the previous view.
  • unwind — performs a multi-level return.
  • popToRoot — returns to the root view.
Modal Presentation:
  • present — displays a modal view, overlaying it on top of current content.
  • dismiss — closes the current modal view and returns to the underlying content.

Requirements

  • iOS 16.0+

Why This Is Interesting

  • The implementation of the unwind transition may be of particular interest to those who have already attempted to create similar transitions in SwiftUI.
  • In addition to the specific task of multi-level return, the unwind() can also be used instead of the usual pop() when it is necessary to pass data back to the previous screen. The onUnwind() call will always be made before the onAppear() call.

Usage Examples

Push
struct SomeView: View {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    var body: some View {
        Button("info") {
            coordinator.push(.info)
        }
    }
}
Pop
struct SomeView: View {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    var body: some View {
        Button("back") {
            coordinator.pop()
        }
    }
}
PopToRoot
struct SomeView: View {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    var body: some View {
        Button("login") {
            coordinator.popToRoot()
        }
    }
}
Unwind Use a unique identifier for your unwind segues. If a segue becomes no longer relevant, it will be automatically removed from the coordinator. Using `onUnwind()` modifier is completely safe, tested, and does not involve any memory leaks or unintended calls.
// B View
// 🟦🟦🅰🟦🟦🟦🟦🟦🟦🅱️  
struct B: View {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    var body: some View {
        Button("pop to A") {
            coordinator.unwind(to: "identifier" /*, with: Any?*/)
        }
    }
}

// A View
// 🟦🟦🅰️
struct A: View {
    var body: some View {
        VStack {}
            .onUnwind(segue: "identifier") /*{ Any? in }*/
    }
}

onUnwind() will always be called before onAppear().

Present
/*
               [B]
[ ][ ][ ][ ][ ][A]
*/
struct A: View {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    var body: some View {
        Button("present") {
            coordinator.present(.B)
        }
    }
}
Dismiss
/*
               [B][ ][ ][ ][CL]
[ ][ ][ ][ ][ ][A]
*/
struct CL: View {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    var body: some View {
        Button("dismiss") {
            coordinator.dismiss(/*to: "identifier" /*, with: Any?*/*/)
        }
    }
}

/*
[ ][ ][ ][ ][ ][A]
*/
struct A: View {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    var body: some View {
        VStack {}
            // Not necessary. Only if you need to capture an onDismiss event.
            .onDismiss(segue: "identifier") /*{ Any? in }*/
    }
}

Using into Your Project

Add NavigationCoordinator to your project.

Configure the App to start with `RootView` as the initial view.
import SwiftUI

struct RootView: View {
    @ObservedObject private var coordinator: NavigationCoordinator<Screen>
    private let root: Screen
    
    internal init(_ root: Screen, withParent coordinator: NavigationCoordinator<Screen>? = nil) {
        self.root = root
        self.coordinator = NavigationCoordinator<Screen>()
        self.coordinator.parent = coordinator
    }
    
    var body: some View {
        NavigationStack(path: $coordinator.path) {
            root.view
                .navigationDestination(for: Screen.self) { screen in
                    screen.view
                }
                .sheet(item: $coordinator.modal) { screen in
                    RootView(screen, withParent: coordinator)
                }
        }
        .environmentObject(coordinator)
    }
}
import SwiftUI

@main
struct SomeApp: App {
    var body: some Scene {
        WindowGroup {
            RootView(.login)
        }
    }
}
Configure the `NavigableScreen` for your project.

In the view property, I recommend avoiding direct View initialization. Instead, use your preferred Dependency Injection pattern, such as View Factory, to externally connect various dependencies to your ViewModel.

import SwiftUI

// Example

enum Screen {
    case login
    case movies
    case detail(id: Int)
    case info
    
    /// Used to uniquely identify segues that either navigate back to a previous screen or dismiss a modal view.
    static let toDetail = "toDetail"
    static let toMovies = "toMovies"
}

extension Screen: NavigableScreen {
    // You can set up DI in this property
    @ViewBuilder
    var view: some View {
        switch self {
        case .login:
            viewFactory.makeLoginView()
            
        case .movies:
            viewFactory.makeMoviesView()
            
        case .detail(let id):
            viewFactory.makeDetailView(id)
            
        case .info:
            viewFactory.makeInfoView()
        }
    }
}
Finally, add the `RegisterSegueModifier` to your project.

To implement the onUnwind() and onDismiss() calls in your views, similar to how onAppear() is used.

import SwiftUI

struct RegisterSegueModifier: ViewModifier {
    @EnvironmentObject var coordinator: NavigationCoordinator<Screen>
    
    let type: NavigationCoordinator<Screen>.Segue.SegueType
    let identifier: String
    let action: ((Any?) -> Void)?
    
    func body(content: Content) -> some View {
        content.onAppear {
            coordinator.registerSegue(type, with: identifier, action: action)
        }
    }
}

extension View {
    func onUnwind(segue identifier: String, perform action: ((Any?) -> Void)? = nil) -> some View {
        modifier(RegisterSegueModifier(type: .unwind, identifier: identifier, action: action))
    }
    
    func onDismiss(segue identifier: String, perform action: ((Any?) -> Void)? = nil) -> some View {
        modifier(RegisterSegueModifier(type: .dismiss, identifier: identifier, action: action))
    }
}

Feel free to take it, modify it, and use it as you see fit.

Reporting Issues

I welcome any issues you find within the project. If you encounter bugs or have suggestions for improvements, please feel free to create an issue on the GitHub repository.

License

Apache License 2.0. See the LICENSE file for details.