├── .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 |
--------------------------------------------------------------------------------