├── .config ├── config.yml └── hosts.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.swift ├── README.md ├── Sources └── AppRouter │ ├── DismissableStackWrapper.swift │ ├── Link.swift │ ├── ObservedStack.swift │ ├── Route.swift │ ├── RoutePresentation.swift │ ├── Router.swift │ ├── Stack.swift │ └── StackController.swift └── Tests └── RouterTests └── RouterTests.swift /.config/config.yml: -------------------------------------------------------------------------------- 1 | # What protocol to use when performing git operations. Supported values: ssh, https 2 | git_protocol: https 3 | # What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment. 4 | editor: 5 | # When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled 6 | prompt: enabled 7 | # A pager program to send command output to, e.g. "less". Set the value to "cat" to disable the pager. 8 | pager: 9 | # Aliases allow you to create nicknames for gh commands 10 | aliases: 11 | co: pr checkout 12 | # The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport. 13 | http_unix_socket: 14 | # What web browser gh should use when opening URLs. If blank, will refer to environment. 15 | browser: 16 | -------------------------------------------------------------------------------- /.config/hosts.yml: -------------------------------------------------------------------------------- 1 | github.com: 2 | user: ViewModifier 3 | git_protocol: https 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "AppRouter", 8 | platforms: [ 9 | .iOS(.v16), 10 | .macOS(.v13) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "AppRouter", 16 | targets: ["AppRouter"]), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package, defining a module or a test suite. 20 | // Targets can depend on other targets in this package and products from dependencies. 21 | .target( 22 | name: "AppRouter"), 23 | .testTarget( 24 | name: "AppRouterTests", 25 | dependencies: ["AppRouter"]), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AppRouter Swift Package 2 | 3 | ## Overview 4 | 5 | `AppRouter` is a Swift package designed to streamline navigation in SwiftUI applications. 6 | 7 | ## Key Features 8 | 9 | - **Custom Route Definitions:** Define app-specific routes using `AppRoute` protocol. 10 | - **Versatile Presentation Modes:** Support for page navigation, fullscreen covers, and sheet presentations. 11 | 12 | ## Usage 13 | 14 | ### Define Routes 15 | 16 | Implement the `AppRoute` protocol to create custom routes for your app. 17 | 18 | ```swift 19 | public enum Route: AppRoute { 20 | case home 21 | case profile(_ userId: String) 22 | case settings 23 | 24 | @ViewBuilder 25 | var content: some View { 26 | switch self { 27 | case .home: 28 | HomeView() 29 | case .profile(let userId): 30 | ProfileView(userId: userId) 31 | case .settings: 32 | SettingsView() 33 | } 34 | } 35 | } 36 | ``` 37 | 38 | ### Setting up the Router 39 | 40 | Define a router type with your custom routes. 41 | 42 | ```swift 43 | public typealias Router = AppRouter 44 | ``` 45 | 46 | ### Router Integration in SwiftUI Views 47 | 48 | Use the `Router.Stack` in your SwiftUI views to implement navigation. 49 | 50 | - Suggested to add at the root view of your app. 51 | - Add Stacks to create nested navigation flows. 52 | 53 | ```swift 54 | Router.Stack { 55 | HomeView() // Your root view 56 | } 57 | ``` 58 | 59 | ### Example Navigation Implementation 60 | 61 | Navigate using `Link` or `StackController`. 62 | 63 | #### Using `Link` 64 | 65 | ```swift 66 | struct HomeView: View { 67 | var body: some View { 68 | VStack { 69 | Router.Link(to: .profile("user-id")) { 70 | Text("Go to Profile") 71 | } 72 | 73 | Router.Link(to: .settings, presentation: .sheet([.medium])) { 74 | Text("Open Settings") 75 | } 76 | } 77 | } 78 | } 79 | ``` 80 | 81 | #### Using `StackController` 82 | 83 | ```swift 84 | struct HomeView: View { 85 | @EnvironmentObject 86 | var stackController: Router.StackController 87 | 88 | var body: some View { 89 | VStack { 90 | Button("Go to Profile") { 91 | stackController.push(route: .profile("user-id")) 92 | } 93 | 94 | Button("Open Settings") { 95 | stackController.present(route: .settings, with: [.medium]) 96 | } 97 | } 98 | } 99 | } 100 | ``` 101 | -------------------------------------------------------------------------------- /Sources/AppRouter/DismissableStackWrapper.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AppRouter { 4 | struct DismissableStackWrapper: View where Root : View { 5 | @StateObject 6 | var stackController: StackController 7 | 8 | @ViewBuilder 9 | public var root: Root 10 | 11 | public init( 12 | dismiss: DismissAction, 13 | @ViewBuilder root: () -> Root 14 | ) { 15 | self._stackController = StateObject( 16 | wrappedValue: .init(dismiss: dismiss) 17 | ) 18 | 19 | self.root = root() 20 | } 21 | 22 | public var body: some View { 23 | AppRouter.ObservedStack( 24 | stack: self.stackController 25 | ) { 26 | self.root 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/AppRouter/Link.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AppRouter { 4 | struct Link: View { 5 | @EnvironmentObject var stack: StackController 6 | 7 | var route: Route 8 | var presentation: RoutePresentation 9 | 10 | let content: Content 11 | 12 | public init( 13 | to route: Route, 14 | presentation: RoutePresentation = .page, 15 | @ViewBuilder content: () -> Content 16 | ) { 17 | self.route = route 18 | self.presentation = presentation 19 | self.content = content() 20 | } 21 | 22 | public var body: some View { 23 | Button { 24 | switch self.presentation { 25 | case .page: 26 | stack.push(route: self.route) 27 | break 28 | case .sheet(let detents): 29 | stack.present(route: self.route, with: detents) 30 | break 31 | case .fullscreenCover: 32 | stack.presentFullScreenCover(route: self.route) 33 | break 34 | } 35 | } label: { 36 | content 37 | } 38 | .buttonStyle(.plain) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/AppRouter/ObservedStack.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AppRouter { 4 | struct ObservedStack : View where Root : View { 5 | @ObservedObject var stack: AppRouter.StackController 6 | 7 | @ViewBuilder 8 | var root: Root 9 | 10 | public var body: some View { 11 | NavigationStack( 12 | path: self.$stack.path 13 | ) { 14 | self.root 15 | .navigationDestination( 16 | for: Route.self 17 | ) { 18 | $0.content 19 | .frame(maxHeight: .infinity) 20 | .navigationTitle("") 21 | } 22 | .frame(maxHeight: .infinity) 23 | .navigationTitle("") 24 | } 25 | .accentColor(Color.primary) 26 | .environmentObject(self.stack) 27 | .sheet(item: self.$stack.sheetRoute) { sheetRoute in 28 | AppRouter.Stack { 29 | sheetRoute.route.content 30 | } 31 | .presentationDetents(sheetRoute.presentation) 32 | } 33 | #if os(iOS) 34 | .fullScreenCover( 35 | item: self.$stack.fullScreenCoverRoute 36 | ) { coverRoute in 37 | AppRouter.Stack { 38 | coverRoute.content 39 | } 40 | } 41 | #endif 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/AppRouter/Route.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public protocol AppRoute: Identifiable, Hashable, Codable { 4 | associatedtype RouteView: View 5 | 6 | var content: RouteView { get } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/AppRouter/RoutePresentation.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public enum RoutePresentation: Identifiable, Hashable { 4 | case page 5 | case fullscreenCover 6 | case sheet(detents: Set = [PresentationDetent.large]) 7 | 8 | public var id: Self { 9 | return self 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/AppRouter/Router.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct AppRouter {} 4 | -------------------------------------------------------------------------------- /Sources/AppRouter/Stack.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AppRouter { 4 | struct Stack: View where Root : View { 5 | @Environment(\.dismiss) 6 | var dismiss 7 | 8 | @ViewBuilder 9 | public var root: Root 10 | 11 | public init( 12 | @ViewBuilder root: () -> Root 13 | ) { 14 | self.root = root() 15 | } 16 | 17 | public var body: some View { 18 | AppRouter.DismissableStackWrapper( 19 | dismiss: self.dismiss 20 | ) { 21 | self.root 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/AppRouter/StackController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | public extension AppRouter { 5 | struct SheetRoute: Identifiable { 6 | public var id: Route { 7 | self.route 8 | } 9 | 10 | let route: Route 11 | let presentation: Set 12 | 13 | public init( 14 | route: Route, 15 | presentation: Set 16 | ) { 17 | self.route = route 18 | self.presentation = presentation 19 | } 20 | } 21 | } 22 | 23 | public extension AppRouter { 24 | class StackController: ObservableObject { 25 | @Published 26 | var path: NavigationPath 27 | 28 | var currentRoute: Route? { 29 | Self.lastRoute(from: self.path) 30 | } 31 | 32 | @Published 33 | var sheetRoute: SheetRoute? 34 | 35 | @Published 36 | var fullScreenCoverRoute: Route? 37 | 38 | var dismissStack: DismissAction 39 | 40 | public init( 41 | path: NavigationPath = NavigationPath(), 42 | dismiss: DismissAction 43 | ) { 44 | self.path = path 45 | self.dismissStack = dismiss 46 | } 47 | 48 | public func goBack(_ count: Int = 1) { 49 | guard canGoBack() else { return } 50 | 51 | self.path.removeLast(count) 52 | } 53 | 54 | public func reset() { 55 | self.path = .init() 56 | } 57 | 58 | public func canGoBack() -> Bool { 59 | self.path.isEmpty == false 60 | } 61 | 62 | public func push(route: Route) { 63 | self.path.append(route) 64 | print("new count: \(self.path.count)") 65 | } 66 | 67 | public func present(route: Route, with presentation: Set) { 68 | self.sheetRoute = .init( 69 | route: route, 70 | presentation: presentation 71 | ) 72 | } 73 | 74 | public func presentFullScreenCover(route: Route) { 75 | self.fullScreenCoverRoute = route 76 | } 77 | 78 | public func dismiss() { 79 | self.dismissStack() 80 | } 81 | 82 | public func dismissSheet() { 83 | self.sheetRoute = nil 84 | } 85 | 86 | public func dismissFullScreenCover() { 87 | self.fullScreenCoverRoute = nil 88 | } 89 | } 90 | } 91 | 92 | 93 | extension AppRouter.StackController { 94 | // FIXME: Theres a way to clean this up a bit 95 | static func lastRoute(from path: NavigationPath) -> Route? { 96 | guard 97 | let pathData = try? JSONEncoder().encode(path.codable), 98 | let pathArr = try? JSONDecoder().decode([String].self, from: pathData), 99 | pathArr.count >= 2, 100 | let data = pathArr[1].data(using: .utf8), 101 | let route = try? JSONDecoder().decode(Route.self, from: data) 102 | else { 103 | return nil 104 | } 105 | 106 | return route 107 | } 108 | } 109 | 110 | -------------------------------------------------------------------------------- /Tests/RouterTests/RouterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Router 3 | 4 | final class RouterTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------