├── Example
├── Sources
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── NamedView.swift
│ ├── Router+Extensions.swift
│ ├── ParameterizedView.swift
│ ├── FavoritesView.swift
│ ├── HomeView.swift
│ └── ExampleApp.swift
└── Example.xcodeproj
│ ├── project.xcworkspace
│ └── contents.xcworkspacedata
│ └── project.pbxproj
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── Sources
└── LinkRouting
│ ├── Routable.swift
│ ├── RouteLink.swift
│ ├── TabRouter.swift
│ ├── RouteBox.swift
│ ├── TabNavigator.swift
│ ├── Route.swift
│ ├── Router.swift
│ └── Navigator.swift
├── Package.swift
├── LICENSE
├── Tests
└── LinRoutingTests
│ ├── RoutesExtractionTests.swift
│ ├── TabNavigatorTests.swift
│ └── NavigatorTests.swift
├── .gitignore
└── README.md
/Example/Sources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Sources/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Example/Sources/NamedView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct NamedView: View {
4 | let text: String
5 |
6 | var body: some View {
7 | Text(text)
8 | }
9 | }
10 |
11 | #Preview {
12 | NamedView(text: "Hello World")
13 | }
14 |
--------------------------------------------------------------------------------
/Example/Sources/Router+Extensions.swift:
--------------------------------------------------------------------------------
1 | import LinkRouting
2 | import SwiftUI
3 |
4 | #if DEBUG
5 | extension Router {
6 | static func singleRooted(@ViewBuilder content: @escaping () -> Content) -> Self {
7 | Router.init(routes: [Route(path: "/", builder: { _ in content() })])
8 | }
9 | }
10 | #endif
11 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/Routable.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | public protocol AnyRouteProtocol {
5 | var path: String { get }
6 | var children: [AnyRouteProtocol] { get }
7 | var isModal: Bool { get }
8 | func build(slug: Parameters) -> AnyView
9 | }
10 |
11 | public protocol AnyTabRouteProtocol: AnyRouteProtocol {
12 | func buildLabel() -> AnyView
13 | }
14 |
--------------------------------------------------------------------------------
/Example/Sources/ParameterizedView.swift:
--------------------------------------------------------------------------------
1 | import LinkRouting
2 | import SwiftUI
3 |
4 | struct ParameterizedView: View {
5 | @Environment(Navigator.self) var navigator
6 | @State private var text: String = ""
7 |
8 | var body: some View {
9 | HStack {
10 | Button("open \"/details/") {
11 | navigator.go(to: "/details/\(text)")
12 | }
13 | TextField("dynamic route", text: $text)
14 | }
15 | }
16 | }
17 |
18 | #Preview {
19 | Router.singleRooted(content: ParameterizedView.init)
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/RouteLink.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | public struct RouteLink: View {
5 | @Environment(Navigator.self) var nav
6 | let label: () -> Label
7 | let route: String
8 |
9 | public init(@ViewBuilder label: @escaping () -> Label, route: String) {
10 | self.label = label
11 | self.route = route
12 | }
13 |
14 | public var body: some View {
15 | HStack {
16 | label()
17 | Spacer()
18 | Image(systemName: "chevron.right")
19 | .foregroundColor(.gray)
20 | .font(Font.system(size: 13))
21 | }
22 | .contentShape(Rectangle())
23 | .onTapGesture {
24 | nav.go(to: route)
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Example/Sources/FavoritesView.swift:
--------------------------------------------------------------------------------
1 | import LinkRouting
2 | import SwiftUI
3 |
4 | struct FavoritesView: View {
5 | @Environment(Navigator.self) var navigator
6 |
7 | var body: some View {
8 | VStack(spacing: 16) {
9 | Button("open \"/details\"") {
10 | navigator.go(to: "/details")
11 | }
12 | Button("say \"/hi\"") {
13 | navigator.go(to: "/hi")
14 | }
15 | Button("say \"/hi/myId\"") {
16 | navigator.go(to: "/hi/myId")
17 | }
18 | Button("open unknown page") {
19 | navigator.go(to: "/url/that/does/not/exist")
20 | }
21 | }
22 | }
23 | }
24 |
25 | #Preview {
26 | Router.singleRooted(content: FavoritesView.init)
27 | }
28 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
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: "link-routing",
8 | platforms: [
9 | .macOS(.v14),
10 | .iOS(.v17)
11 | ],
12 | products: [
13 | .library(
14 | name: "LinkRouting",
15 | targets: ["LinkRouting"]
16 | ),
17 | ],
18 | targets: [
19 | .target(
20 | name: "LinkRouting",
21 | path: "Sources"
22 | ),
23 | .testTarget(
24 | name: "LinkRoutingTests",
25 | dependencies: ["LinkRouting"],
26 | path: "Tests"
27 | )
28 | ],
29 | swiftLanguageModes: [.v6]
30 | )
31 |
--------------------------------------------------------------------------------
/Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | },
8 | {
9 | "appearances" : [
10 | {
11 | "appearance" : "luminosity",
12 | "value" : "dark"
13 | }
14 | ],
15 | "idiom" : "universal",
16 | "platform" : "ios",
17 | "size" : "1024x1024"
18 | },
19 | {
20 | "appearances" : [
21 | {
22 | "appearance" : "luminosity",
23 | "value" : "tinted"
24 | }
25 | ],
26 | "idiom" : "universal",
27 | "platform" : "ios",
28 | "size" : "1024x1024"
29 | }
30 | ],
31 | "info" : {
32 | "author" : "xcode",
33 | "version" : 1
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Example/Sources/HomeView.swift:
--------------------------------------------------------------------------------
1 | import LinkRouting
2 | import SwiftUI
3 |
4 | struct HomeView: View {
5 | @Environment(Navigator.self) var navigator
6 |
7 | var body: some View {
8 | VStack {
9 | List {
10 | Button("open \"/favorites\"") {
11 | navigator.go(to: "/favorites")
12 | }
13 | RouteLink(
14 | label: {
15 | Text("open \"/details\"")
16 | },
17 | route: "/details"
18 | )
19 | Button("say \"/hi\" modally") {
20 | navigator.go(to: "/hi")
21 | }
22 | ParameterizedView()
23 | }
24 | }
25 | }
26 | }
27 |
28 | #Preview {
29 | Router.singleRooted(content: HomeView.init)
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/TabRouter.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | struct TabRouter: View {
5 | @State private var navigator: TabNavigator
6 | let routes: [AnyTabRouteProtocol]
7 |
8 | init(routes: [AnyTabRouteProtocol], rootPath: String?) {
9 | self.navigator = try! TabNavigator(tabs: routes, rootPath: rootPath ?? routes.first?.path ?? "/")
10 | self.routes = routes
11 | }
12 |
13 | var body: some View {
14 | TabView(selection: $navigator.rootPath) {
15 | ForEach(navigator.navigators, id: \.rootPath) { nav in
16 | Router(navigator: nav)
17 | .tabItem {
18 | routes.first { route in
19 | route.path == nav.rootPath
20 | }?.buildLabel()
21 | }
22 | .tag(nav.root?.route.pathComponent)
23 | }
24 | }
25 | .environment(navigator)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/RouteBox.swift:
--------------------------------------------------------------------------------
1 | struct RouteBox: Hashable {
2 | func hash(into hasher: inout Hasher) {
3 | hasher.combine(route.pathComponent)
4 | }
5 |
6 | static func == (lhs: RouteBox, rhs: RouteBox) -> Bool {
7 | lhs.route.pathComponent == rhs.route.pathComponent
8 | }
9 |
10 | let route: Crumb
11 | let parameters: Parameters
12 |
13 | init(parameters: Parameters, route: Crumb) {
14 | self.route = route
15 | self.parameters = parameters
16 | }
17 | }
18 |
19 | extension RouteBox: Codable {
20 | enum CodingKeys: String, CodingKey {
21 | case path
22 | }
23 |
24 | func encode(to encoder: any Encoder) throws {
25 | var container = encoder.container(keyedBy: CodingKeys.self)
26 | try container.encode(replaceKey(in: route.pathComponent, from: parameters), forKey: .path)
27 | }
28 |
29 | init(from decoder: any Decoder) throws {
30 | fatalError("Not implemented")
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Kirill Chuyanov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/TabNavigator.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | @Observable
5 | public final class TabNavigator: Navigator {
6 | var navigators: [Navigator] = []
7 |
8 | internal init(tabs: [AnyRouteProtocol], rootPath: String = "/") throws {
9 | try super.init(routes: tabs, root: rootPath)
10 | self.navigators = tabs.map { [unowned self] in
11 | try! Navigator(routes: [$0], root: $0.path, parent: self)
12 | }
13 | }
14 |
15 | override func go(to path: String, caller: Navigator? = nil) {
16 | let nav = navigators.first { nav in
17 | nav.map.map(\.path).contains { hasPath(path, in: $0) }
18 | }
19 |
20 | guard let nav else {
21 | let current = navigators.first(where: { nav in nav.rootPath == rootPath })
22 | current?.path.append(RouteBox(parameters: [:], route: Crumb(pathComponent: "not-found", isModal: false, build: { _ in
23 | AnyView(Text("404: Page Not Found"))
24 | })))
25 | return
26 | }
27 |
28 | rootPath = nav.rootPath
29 | nav.go(to: path)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Example/Sources/ExampleApp.swift:
--------------------------------------------------------------------------------
1 | import LinkRouting
2 | import SwiftUI
3 |
4 | @main
5 | struct ExampleApp: App {
6 | var body: some Scene {
7 | WindowGroup {
8 | Router.tabbed(tabs: [
9 | TabRoute(
10 | path: "/home",
11 | builder: { _ in HomeView() },
12 | labelBuilder: { Label("Home", systemImage: "house") },
13 | children: [
14 | Route(path: "details", builder: { _ in NamedView(text: "Details")}),
15 | Route(path: "hi", builder: { _ in NamedView(text: "Details")}, isModal: true),
16 | Route(path: "hi/:id", builder: { params in NamedView(text: params["id"] ?? "OOps")}, isModal: true),
17 | Route(path: "details/:id", builder: { params in NamedView(text: params["id"] ?? "No param")}, isModal: true),
18 | ]
19 | ),
20 | TabRoute(
21 | path: "/favorites",
22 | builder: { _ in FavoritesView() },
23 | labelBuilder: { Label("Favorites", systemImage: "star") },
24 | children: []
25 | )
26 | ])
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/Route.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | public struct Route: AnyRouteProtocol {
5 | public let path: String
6 | let builder: (Parameters) -> Content
7 | public let children: [AnyRouteProtocol]
8 | public let isModal: Bool
9 |
10 | public init(
11 | path: String,
12 | @ViewBuilder builder: @escaping (Parameters) -> Content,
13 | children: [AnyRouteProtocol] = [],
14 | isModal: Bool = false
15 | ) {
16 | self.path = path
17 | self.builder = builder
18 | self.children = children
19 | self.isModal = isModal
20 | }
21 |
22 | public func build(slug: Parameters) -> AnyView {
23 | AnyView(builder(slug))
24 | }
25 | }
26 |
27 | @MainActor
28 | public struct TabRoute: AnyTabRouteProtocol {
29 | public var path: String
30 | public let isModal = false
31 | public var children: [any AnyRouteProtocol]
32 |
33 | let builder: (Parameters) -> Content
34 | let labelBuilder: () -> Label
35 |
36 | public init(
37 | path: String,
38 | @ViewBuilder builder: @escaping (Parameters) -> Content,
39 | @ViewBuilder labelBuilder: @escaping () -> Label,
40 | children: [any AnyRouteProtocol]
41 | ) {
42 | self.path = path
43 | self.children = children
44 | self.builder = builder
45 | self.labelBuilder = labelBuilder
46 | }
47 |
48 | public func build(slug: Parameters) -> AnyView {
49 | AnyView(builder(slug))
50 | }
51 |
52 | public func buildLabel() -> AnyView {
53 | AnyView(labelBuilder())
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/Router.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | public struct Router: View {
5 | @State private var navigator: Navigator
6 |
7 | static public func tabbed(tabs: [AnyTabRouteProtocol], rootPath: String? = nil) -> some View {
8 | TabRouter(routes: tabs, rootPath: rootPath)
9 | }
10 |
11 | public init(routes: [AnyRouteProtocol], root: String = "/") {
12 | self.navigator = try! Navigator(routes: routes, root: root)
13 | }
14 |
15 | init(navigator: Navigator) {
16 | self.navigator = navigator
17 | }
18 |
19 | public var body: some View {
20 | NavigationStack(path: $navigator.path) {
21 | if let box = navigator.root {
22 | box.route.build(box.parameters)
23 | .frame(maxWidth: .infinity, maxHeight: .infinity)
24 | .navigationDestination(for: RouteBox.self, destination: { box in
25 | box.route.build(box.parameters)
26 | })
27 | } else {
28 | AnyView(Text("404: Page Not Found"))
29 | .frame(maxWidth: .infinity, maxHeight: .infinity)
30 | }
31 | }
32 | .environment(navigator)
33 | .sheet(
34 | item: $navigator.modalPath,
35 | content: {
36 | Router(navigator: navigator.modalNavigator()).id($0)
37 | }
38 | )
39 | .onOpenURL { url in
40 | navigator.go(to: url.host().map { "/" + $0 + url.path() } ?? "/")
41 | }
42 | }
43 | }
44 |
45 | extension String: @retroactive Identifiable {
46 | public var id: Self { self }
47 | }
48 |
--------------------------------------------------------------------------------
/Tests/LinRoutingTests/RoutesExtractionTests.swift:
--------------------------------------------------------------------------------
1 | @testable import LinkRouting
2 | import SwiftUI
3 | import SwiftUICore
4 | import Testing
5 |
6 | struct RoutesExtractionTests {
7 |
8 | @Test
9 | func routesExtraction() async throws {
10 | let result = findRoute(
11 | map: [
12 | RoutePath(
13 | path: "/details/:id",
14 | crumbs: [
15 | Crumb(pathComponent: "/", isModal: false, build: { _ in AnyView(Text("root")) }),
16 | Crumb(pathComponent: "details", isModal: false, build: { _ in AnyView(Text("details"))}),
17 | Crumb(pathComponent: ":id", isModal: true, build: { id in AnyView(Text("\(String(describing: id))")) })
18 | ]
19 | )
20 | ],
21 | path: "/details/8888-0000",
22 | accumulatedParams: Parameters()
23 | )
24 |
25 | try #require(result != nil)
26 | #expect(result?.count == 3)
27 | #expect(result![0].0.isEmpty)
28 | #expect(result![1].0.isEmpty)
29 | #expect(result![2].0["id"] == "8888-0000")
30 | }
31 |
32 | @Test
33 | func routesExtractionComplex() async throws {
34 | let result = findRoute(
35 | map: [
36 | RoutePath(
37 | path: "/details/:id/item/:id",
38 | crumbs: [
39 | Crumb(pathComponent: "/", isModal: false, build: { _ in AnyView(Text("root")) }),
40 | Crumb(pathComponent: "details", isModal: false, build: { _ in AnyView(Text("details"))}),
41 | Crumb(pathComponent: ":id", isModal: false, build: { id in AnyView(Text("\(String(describing: id))")) }),
42 | Crumb(pathComponent: "item", isModal: false, build: { _ in AnyView(Text("item"))}),
43 | Crumb(pathComponent: ":id2", isModal: false, build: { id in AnyView(Text("\(String(describing: id))")) }),
44 | ]
45 | )
46 | ],
47 | path: "/details/8888-0000/item/02",
48 | accumulatedParams: Parameters()
49 | )
50 |
51 | try #require(result != nil)
52 | #expect(result?.count == 5)
53 | #expect(result![0].0.isEmpty)
54 | #expect(result![1].0.isEmpty)
55 | #expect(result![2].0["id"] == "8888-0000")
56 | #expect(result![3].0.isEmpty == false)
57 | #expect(result![4].0["id2"] == "02")
58 | }
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Mac OS X
2 | .DS_Store
3 |
4 | # Xcode
5 | #
6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
7 |
8 | ## User settings
9 | xcuserdata/
10 |
11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
12 | *.xcscmblueprint
13 | *.xccheckout
14 |
15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
16 | build/
17 | DerivedData/
18 | *.moved-aside
19 | *.pbxuser
20 | !default.pbxuser
21 | *.mode1v3
22 | !default.mode1v3
23 | *.mode2v3
24 | !default.mode2v3
25 | *.perspectivev3
26 | !default.perspectivev3
27 |
28 | ## Obj-C/Swift specific
29 | *.hmap
30 |
31 | ## App packaging
32 | *.ipa
33 | *.dSYM.zip
34 | *.dSYM
35 |
36 | ## Playgrounds
37 | timeline.xctimeline
38 | playground.xcworkspace
39 |
40 | # Swift Package Manager
41 | #
42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
43 | # Packages/
44 | # Package.pins
45 | # Package.resolved
46 | # *.xcodeproj
47 | #
48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
49 | # hence it is not needed unless you have added a package configuration file to your project
50 | # .swiftpm
51 |
52 | .build/
53 |
54 | # CocoaPods
55 | #
56 | # We recommend against adding the Pods directory to your .gitignore. However
57 | # you should judge for yourself, the pros and cons are mentioned at:
58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
59 | #
60 | # Pods/
61 | #
62 | # Add this line if you want to avoid checking in source code from the Xcode workspace
63 | # *.xcworkspace
64 |
65 | # Carthage
66 | #
67 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
68 | # Carthage/Checkouts
69 |
70 | Carthage/Build/
71 |
72 | # Accio dependency management
73 | Dependencies/
74 | .accio/
75 |
76 | # fastlane
77 | #
78 | # It is recommended to not store the screenshots in the git repo.
79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
80 | # For more information about the recommended setup visit:
81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
82 |
83 | fastlane/report.xml
84 | fastlane/Preview.html
85 | fastlane/screenshots/**/*.png
86 | fastlane/test_output
87 |
88 | # Code Injection
89 | #
90 | # After new code Injection tools there's a generated folder /iOSInjectionProject
91 | # https://github.com/johnno1962/injectionforxcode
92 |
93 | iOSInjectionProject/
--------------------------------------------------------------------------------
/Tests/LinRoutingTests/TabNavigatorTests.swift:
--------------------------------------------------------------------------------
1 | @testable import LinkRouting
2 | import SwiftUI
3 | import Testing
4 |
5 | @MainActor
6 | @Suite
7 | struct TabNavigatorTests {
8 | @Test
9 | func switchTabWithFurtherPush() throws {
10 | let sut = try TabNavigator(
11 | tabs: [
12 | Route(
13 | path: "/favorites",
14 | builder: { _ in Text("Root") },
15 | children: [
16 | Route(path: "details", builder: { _ in Text("Details") }),
17 | Route(path: "details2", builder: { _ in Text("Details") })
18 | ]
19 | ),
20 | Route(
21 | path: "/home",
22 | builder: { _ in Text("Root") },
23 | children: [
24 | Route(path: "details", builder: { _ in Text("Details") }),
25 | Route(path: "details2", builder: { _ in Text("Details") })
26 | ]
27 | ),
28 | Route(
29 | path: "/account",
30 | builder: { _ in Text("Root") },
31 | children: [
32 | Route(path: "details", builder: { _ in Text("Details") }),
33 | Route(path: "details2", builder: { _ in Text("Details") })
34 | ]
35 | )
36 | ],
37 | rootPath: "/home"
38 | )
39 |
40 | sut.go(to: "/favorites/details2")
41 |
42 | #expect(sut.rootPath == "/favorites")
43 | #expect(sut.navigators[0].path.value == "details2")
44 | }
45 |
46 | @Test
47 | func switchTabWithParameterizedPathPush() throws {
48 | let sut = try TabNavigator(
49 | tabs: [
50 | Route(
51 | path: "/favorites",
52 | builder: { _ in Text("Root") },
53 | children: [
54 | Route(path: "details", builder: { _ in Text("Details") }),
55 | Route(path: ":id", builder: { _ in Text("Details") })
56 | ]
57 | ),
58 | Route(
59 | path: "/home",
60 | builder: { _ in Text("Root") },
61 | children: [
62 | Route(path: "details", builder: { _ in Text("Details") }),
63 | Route(path: "details2", builder: { _ in Text("Details") })
64 | ]
65 | ),
66 | Route(
67 | path: "/account",
68 | builder: { _ in Text("Root") },
69 | children: [
70 | Route(path: "details", builder: { _ in Text("Details") }),
71 | Route(path: "details2", builder: { _ in Text("Details") })
72 | ]
73 | )
74 | ],
75 | rootPath: "/home"
76 | )
77 |
78 | sut.go(to: "/favorites/details2")
79 |
80 | #expect(sut.rootPath == "/favorites")
81 | #expect(sut.navigators[0].path.value == "details2")
82 | }
83 |
84 | @Test
85 | func routeToUnknownPath() throws {
86 | let sut = try TabNavigator(
87 | tabs: [
88 | Route(
89 | path: "/favorites",
90 | builder: { _ in Text("Root") },
91 | children: [
92 | Route(path: "details", builder: { _ in Text("Details") }),
93 | Route(path: "details2", builder: { _ in Text("Details") })
94 | ]
95 | ),
96 | Route(
97 | path: "/home",
98 | builder: { _ in Text("Root") },
99 | children: [
100 | Route(path: "details", builder: { _ in Text("Details") }),
101 | Route(path: "details2", builder: { _ in Text("Details") })
102 | ]
103 | ),
104 | Route(
105 | path: "/account",
106 | builder: { _ in Text("Root") },
107 | children: [
108 | Route(path: "details", builder: { _ in Text("Details") }),
109 | Route(path: "details2", builder: { _ in Text("Details") })
110 | ]
111 | )
112 | ],
113 | rootPath: "/home"
114 | )
115 |
116 | sut.go(to: "/shop")
117 |
118 | #expect(sut.rootPath == "/home")
119 | #expect(sut.navigators[1].path.value == "not-found")
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Link Routing
2 |
3 | A library that makes SwiftUI routing easy.
4 |
5 | > [!NOTE]
6 | > This project is a POC showing off a declarative way to make routing in SwiftUI applications.
7 |
8 | ## Background
9 |
10 | Even in the newest SwiftUI, making a navigation is still a challenge. For instance, if you want to push a view into the navigation stack, you have to do something like this:
11 |
12 | ```swift
13 | NavigationLink(destination: PageView()) { Text("page") }
14 | ```
15 |
16 | This way, we create a tight coupling between two screens. Imagine if it is possible to navigate to `PageView` from several places, and if you decide to rename the `PageVew` or replace it with another flow, it would require you to apply changes in every place, creating a huge diff.
17 |
18 | Luckily, in iOS 16, Apple introduced `NavigationPath,` which solves the problem of pushing and popping the views. The TabBar and Sheet presentation remains the same — you cannot simply define what view to show in a single place. I hope Apple will introduce solutions to these cases as well in future versions, but for now, we have what we have.
19 |
20 | ## Solution
21 |
22 | What if we follow the routing principles from Web applications? Overall, a mobile application is the same front-end app and boils down to rendering pages. So one route - one page.
23 |
24 | ```swift
25 | Router(routes: [
26 | Route(path: "/", builder: { _ in ContentView() }),
27 | ])
28 | ```
29 |
30 | And that's all for a single-page application. All you have to do is implement `ContentView`.
31 |
32 | ### Extendability
33 |
34 | What if we have to add a new route? Easy-peasy:
35 |
36 | ```swift
37 | Router(routes: [
38 | Route(path: "/", builder: { _ in HomeView() }),
39 | Route(path: "/favorites", builder: { _ in FavoritesView() }),
40 | ])
41 | ```
42 |
43 | Now, we have two routes, and the only thing we have to do is to implement another view. As these routes are siblings, navigating from one to another would replace pages. If we want to achieve the push/pop animation, we can do it like this:
44 |
45 | ```swift
46 | Router(routes: [
47 | Route(path: "/", builder: { _ in HomeView() }, children: [
48 | Route(path: "favorites", builder: { _ in FavoritesView() }),
49 | ]),
50 | ])
51 | ```
52 |
53 | Note that we removed `/` from the path for Favorites. The Router will concatenate the paths forming the correct URL - `/`favorites`.
54 |
55 | But as mentioned, Apple already solved the problem with reusable Views in the navigation stack, so let's pump it up.
56 |
57 | #### Modal Presentation
58 |
59 | If a route has to be presented modally, we can add a simple flag:
60 |
61 | ```swift
62 | Router(routes: [
63 | Route(path: "/", builder: { _ in HomeView() }, children: [
64 | Route(path: "favorites", builder: { _ in FavoritesView() }),
65 | Route(path: "details", builder: { _ in DetailsView() }, isModal: true),
66 | ]),
67 | ])
68 | ```
69 |
70 | And that's all that is needed to present `details` modally. As the screen is a child for the root, no matter how deep we are in the navigation wilds, we'll be dropped to the root, and a modal will be opened.
71 |
72 | However, pushing/popping and presenting modal pages are not the only things we often use in mobile development.
73 |
74 | #### Tab Navigation
75 |
76 | If we want to define a tabbed application, we can do that using:
77 |
78 | ```swift
79 | Router.tabbed(tabs: [
80 | TabRoute(
81 | path: "/",
82 | builder: { _ in HomeView() },
83 | labelBuilder: { Label("Home", systemImage: "house") },
84 | children: [
85 | Route(path: "details", builder: { _ in NamedView(text: "Details")}),
86 | ]
87 | ),
88 | TabRoute(
89 | path: "/favorites",
90 | builder: { _ in FavoritesView() },
91 | labelBuilder: { Label("Favorites", systemImage: "star") },
92 | children: []
93 | )
94 | ])
95 | ```
96 |
97 | Now we introduce `TabRoute` which knows how to build a label in TabBar.
98 |
99 | ### Navigation
100 |
101 | But how do we navigate?
102 |
103 | Under the hood, the library uses `Navigator` - an observable object - which does the magic.
104 |
105 | ```swift
106 | struct HomeView: View {
107 | @Environment(Navigator.self) var navigator
108 |
109 | var body: some View {
110 | VStack {
111 | List {
112 | Button("open \"/favorites\"") {
113 | navigator.go(to: "/favorites")
114 | }
115 | RouteLink(
116 | label: {
117 | Text("open \"/details\"")
118 | },
119 | route: "/details"
120 | )
121 | }
122 | }
123 | }
124 | }
125 | ```
126 |
127 | `Navigator` has a single function, `go(to:)`, which accepts a route. In addition, there is `RouteLink`, which mimics the chevron in a list item.
128 |
129 | ## Example
130 |
131 | For more details, check the Example folder in the repository.
132 |
--------------------------------------------------------------------------------
/Tests/LinRoutingTests/NavigatorTests.swift:
--------------------------------------------------------------------------------
1 | @testable import LinkRouting
2 | import SwiftUI
3 | import Testing
4 |
5 | @MainActor
6 | @Suite
7 | struct NavigatorTests {
8 |
9 | @Test
10 | func oneLevel() throws {
11 | let sut = try Navigator(
12 | routes: [
13 | Route(
14 | path: "/",
15 | builder: { _ in Text("Root") },
16 | children: [
17 | Route(path: "details", builder: { _ in Text("Details") }),
18 | Route(path: "details2", builder: { _ in Text("Details") })
19 | ]
20 | )
21 | ]
22 | )
23 |
24 | sut.go(to: "/details")
25 |
26 | #expect(sut.path.value == "details")
27 | }
28 |
29 | @Test
30 | func twoLevels() throws {
31 | let sut = try Navigator(
32 | routes: [
33 | Route(
34 | path: "/",
35 | builder: { _ in Text("Root") },
36 | children: [
37 | Route(
38 | path: "details",
39 | builder: { _ in Text("Details")},
40 | children: [
41 | Route(
42 | path: "id",
43 | builder: { _ in Text("ID")}
44 | )
45 | ]
46 | )
47 | ]
48 | )
49 | ]
50 | )
51 |
52 | sut.go(to: "/details/id")
53 |
54 | #expect(sut.path.value == "details/id")
55 | }
56 |
57 | @Test
58 | func root() throws {
59 | let sut = try Navigator(
60 | routes: [
61 | Route(
62 | path: "/root",
63 | builder: { _ in Text("Root") },
64 | children: [
65 | Route(
66 | path: "details",
67 | builder: { _ in Text("Details")},
68 | children: [
69 | Route(
70 | path: "id",
71 | builder: { _ in Text("ID")}
72 | )
73 | ]
74 | )
75 | ]
76 | )
77 | ],
78 | root: "/root"
79 | )
80 |
81 | sut.go(to: "/root/details/id")
82 |
83 | #expect(sut.path.value == "details/id")
84 | #expect(sut.rootPath == "/root")
85 | }
86 |
87 | @Test
88 | func fail() {
89 | #expect(throws: NavigatorError.rootPathDoesNotPresentInRoutes) {
90 | try Navigator(
91 | routes: [
92 | Route(path: "/path", builder: { _ in Text("Path") })
93 | ],
94 | root: "/"
95 | )
96 | }
97 | }
98 |
99 | @Test
100 | func routeToNotFoundPage() throws {
101 | let sut = try Navigator(
102 | routes: [
103 | Route(
104 | path: "/",
105 | builder: { _ in Text("Root") },
106 | children: [
107 | Route(path: "details", builder: { _ in Text("Details") }),
108 | ]
109 | )
110 | ]
111 | )
112 |
113 | sut.go(to: "/favorites")
114 |
115 | #expect(sut.path.value == "not-found")
116 | }
117 |
118 | @Test
119 | func modalRoutePath() throws {
120 | let sut = try Navigator(
121 | routes: [
122 | Route(
123 | path: "/",
124 | builder: { _ in Text("Home") },
125 | children: [
126 | Route(
127 | path: "details",
128 | builder: { _ in Text("Details") },
129 | isModal: true
130 | )
131 | ]
132 | )
133 | ]
134 | )
135 |
136 | sut.go(to: "/details")
137 |
138 | #expect(sut.path.value == "")
139 | #expect(sut.modalPath == "details")
140 | }
141 |
142 | @Test
143 | func modalRoutePathThroughPush() throws {
144 | let sut = try Navigator(
145 | routes: [
146 | Route(
147 | path: "/",
148 | builder: { _ in Text("Home") },
149 | children: [
150 | Route(
151 | path: "items",
152 | builder: { _ in Text("Details") },
153 | children: [
154 | Route(
155 | path: "details",
156 | builder: { _ in Text("Details") },
157 | isModal: true
158 | )
159 | ]
160 | )
161 | ]
162 | )
163 | ]
164 | )
165 |
166 | sut.go(to: "/items/details")
167 |
168 | #expect(sut.path.value == "items")
169 | #expect(sut.modalPath == "details")
170 | }
171 |
172 | @Test
173 | func modalNavigatorRoutesToTarget() throws {
174 | let sut = try Navigator(
175 | routes: [
176 | Route(
177 | path: "/",
178 | builder: { _ in Text("Home") },
179 | children: [
180 | Route(
181 | path: "details",
182 | builder: { _ in Text("Details") },
183 | children: [
184 | Route(path: "id", builder: { _ in Text("ID") })
185 | ],
186 | isModal: true
187 | )
188 | ]
189 | )
190 | ]
191 | )
192 |
193 | sut.go(to: "/details/id")
194 |
195 | #expect(sut.path.value == "")
196 | #expect(sut.modalPath == "details/id")
197 | #expect(sut.modalNavigator().path.value == "id")
198 | }
199 | }
200 |
201 | extension NavigationPath {
202 | var value: String {
203 | let encoder = JSONEncoder()
204 | let jsonData = try! encoder.encode(codable)
205 |
206 | let array = try! JSONSerialization.jsonObject(with: jsonData) as! [String]
207 | let filtered = array.filter { $0.contains("{") && $0.contains("}") }
208 | var boxes = filtered.map { try! JSONDecoder().decode(RouteBox_.self, from: $0.data(using: .utf8)!)}
209 |
210 | guard !boxes.isEmpty else { return "" }
211 |
212 | let first = boxes.removeLast()
213 |
214 | return boxes.reversed().reduce(first.path) { partialResult, b in
215 | partialResult + "/" + b.path
216 | }
217 | }
218 | }
219 |
220 | struct RouteBox_: Codable {
221 | let path: String
222 | }
223 |
--------------------------------------------------------------------------------
/Sources/LinkRouting/Navigator.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public enum NavigatorError: Error {
4 | case rootPathDoesNotPresentInRoutes
5 | }
6 |
7 | public typealias Parameters = [String: String]
8 |
9 | @MainActor
10 | @Observable
11 | public class Navigator {
12 | var path = NavigationPath()
13 | let map: [RoutePath]
14 | var modalPath: String?
15 | var rootPath: String
16 |
17 | private(set) var root: RouteBox?
18 |
19 | private let parentNavigator: Navigator?
20 | private var modalMap: [RoutePath]?
21 | private var modalRoot: String?
22 | private var initialParameters: Parameters
23 |
24 | init(
25 | routes: [AnyRouteProtocol],
26 | root: String = "/",
27 | parent: Navigator? = nil,
28 | initialParameters: Parameters = [:]
29 | ) throws {
30 | self.rootPath = root
31 | guard let root = routes.first(where: { $0.path == root }) else {
32 | throw NavigatorError.rootPathDoesNotPresentInRoutes
33 | }
34 | self.parentNavigator = parent
35 | self.root = RouteBox(
36 | parameters: initialParameters,
37 | route: Crumb(pathComponent: root.path, isModal: root.isModal, build: root.build)
38 | )
39 | self.map = _BuildMap(routes: routes)
40 | self.initialParameters = initialParameters
41 | }
42 |
43 | init(map: [RoutePath], rootPath: String, parent: Navigator? = nil, initialParameters: Parameters) {
44 | self.rootPath = rootPath
45 | self.parentNavigator = parent
46 | self.map = map
47 | self.initialParameters = initialParameters
48 | }
49 |
50 | func modalNavigator() -> Navigator {
51 | let modalRoot = modalRoot ?? "/"
52 | let nav = Navigator(
53 | map: modalMap ?? [],
54 | rootPath: modalRoot,
55 | parent: self,
56 | initialParameters: initialParameters
57 | )
58 | nav.go(to: modalPath ?? "/")
59 | return nav
60 | }
61 |
62 | public func go(to path: String) {
63 | go(to: path, caller: nil)
64 | }
65 |
66 | func go(to path: String, caller: Navigator? = nil) {
67 | let relativePath = (path.hasSuffix("/" + rootPath) ? rootPath : nil)
68 | ?? path.range(of: rootPath + "/").map { String(path[$0.lowerBound...]) }
69 |
70 | guard
71 | let routes = findRoute(
72 | map: map,
73 | path: relativePath ?? path,
74 | accumulatedParams: initialParameters
75 | )
76 | else {
77 | if let parent = parentNavigator {
78 | parent.go(to: path, caller: self)
79 | } else {
80 | // TODO: parameterize NotFound
81 | (caller ?? self).path.append(
82 | RouteBox(parameters: [:], route: Crumb(
83 | pathComponent: "not-found",
84 | isModal: false,
85 | build: { _ in AnyView(Text("404: Page Not Found")) }
86 | )))
87 | }
88 | return
89 | }
90 |
91 | rootPath = routes.first.map { (params, crumb) in
92 | replaceKey(in: crumb.pathComponent, from: params)
93 | } ?? self.rootPath
94 | root = routes.first.map { RouteBox(parameters: $0.0, route: $0.1) }
95 |
96 | let path = routes.dropFirst()
97 | if let modalIndex = path.firstIndex(where: { $0.1.isModal == true }) {
98 | let navPath = path.prefix(modalIndex-1)
99 | self.path = NavigationPath(navPath.map(RouteBox.init))
100 |
101 | let newCrumbs = [path[modalIndex]] + path.dropFirst(modalIndex)
102 |
103 | let prev = ([rootPath] + path.prefix(modalIndex).map(\.1.pathComponent))
104 | .joined(separator: "/")
105 | .replacingOccurrences(of: "//", with: "/")
106 |
107 | modalPath = newCrumbs.map { (params, crumb) in
108 | replaceKey(in: crumb.pathComponent, from: params)
109 | }.joined(separator: "/")
110 | initialParameters = newCrumbs.first?.0 ?? initialParameters
111 |
112 | modalRoot = path[modalIndex].1.pathComponent
113 |
114 | modalMap = map.filter { hasPath(prev, in: $0.path) }.map { routePath in
115 | let newCrumbs = routePath.crumbs.drop { $0.pathComponent != modalRoot}
116 | return RoutePath(path: newCrumbs.map(\.pathComponent).joined(separator: "/"), crumbs: Array(newCrumbs))
117 | }
118 | } else {
119 | self.modalMap = nil
120 | self.modalPath = nil
121 | self.path = NavigationPath(path.map(RouteBox.init))
122 | }
123 | }
124 | }
125 |
126 | func hasPath(_ path: String, in template: String) -> Bool {
127 | let pathComponents = path.split(separator: "/")
128 | let templateComponents = template.split(separator: "/")
129 |
130 | guard pathComponents.count <= templateComponents.count else {
131 | return false
132 | }
133 |
134 | for (pathComponent, templateComponent) in zip(pathComponents, templateComponents) {
135 | if templateComponent.hasPrefix(":") {
136 | continue
137 | }
138 | if pathComponent != templateComponent {
139 | return false
140 | }
141 | }
142 |
143 | return true
144 | }
145 |
146 | func replaceKey(in path: String, from params: Parameters) -> String {
147 | guard let colonRange = path.range(of: ":") else {
148 | return path
149 | }
150 |
151 | let keyStartIndex = path.index(after: colonRange.lowerBound)
152 | let key = String(path[keyStartIndex...])
153 |
154 | return params[key].map {
155 | var path = path
156 | path.replaceSubrange(colonRange.lowerBound..., with: $0)
157 | return path
158 | } ?? path
159 | }
160 |
161 | struct Crumb {
162 | let pathComponent: String
163 | let isModal: Bool
164 | let build: (Parameters) -> AnyView
165 | }
166 |
167 | struct RoutePath {
168 | let path: String
169 | let crumbs: [Crumb]
170 | }
171 |
172 | @MainActor
173 | func _BuildMap(routes: [AnyRouteProtocol]) -> [RoutePath] {
174 | routes.map { _buildMap(route: $0) }.flatMap { $0 }
175 | }
176 |
177 | @MainActor
178 | func _buildMap(route: AnyRouteProtocol) -> [RoutePath] {
179 | let currentCrumb = Crumb(pathComponent: route.path, isModal: route.isModal, build: route.build)
180 |
181 | return route.children.map {
182 | _buildMap(route: $0).map { child in
183 | RoutePath(
184 | path: (route.path + "/" + child.path).replacingOccurrences(of: "//", with: "/"),
185 | crumbs: [currentCrumb] + child.crumbs
186 | )
187 | }
188 | }.flatMap { $0 } + [RoutePath(path: route.path, crumbs: [currentCrumb])]
189 | }
190 |
191 | func findRoute(map: [RoutePath], path: String, accumulatedParams: Parameters) -> [(Parameters, Crumb)]? {
192 | map.map {
193 | extract(pattern: $0, path: path, accumulatedParams: accumulatedParams)
194 | }.first(where: { $0 != nil }) ?? nil
195 | }
196 |
197 | func extract(pattern: RoutePath, path: String, accumulatedParams: Parameters) -> [(Parameters, Crumb)]? {
198 | var pathComponents = path.splitIncludingRoot()
199 | let components = pattern.crumbs
200 |
201 | var result: [(Parameters, Crumb)] = []
202 | var params = accumulatedParams
203 |
204 | for component in components {
205 | for subComponent in component.pathComponent.splitIncludingRoot() {
206 | if pathComponents.isEmpty { return nil }
207 | if subComponent.hasPrefix(":") {
208 | let paramName = String(subComponent.dropFirst())
209 | let param = String(pathComponents[0])
210 | params[paramName] = param
211 | pathComponents = Array(pathComponents.dropFirst())
212 | } else if pathComponents[0] == subComponent {
213 | pathComponents = Array(pathComponents.dropFirst())
214 | } else {
215 | return nil
216 | }
217 | }
218 | result.append((params, component))
219 | }
220 |
221 | return pathComponents.isEmpty ? result : nil
222 | }
223 |
224 | private extension String {
225 | func splitIncludingRoot() -> [String] {
226 | if hasPrefix("/") {
227 | return ["/"] + split(separator: "/").map(String.init)
228 | }
229 |
230 | return split(separator: "/").map(String.init)
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 70;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | ED9BEB292C4AA0CF00EBDBA5 /* LinkRouting in Frameworks */ = {isa = PBXBuildFile; productRef = ED9BEB282C4AA0CF00EBDBA5 /* LinkRouting */; };
11 | ED9BEB2F2C4AA29F00EBDBA5 /* LinkRouting in Frameworks */ = {isa = PBXBuildFile; productRef = ED9BEB2E2C4AA29F00EBDBA5 /* LinkRouting */; };
12 | /* End PBXBuildFile section */
13 |
14 | /* Begin PBXFileReference section */
15 | EDE061432C4A9E6F00300651 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
16 | /* End PBXFileReference section */
17 |
18 | /* Begin PBXFileSystemSynchronizedRootGroup section */
19 | EDE061452C4A9E6F00300651 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; };
20 | /* End PBXFileSystemSynchronizedRootGroup section */
21 |
22 | /* Begin PBXFrameworksBuildPhase section */
23 | EDE061402C4A9E6F00300651 /* Frameworks */ = {
24 | isa = PBXFrameworksBuildPhase;
25 | buildActionMask = 2147483647;
26 | files = (
27 | ED9BEB292C4AA0CF00EBDBA5 /* LinkRouting in Frameworks */,
28 | ED9BEB2F2C4AA29F00EBDBA5 /* LinkRouting in Frameworks */,
29 | );
30 | runOnlyForDeploymentPostprocessing = 0;
31 | };
32 | /* End PBXFrameworksBuildPhase section */
33 |
34 | /* Begin PBXGroup section */
35 | EDE0613A2C4A9E6F00300651 = {
36 | isa = PBXGroup;
37 | children = (
38 | EDE061452C4A9E6F00300651 /* Sources */,
39 | EDE061442C4A9E6F00300651 /* Products */,
40 | );
41 | sourceTree = "";
42 | };
43 | EDE061442C4A9E6F00300651 /* Products */ = {
44 | isa = PBXGroup;
45 | children = (
46 | EDE061432C4A9E6F00300651 /* Example.app */,
47 | );
48 | name = Products;
49 | sourceTree = "";
50 | };
51 | /* End PBXGroup section */
52 |
53 | /* Begin PBXNativeTarget section */
54 | EDE061422C4A9E6F00300651 /* Example */ = {
55 | isa = PBXNativeTarget;
56 | buildConfigurationList = EDE061512C4A9E7000300651 /* Build configuration list for PBXNativeTarget "Example" */;
57 | buildPhases = (
58 | EDE0613F2C4A9E6F00300651 /* Sources */,
59 | EDE061402C4A9E6F00300651 /* Frameworks */,
60 | EDE061412C4A9E6F00300651 /* Resources */,
61 | );
62 | buildRules = (
63 | );
64 | dependencies = (
65 | );
66 | fileSystemSynchronizedGroups = (
67 | EDE061452C4A9E6F00300651 /* Sources */,
68 | );
69 | name = Example;
70 | packageProductDependencies = (
71 | ED9BEB282C4AA0CF00EBDBA5 /* LinkRouting */,
72 | ED9BEB2E2C4AA29F00EBDBA5 /* LinkRouting */,
73 | );
74 | productName = Example;
75 | productReference = EDE061432C4A9E6F00300651 /* Example.app */;
76 | productType = "com.apple.product-type.application";
77 | };
78 | /* End PBXNativeTarget section */
79 |
80 | /* Begin PBXProject section */
81 | EDE0613B2C4A9E6F00300651 /* Project object */ = {
82 | isa = PBXProject;
83 | attributes = {
84 | BuildIndependentTargetsInParallel = 1;
85 | LastSwiftUpdateCheck = 1600;
86 | LastUpgradeCheck = 1600;
87 | TargetAttributes = {
88 | EDE061422C4A9E6F00300651 = {
89 | CreatedOnToolsVersion = 16.0;
90 | };
91 | };
92 | };
93 | buildConfigurationList = EDE0613E2C4A9E6F00300651 /* Build configuration list for PBXProject "Example" */;
94 | compatibilityVersion = "Xcode 15.0";
95 | developmentRegion = en;
96 | hasScannedForEncodings = 0;
97 | knownRegions = (
98 | en,
99 | Base,
100 | );
101 | mainGroup = EDE0613A2C4A9E6F00300651;
102 | packageReferences = (
103 | ED9BEB2D2C4AA29F00EBDBA5 /* XCLocalSwiftPackageReference "../../LinkRouting" */,
104 | );
105 | productRefGroup = EDE061442C4A9E6F00300651 /* Products */;
106 | projectDirPath = "";
107 | projectRoot = "";
108 | targets = (
109 | EDE061422C4A9E6F00300651 /* Example */,
110 | );
111 | };
112 | /* End PBXProject section */
113 |
114 | /* Begin PBXResourcesBuildPhase section */
115 | EDE061412C4A9E6F00300651 /* Resources */ = {
116 | isa = PBXResourcesBuildPhase;
117 | buildActionMask = 2147483647;
118 | files = (
119 | );
120 | runOnlyForDeploymentPostprocessing = 0;
121 | };
122 | /* End PBXResourcesBuildPhase section */
123 |
124 | /* Begin PBXSourcesBuildPhase section */
125 | EDE0613F2C4A9E6F00300651 /* Sources */ = {
126 | isa = PBXSourcesBuildPhase;
127 | buildActionMask = 2147483647;
128 | files = (
129 | );
130 | runOnlyForDeploymentPostprocessing = 0;
131 | };
132 | /* End PBXSourcesBuildPhase section */
133 |
134 | /* Begin XCBuildConfiguration section */
135 | EDE0614F2C4A9E7000300651 /* Debug */ = {
136 | isa = XCBuildConfiguration;
137 | buildSettings = {
138 | ALWAYS_SEARCH_USER_PATHS = NO;
139 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
140 | CLANG_ANALYZER_NONNULL = YES;
141 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
142 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
143 | CLANG_ENABLE_MODULES = YES;
144 | CLANG_ENABLE_OBJC_ARC = YES;
145 | CLANG_ENABLE_OBJC_WEAK = YES;
146 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
147 | CLANG_WARN_BOOL_CONVERSION = YES;
148 | CLANG_WARN_COMMA = YES;
149 | CLANG_WARN_CONSTANT_CONVERSION = YES;
150 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
151 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
152 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
153 | CLANG_WARN_EMPTY_BODY = YES;
154 | CLANG_WARN_ENUM_CONVERSION = YES;
155 | CLANG_WARN_INFINITE_RECURSION = YES;
156 | CLANG_WARN_INT_CONVERSION = YES;
157 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
158 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
159 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
160 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
161 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
162 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
163 | CLANG_WARN_STRICT_PROTOTYPES = YES;
164 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
165 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
166 | CLANG_WARN_UNREACHABLE_CODE = YES;
167 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
168 | COPY_PHASE_STRIP = NO;
169 | DEBUG_INFORMATION_FORMAT = dwarf;
170 | ENABLE_STRICT_OBJC_MSGSEND = YES;
171 | ENABLE_TESTABILITY = YES;
172 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
173 | GCC_C_LANGUAGE_STANDARD = gnu17;
174 | GCC_DYNAMIC_NO_PIC = NO;
175 | GCC_NO_COMMON_BLOCKS = YES;
176 | GCC_OPTIMIZATION_LEVEL = 0;
177 | GCC_PREPROCESSOR_DEFINITIONS = (
178 | "DEBUG=1",
179 | "$(inherited)",
180 | );
181 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
182 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
183 | GCC_WARN_UNDECLARED_SELECTOR = YES;
184 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
185 | GCC_WARN_UNUSED_FUNCTION = YES;
186 | GCC_WARN_UNUSED_VARIABLE = YES;
187 | IPHONEOS_DEPLOYMENT_TARGET = 18.0;
188 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
189 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
190 | MTL_FAST_MATH = YES;
191 | ONLY_ACTIVE_ARCH = YES;
192 | SDKROOT = iphoneos;
193 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
194 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
195 | };
196 | name = Debug;
197 | };
198 | EDE061502C4A9E7000300651 /* Release */ = {
199 | isa = XCBuildConfiguration;
200 | buildSettings = {
201 | ALWAYS_SEARCH_USER_PATHS = NO;
202 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
203 | CLANG_ANALYZER_NONNULL = YES;
204 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
205 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
206 | CLANG_ENABLE_MODULES = YES;
207 | CLANG_ENABLE_OBJC_ARC = YES;
208 | CLANG_ENABLE_OBJC_WEAK = YES;
209 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
210 | CLANG_WARN_BOOL_CONVERSION = YES;
211 | CLANG_WARN_COMMA = YES;
212 | CLANG_WARN_CONSTANT_CONVERSION = YES;
213 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
214 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
215 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
216 | CLANG_WARN_EMPTY_BODY = YES;
217 | CLANG_WARN_ENUM_CONVERSION = YES;
218 | CLANG_WARN_INFINITE_RECURSION = YES;
219 | CLANG_WARN_INT_CONVERSION = YES;
220 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
221 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
222 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
223 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
224 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
225 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
226 | CLANG_WARN_STRICT_PROTOTYPES = YES;
227 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
228 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
229 | CLANG_WARN_UNREACHABLE_CODE = YES;
230 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
231 | COPY_PHASE_STRIP = NO;
232 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
233 | ENABLE_NS_ASSERTIONS = NO;
234 | ENABLE_STRICT_OBJC_MSGSEND = YES;
235 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
236 | GCC_C_LANGUAGE_STANDARD = gnu17;
237 | GCC_NO_COMMON_BLOCKS = YES;
238 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
239 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
240 | GCC_WARN_UNDECLARED_SELECTOR = YES;
241 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
242 | GCC_WARN_UNUSED_FUNCTION = YES;
243 | GCC_WARN_UNUSED_VARIABLE = YES;
244 | IPHONEOS_DEPLOYMENT_TARGET = 18.0;
245 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
246 | MTL_ENABLE_DEBUG_INFO = NO;
247 | MTL_FAST_MATH = YES;
248 | SDKROOT = iphoneos;
249 | SWIFT_COMPILATION_MODE = wholemodule;
250 | VALIDATE_PRODUCT = YES;
251 | };
252 | name = Release;
253 | };
254 | EDE061522C4A9E7000300651 /* Debug */ = {
255 | isa = XCBuildConfiguration;
256 | buildSettings = {
257 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
258 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
259 | CODE_SIGN_STYLE = Automatic;
260 | CURRENT_PROJECT_VERSION = 1;
261 | DEVELOPMENT_ASSET_PATHS = "\"Sources/Preview Content\"";
262 | ENABLE_PREVIEWS = YES;
263 | GENERATE_INFOPLIST_FILE = YES;
264 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
265 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
266 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
267 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
268 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
269 | IPHONEOS_DEPLOYMENT_TARGET = 17.0;
270 | LD_RUNPATH_SEARCH_PATHS = (
271 | "$(inherited)",
272 | "@executable_path/Frameworks",
273 | );
274 | MARKETING_VERSION = 1.0;
275 | PRODUCT_BUNDLE_IDENTIFIER = "com.link-routing.Example";
276 | PRODUCT_NAME = "$(TARGET_NAME)";
277 | SWIFT_EMIT_LOC_STRINGS = YES;
278 | SWIFT_STRICT_CONCURRENCY = complete;
279 | SWIFT_VERSION = 5.0;
280 | TARGETED_DEVICE_FAMILY = "1,2";
281 | };
282 | name = Debug;
283 | };
284 | EDE061532C4A9E7000300651 /* Release */ = {
285 | isa = XCBuildConfiguration;
286 | buildSettings = {
287 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
288 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
289 | CODE_SIGN_STYLE = Automatic;
290 | CURRENT_PROJECT_VERSION = 1;
291 | DEVELOPMENT_ASSET_PATHS = "\"Sources/Preview Content\"";
292 | ENABLE_PREVIEWS = YES;
293 | GENERATE_INFOPLIST_FILE = YES;
294 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
295 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
296 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
297 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
298 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
299 | IPHONEOS_DEPLOYMENT_TARGET = 17.0;
300 | LD_RUNPATH_SEARCH_PATHS = (
301 | "$(inherited)",
302 | "@executable_path/Frameworks",
303 | );
304 | MARKETING_VERSION = 1.0;
305 | PRODUCT_BUNDLE_IDENTIFIER = "com.link-routing.Example";
306 | PRODUCT_NAME = "$(TARGET_NAME)";
307 | SWIFT_EMIT_LOC_STRINGS = YES;
308 | SWIFT_STRICT_CONCURRENCY = complete;
309 | SWIFT_VERSION = 5.0;
310 | TARGETED_DEVICE_FAMILY = "1,2";
311 | };
312 | name = Release;
313 | };
314 | /* End XCBuildConfiguration section */
315 |
316 | /* Begin XCConfigurationList section */
317 | EDE0613E2C4A9E6F00300651 /* Build configuration list for PBXProject "Example" */ = {
318 | isa = XCConfigurationList;
319 | buildConfigurations = (
320 | EDE0614F2C4A9E7000300651 /* Debug */,
321 | EDE061502C4A9E7000300651 /* Release */,
322 | );
323 | defaultConfigurationIsVisible = 0;
324 | defaultConfigurationName = Release;
325 | };
326 | EDE061512C4A9E7000300651 /* Build configuration list for PBXNativeTarget "Example" */ = {
327 | isa = XCConfigurationList;
328 | buildConfigurations = (
329 | EDE061522C4A9E7000300651 /* Debug */,
330 | EDE061532C4A9E7000300651 /* Release */,
331 | );
332 | defaultConfigurationIsVisible = 0;
333 | defaultConfigurationName = Release;
334 | };
335 | /* End XCConfigurationList section */
336 |
337 | /* Begin XCLocalSwiftPackageReference section */
338 | ED9BEB2D2C4AA29F00EBDBA5 /* XCLocalSwiftPackageReference "../../LinkRouting" */ = {
339 | isa = XCLocalSwiftPackageReference;
340 | relativePath = ../../LinkRouting;
341 | };
342 | /* End XCLocalSwiftPackageReference section */
343 |
344 | /* Begin XCSwiftPackageProductDependency section */
345 | ED9BEB282C4AA0CF00EBDBA5 /* LinkRouting */ = {
346 | isa = XCSwiftPackageProductDependency;
347 | productName = LinkRouting;
348 | };
349 | ED9BEB2E2C4AA29F00EBDBA5 /* LinkRouting */ = {
350 | isa = XCSwiftPackageProductDependency;
351 | productName = LinkRouting;
352 | };
353 | /* End XCSwiftPackageProductDependency section */
354 | };
355 | rootObject = EDE0613B2C4A9E6F00300651 /* Project object */;
356 | }
357 |
--------------------------------------------------------------------------------