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