├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Helm │ ├── Errors.swift │ ├── Graph+Operators.swift │ ├── Graph.swift │ ├── Helm+Navigation.swift │ ├── Helm+Transitions.swift │ ├── Helm+Traversal.swift │ ├── Helm+Validation.swift │ ├── Helm.swift │ ├── Optional+Unwrap.swift │ ├── Segue+Graph.swift │ └── Segue.swift └── Playground │ ├── Examples │ ├── ActionSheet.swift │ ├── Basic.swift │ ├── NavigationView.swift │ └── TabView.swift │ ├── Media.xcassets │ ├── Contents.json │ └── logo.imageset │ │ ├── Contents.json │ │ └── logo.svg │ └── PlaygroundFragment.swift ├── Tests └── HelmTests │ ├── Fixtures.swift │ ├── GraphTests.swift │ └── HelmTests.swift ├── flow-no-segues.svg ├── flow-with-segues.svg └── graphics.sketch /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Valentin Radu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-collections", 6 | "repositoryURL": "https://github.com/apple/swift-collections.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "48254824bb4248676bf7ce56014ff57b142b77eb", 10 | "version": "1.0.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import Foundation 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "Helm", 9 | platforms: [ 10 | .iOS(.v14), .macOS(.v11), .tvOS(.v9), 11 | .macCatalyst(.v13), .watchOS(.v2), .driverKit(.v19), 12 | ], 13 | products: [ 14 | // Products define the executables and libraries a package produces, and make them visible to other packages. 15 | .library( 16 | name: "Helm", 17 | targets: ["Helm"] 18 | ), 19 | .library( 20 | name: "Playground", 21 | targets: ["Playground"] 22 | ), 23 | ], 24 | dependencies: [ 25 | .package( 26 | url: "https://github.com/apple/swift-collections.git", 27 | .upToNextMajor(from: "1.0.0") 28 | ), 29 | ], 30 | targets: [ 31 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 32 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 33 | .target( 34 | name: "Helm", 35 | dependencies: [ 36 | .product(name: "Collections", package: "swift-collections"), 37 | ] 38 | ), 39 | .target( 40 | name: "Playground", 41 | dependencies: ["Helm"], 42 | resources: [.process("Media.xcassets")] 43 | ), 44 | .testTarget( 45 | name: "HelmTests", 46 | dependencies: ["Helm"] 47 | ), 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Helm 2 | 3 | [![SwiftUI](https://img.shields.io/badge/SwiftUI-blue.svg?style=for-the-badge&logo=swift&logoColor=black)](https://developer.apple.com/xcode/swiftui) 4 | [![Swift](https://img.shields.io/badge/Swift-5.3-orange.svg?style=for-the-badge&logo=swift)](https://swift.org) 5 | [![Xcode](https://img.shields.io/badge/Xcode-13-blue.svg?style=for-the-badge&logo=Xcode&logoColor=white)](https://developer.apple.com/xcode) 6 | [![MIT](https://img.shields.io/badge/license-MIT-black.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) 7 | 8 | Helm is a declarative, graph-based routing library for SwiftUI. It fully describes all the navigation flows in an app and can handle complex overlapping UI, modals, deeplinking, and much more. 9 | 10 | ## Index 11 | * [Concepts](#concepts) 12 | * [Usage](#usage) 13 | * [Overview](#overview) 14 | * [Error handling](#error-handling) 15 | * [Deeplinking](#deeplinking) 16 | * [Snapshot testing](#snapshot-testing) 17 | * [Examples](#example) 18 | * [License](#license) 19 | 20 | ## Features 21 | 22 | - lightweight, less than 2K lines of code 23 | - declarative 24 | - deeplinking-ready, it takes a single call to navigate anywhere 25 | - snapshot testing ready, iterate through all screens, capture and compare them 26 | - fully documented interface 27 | - expressive errors 28 | - tested, 90%+ coverage 29 | - zero 3rd party dependencies 30 | 31 | ## Concepts 32 | 33 | ### The navigation graph 34 | 35 | In Helm navigation rules are defined in a graph structure using fragments and segues. Fragments are dynamic sections of an app, some are screens, others overlapping views (like a sliding player in a music listening app). 36 | Segues are directed edges used to specify rules between two fragments, such as the presentation style or the auto flag (more about these [below](#segues)). 37 | 38 | ### The presented path 39 | 40 | Unlike traditional routers, Helm uses an ordered set of edges to represent the path. This allows querying the presented fragments and the steps needed to reach them while enabling multilayered UIs. 41 | The path can also have an optional id assigned to each of its fragments. These are used to present dynamic data from the same fragment. (i.e. in a master-detail list the `.detail` fragment would need the currently presented item's id.) 42 | 43 | ### Transitions 44 | 45 | Transitions encapsulate the navigation command from a fragment to another. In Helm there are 3 types of transitions: 46 | 47 | - presenting a new fragment 48 | - dismissing an already presented fragment 49 | - fully replacing the presented path 50 | 51 | ### Helm 52 | 53 | `Helm`, the main class, navigates between fragments, returns their presentation state and all possible transition and so on. It conforms to `ObservableObject`, ready to work as an injected `@EnvironmentObject`. 54 | 55 | ### Segues 56 | 57 | Segues are directed edges between fragments with navigation rules: 58 | 59 | - `style`: `.hold` or `.pass`, when presenting a new fragment from an already presented one, should the original hold its status or pass it to the destination. In simpler terms, if we want both fragments to be visible after the transition (e.g. when you present a modal or an overlapping view in general), we should use `.hold`. 60 | - `dismissable`: trying to dismiss a fragment that's not marked as such will lead to an error (e.g. once user onboarding is done, you can't dismiss the dashboard and return to the onboarding screens). 61 | - `auto`: some container fragments (like tabs) automatically present a child. Marking a segue as auto will present its `out` fragment as soon as its `in` fragment is reached. 62 | - `tag`: sometimes is convenient to present or dismiss a segue by its tag. 63 | 64 | ## Usage 65 | 66 | Full examples covering most of the scenarios you'd find in a SwiftUI app can be found [here](https://github.com/priyankpat/HelmSamples). 67 | 68 | We first define all the fragments in the app. 69 | 70 | ```swift 71 | enum Section: Fragment { 72 | // the first screen right after the app starts 73 | case splash 74 | 75 | // the screen that contains the login, register or forgot password fragments 76 | case gatekeeper 77 | // the three fragments of the gatekeeper screen 78 | case login 79 | case register 80 | case forgotPass 81 | 82 | // and so on ... 83 | } 84 | ``` 85 | 86 | We now have: 87 | 88 |

89 | 90 |

91 | 92 | Next, the navigation graph. Normally we'd have to write down each segue. 93 | 94 | ```swift 95 | let segues: Set> = [ 96 | Segue(from: .splash, to: .gatekeeper), 97 | Segue(from: .splash, to: .dashboard), 98 | Segue(from: .gatekeeper, to: .login, auto: true) 99 | Segue(from: .gatekeeper, to: .register) 100 | //... 101 | ] 102 | ``` 103 | 104 | But this can get extra verbose, so, instead, we can use the directed edge operator `=>` to define all the edges, then turn them into segues. Since `=>` supports one-to-many, many-to-one and many-to-many connections, we can create all edges in fewer lines of code. 105 | 106 | ```swift 107 | let edges = Set>() 108 | .union(.splash => [.gatekeeper, .dashboard]) 109 | .union([.gatekeeper => .login]) 110 | .union(.login => .register => .forgotPass => .login) 111 | .union(.login => .forgotPass => .register => .login) 112 | .union([.login, .register] => .dashboard) 113 | .union(.dashboard => [.news, .compose]) 114 | .union(.library => .news => .library) 115 | 116 | let segues = Set(edges.map { (edge: DirectedEdge
) -> Segue
in 117 | switch edge { 118 | case .gatekeeper => .login: 119 | return Segue(edge, style: .hold, auto: true) 120 | case .dashboard => .news: 121 | return Segue(edge, style: .hold, auto: true) 122 | case .dashboard => .compose: 123 | return Segue(edge, style: .hold, dismissable: true) 124 | case .dashboard => .library: 125 | return Segue(edge, style: .hold) 126 | default: 127 | // the default is style: .pass, auto: false, dismissable: false 128 | return Segue(edge) 129 | } 130 | }) 131 | ``` 132 | 133 | Now we have: 134 | 135 |

136 | 137 |

138 | 139 | Once we have the segues, the next step is to create our `Helm` instance. Optionally, we can also pass a path to start the app at a certain fragment other than the entry. Note that the entry fragment (in this case `.splash`) is always presented. 140 | 141 | ```swift 142 | try Helm(nav: segues) 143 | // or 144 | try Helm(nav: segues, 145 | path: [ 146 | .splash => .gatekeeper, 147 | .gatekeeper => .register 148 | ]) 149 | ``` 150 | 151 | Then, we inject `Helm` into the top-most view: 152 | 153 | ``` 154 | struct RootView: View { 155 | @StateObject private var _helm: Helm = ... 156 | 157 | var body: some View { 158 | ZStack { 159 | //... 160 | } 161 | .environmentObject(_helm) 162 | } 163 | } 164 | ``` 165 | 166 | Finally, we can use Helm. Be sure to check the interface documentation for each of the presenting/dismissing methods to find out how they differ. 167 | 168 | ```swift 169 | struct DashboardView: View { 170 | @EnvironmentObject private var _helm: Helm 171 | 172 | var body: some View { 173 | VStack { 174 | HStack { 175 | Spacer() 176 | LargeButton(action: { _helm.present(fragment: .compose) }) { 177 | Image(systemName: "plus.square.on.square") 178 | } 179 | } 180 | TabView(selection: _helm.pickPresented([.library, .news, .settings])) { 181 | LibraryView() 182 | .tabItem { 183 | Label("Library", systemImage: "book.closed") 184 | } 185 | .tag(Optional.some(PlaygroundFragment.library)) 186 | NewsView() 187 | .tabItem { 188 | Label("News", systemImage: "newspaper") 189 | } 190 | .tag(Optional.some(PlaygroundFragment.news)) 191 | SettingsView() 192 | .tabItem { 193 | Label("Settings", systemImage: "gearshape.fill") 194 | } 195 | .tag(Optional.some(PlaygroundFragment.settings)) 196 | } 197 | } 198 | .sheet(isPresented: _helm.isPresented(.compose)) { 199 | ComposeView() 200 | } 201 | } 202 | } 203 | ``` 204 | 205 | ## Error handling 206 | 207 | Most of Helm's methods don't throw, instead, they report errors using the `errors` published property. This allows seamless integration with SwiftUI handlers (e.g. `Button`'s action) while also making things easy to debug and assert. 208 | 209 | ```swift 210 | _helm.$errors 211 | .sink { 212 | assertionFailure($0.description) 213 | } 214 | .store(in: &cancellables) 215 | ``` 216 | 217 | ## Deeplinking 218 | 219 | The presented path (`OrderedSet>`) is already conforming to `Encodable` and `Decodable` protocols so it can easily be saved and restored as a JSON object. Alternatively, one could translate a simpler string path to the graph-based presentation path and use the former to link sections in the app. 220 | 221 | ## Snapshot Testing 222 | 223 | Being able to walk the navigation graph is one of the greatest advantages of Helm. This can have multiple uses, snapshot testing being the most important. Walk, take snapshots after each step and compare the result with previously saved snapshots. All done in a couple of lines of code: 224 | 225 | ```swift 226 | let transitions = _helm.transitions() 227 | for transition in transitions { 228 | try helm.navigate(transition: transition) 229 | // mutate state if needed, take a snapshot, compare it 230 | } 231 | ``` 232 | 233 | Also, by using a custom transition set, one can make arbitrary steps between fragments. This can be used to automatically record videos (and snapshots) for a specific flow (really helpful with App Store promotional material). 234 | 235 | ## Examples 236 | 237 | The package contains an extra project called `Playground`. It's integrating Helm with SwiftUI, including using `NavigationView`s, sheet modals, `TabView`, etc. 238 | 239 | ## License 240 | [MIT License](LICENSE) 241 | -------------------------------------------------------------------------------- /Sources/Helm/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 31/12/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum HelmError: Equatable, LocalizedError { 11 | case empty 12 | case emptyPath 13 | case missingInlets 14 | case ambiguousInlets 15 | case oneEdgeToManySegues(Set>) 16 | case autoCycleDetected(Set>) 17 | case pathMismatch(Set>) 18 | case ambiguousAutoSegues(Set>) 19 | case missingSegueToFragment(N) 20 | case missingSegueForEdge(DirectedEdge) 21 | case missingTaggedSegue(name: AnyHashable) 22 | case missingPathEdge(Segue) 23 | case fragmentNotPresented(N) 24 | case ambiguousForwardFromFragment(N) 25 | case fragmentMissingDismissableSegue(N) 26 | case segueNotDismissable(Segue) 27 | case noDismissableSegues 28 | } 29 | 30 | extension HelmError: CustomStringConvertible { 31 | public var description: String { 32 | switch self { 33 | case .empty: 34 | return "The navigation graph is empty." 35 | case .missingInlets: 36 | return "The navigation graph has no inlets (no entry points). Check if the first segue you added is not part of a cycle. Cycles are allowed only as long as the navigation graph has at least one node that's not part of any cycles." 37 | case let .autoCycleDetected(segues): 38 | return "Auto segues cycle found: \(segues). This would lead to an infinite loop when each auto segue triggers the other." 39 | case let .ambiguousAutoSegues(segues): 40 | return "Ambiguous navigation. Multiple auto segues found: \(segues)." 41 | case let .missingTaggedSegue(name): 42 | return "Can't find segue with tag \(name)." 43 | case let .fragmentNotPresented(fragment): 44 | return "(\(fragment)) is not presented." 45 | case let .fragmentMissingDismissableSegue(fragment): 46 | return "\(fragment) has no dismissable ingress segue." 47 | case .emptyPath: 48 | return "Navigation path is empty." 49 | case let .segueNotDismissable(segue): 50 | return "\(segue) is not dismissable." 51 | case let .pathMismatch(path): 52 | return "\(path) does not match any valid path in the navigation graph." 53 | case .ambiguousInlets: 54 | return "The navigation graph should only have one entry point." 55 | case let .ambiguousForwardFromFragment(fragment): 56 | return "Ambiguous forward navigation. Multiple segues leave \(fragment)." 57 | case let .oneEdgeToManySegues(segues): 58 | return "Multiple segues (\(segues)) for the same edge." 59 | case let .missingPathEdge(edge): 60 | return "\(edge) is missing from the path." 61 | case let .missingSegueForEdge(edge): 62 | return "No segue found for \(edge)" 63 | case let .missingSegueToFragment(fragment): 64 | return "No segue from a presented fragment to \(fragment)." 65 | case .noDismissableSegues: 66 | return "None of the presented fragments have dismissable segues." 67 | } 68 | } 69 | 70 | public var errorDescription: String? { 71 | description 72 | } 73 | } 74 | 75 | public enum DirectedEdgeCollectionError: Equatable, LocalizedError { 76 | case ambiguousEgressEdges(Set, from: E.N) 77 | case ambiguousIngressEdges(Set, to: E.N) 78 | case missingEgressEdges(from: E.N) 79 | case missingIngressEdges(to: E.N) 80 | } 81 | 82 | extension DirectedEdgeCollectionError: CustomStringConvertible { 83 | public var description: String { 84 | switch self { 85 | case let .ambiguousEgressEdges(edges, node): 86 | return "Unable to solve ambiguity. (\(node) has multiple egress edges candidates: (\(edges))." 87 | case let .ambiguousIngressEdges(edges, node): 88 | return "Unable to solve ambiguity. (\(node) has multiple ingress edges candidates: (\(edges))." 89 | case let .missingEgressEdges(node): 90 | return "Missing egress edges from \(node)." 91 | case let .missingIngressEdges(node): 92 | return "Missing ingress edges towards \(node)." 93 | } 94 | } 95 | 96 | public var errorDescription: String? { 97 | description 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Helm/Graph+Operators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 14/01/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The precedence group used for the directed edges operators. 11 | precedencegroup DirectedConnectorPrecedence { 12 | associativity: left 13 | assignment: false 14 | } 15 | 16 | /// The one way connector operator 17 | infix operator =>: DirectedConnectorPrecedence 18 | 19 | public func => (lhs: N, rhs: N) -> DirectedEdge { 20 | return DirectedEdge(from: lhs, to: rhs) 21 | } 22 | 23 | public func => (lhs: N, rhs: Set) -> Set> { 24 | return Set(rhs.map { 25 | DirectedEdge(from: lhs, to: $0) 26 | }) 27 | } 28 | 29 | public func => (lhs: Set, rhs: N) -> Set> { 30 | return Set(lhs.map { 31 | DirectedEdge(from: $0, to: rhs) 32 | }) 33 | } 34 | 35 | public func => (lhs: Set, rhs: Set) -> Set> { 36 | return Set(lhs.flatMap { edge in 37 | rhs.map { 38 | DirectedEdge(from: edge, to: $0) 39 | } 40 | }) 41 | } 42 | 43 | public func => (lhs: DirectedEdge, rhs: N) -> Set> { 44 | return [ 45 | lhs, 46 | DirectedEdge(from: lhs.to, to: rhs), 47 | ] 48 | } 49 | 50 | public func => (lhs: DirectedEdge, rhs: Set) -> Set> { 51 | return Set([lhs] + rhs.map { 52 | DirectedEdge(from: lhs.to, to: $0) 53 | }) 54 | } 55 | 56 | public func => (lhs: Set>, rhs: N) -> Set> { 57 | return Set(lhs + lhs.outlets.map { 58 | DirectedEdge(from: $0.to, to: rhs) 59 | }) 60 | } 61 | 62 | public func => (lhs: Set>, rhs: Set) -> Set> { 63 | return Set(lhs + lhs.outlets.flatMap { outlet in 64 | rhs.map { 65 | DirectedEdge(from: outlet.to, to: $0) 66 | } 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Helm/Graph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 07/01/2022. 6 | // 7 | 8 | import Collections 9 | import Foundation 10 | import OrderedCollections 11 | 12 | /// A node in the graph 13 | public protocol Node: Hashable, Comparable {} 14 | 15 | /// The directed relationship between two nodes. 16 | public protocol DirectedConnector: Hashable, Comparable, CustomDebugStringConvertible { 17 | associatedtype N: Node 18 | /// The input node 19 | var from: N { get } 20 | /// The output node 21 | var to: N { get } 22 | } 23 | 24 | public extension CustomDebugStringConvertible where Self: DirectedConnector { 25 | var debugDescription: String { 26 | return "\(from) -> \(to)" 27 | } 28 | } 29 | 30 | public extension Comparable where Self: DirectedConnector { 31 | static func < (lhs: Self, rhs: Self) -> Bool { 32 | if lhs.from == rhs.from { 33 | return lhs.to < rhs.to 34 | } else { 35 | return lhs.from < rhs.from 36 | } 37 | } 38 | } 39 | 40 | /// An directed edge between two nodes 41 | public struct DirectedEdge: DirectedConnector { 42 | public let from: N 43 | public let to: N 44 | public init(from: N, to: N) { 45 | self.from = from 46 | self.to = to 47 | } 48 | } 49 | 50 | extension DirectedEdge: Encodable where N: Encodable {} 51 | extension DirectedEdge: Decodable where N: Decodable {} 52 | 53 | /// A collection of edges. 54 | public protocol EdgeCollection: Collection & Hashable & Sequence {} 55 | 56 | public extension EdgeCollection where Element: Hashable { 57 | /// Checks if the graph has a specific edge. 58 | /// - parameter edge: The edge to search for 59 | func has(edge: Element) -> Bool { 60 | contains(edge) 61 | } 62 | } 63 | 64 | public extension EdgeCollection where Element: DirectedConnector { 65 | private typealias Error = DirectedEdgeCollectionError 66 | 67 | /// Checks if the graph has a specific node. 68 | /// - parameter node: The node to search for 69 | func has(node: Element.N) -> Bool { 70 | contains(where: { 71 | $0.from == node || $0.to == node 72 | }) 73 | } 74 | 75 | /// Detect if the graph has cycles 76 | var hasCycle: Bool { 77 | firstCycle != nil 78 | } 79 | 80 | /// Finds the first cycle in the graph 81 | var firstCycle: Set? { 82 | var visited: Set = [] 83 | 84 | guard count > 0 else { 85 | return nil 86 | } 87 | 88 | guard inlets.count > 0 else { 89 | return Set(self) 90 | } 91 | 92 | for entry in inlets { 93 | var stack: [Element] = [entry] 94 | 95 | while let edge = stack.last { 96 | visited.insert(edge) 97 | 98 | let nextEdges = 99 | filter { $0.from == edge.to && !visited.contains($0) } 100 | .sorted() 101 | 102 | if let nextEdge = nextEdges.first { 103 | let cycle = stack.drop(while: { nextEdge.to != $0.from }) 104 | 105 | if cycle.count > 0 { 106 | return Set(cycle + [nextEdge]) 107 | } else { 108 | stack.append(nextEdge) 109 | } 110 | } else { 111 | stack.removeLast() 112 | } 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | 119 | /// Returns all the edges that leave a specific node 120 | /// - parameter for: The node from which the edges leave 121 | func egressEdges(for node: Element.N) -> Set { 122 | Set(filter { $0.from == node }) 123 | } 124 | 125 | /// Returns all the edges that leave a set of nodes 126 | /// - parameter for: The node from which the edges leave 127 | func egressEdges(for nodes: Set) -> Set { 128 | Set( 129 | nodes.flatMap { 130 | egressEdges(for: $0) 131 | } 132 | ) 133 | } 134 | 135 | /// Returns an unique egress edge from a given node. 136 | /// - throws: If multiple edges leave the node. 137 | /// - throws: If no edges leave the node. 138 | func uniqueEgressEdge(for node: Element.N) throws -> Element { 139 | let edges = egressEdges(for: node) 140 | guard edges.count > 0 else { 141 | throw Error.missingEgressEdges(from: node) 142 | } 143 | guard edges.count == 1 else { 144 | throw Error.ambiguousEgressEdges(edges, from: node) 145 | } 146 | return edges.first! 147 | } 148 | 149 | /// Returns all the edges that arrive to a specific node 150 | /// - parameter for: The destination node 151 | func ingressEdges(for node: Element.N) -> Set { 152 | Set(filter { $0.to == node }) 153 | } 154 | 155 | /// Returns all the edges that arrive to a set of nodes 156 | /// - parameter for: The destination nodes 157 | func ingressEdges(for nodes: Set) -> Set { 158 | Set( 159 | nodes.flatMap { 160 | ingressEdges(for: $0) 161 | } 162 | ) 163 | } 164 | 165 | /// Returns an unique ingress edge towards a given node. 166 | /// - throws: If multiple edges lead to the node. 167 | /// - throws: If no edges lead to the node. 168 | func uniqueIngressEdge(for node: Element.N) throws -> Element { 169 | let edges = ingressEdges(for: node) 170 | guard edges.count > 0 else { 171 | throw Error.missingIngressEdges(to: node) 172 | } 173 | guard edges.count == 1 else { 174 | throw Error.ambiguousIngressEdges(edges, to: node) 175 | } 176 | return edges.first! 177 | } 178 | 179 | /// Inlets are edges that are unconnected with the graph at their `in` node. 180 | /// They can be seen as entry points in a directed graph. 181 | var inlets: Set { 182 | let ins = Set(map { $0.from }) 183 | let outs = Set(map { $0.to }) 184 | return egressEdges(for: Set(ins.subtracting(outs))) 185 | } 186 | 187 | /// Outlets are edges that are unconnected with the graph at their `out` node. 188 | /// They can be seen as exit points in a directed graph. 189 | var outlets: Set { 190 | let ins = Set(map { $0.from }) 191 | let outs = Set(map { $0.to }) 192 | return ingressEdges(for: Set(outs.subtracting(ins))) 193 | } 194 | 195 | var nodes: Set { 196 | Set(flatMap { [$0.from, $0.to] }) 197 | } 198 | 199 | var disconnectedSubgraphs: Set> { 200 | var labels: [Element: Int] = [:] 201 | var currentLabel = 0 202 | 203 | for segue in self { 204 | guard labels[segue] == nil else { 205 | continue 206 | } 207 | for nextSegue in dfs(from: segue.from) { 208 | labels[nextSegue] = currentLabel 209 | } 210 | currentLabel += 1 211 | } 212 | 213 | let result = Dictionary(grouping: labels, by: { $0.value }) 214 | .values 215 | .map { 216 | Set($0.map { $0.key }) 217 | } 218 | 219 | return Set(result) 220 | } 221 | 222 | /// Iterates through the entire graph or a fragment of it (starting at a given node) depth first. Edges leading to the same node are iterated. 223 | /// - parameter from: An optional start node. If not provided, the entire graph will be iterated. 224 | /// - parameter until: An optional end node. If provided, the search will end when reaching it. 225 | /// - returns: An ordered set containing all the iterated segues in the right order. 226 | func dfs(from: Element.N? = nil, until: Element.N? = nil) -> OrderedSet { 227 | var visited: OrderedSet = [] 228 | let entries: Set 229 | 230 | if let from = from { 231 | entries = egressEdges(for: from) 232 | } else if inlets.count > 0 { 233 | entries = inlets 234 | } else if let first = first { 235 | entries = [first] 236 | } else { 237 | return [] 238 | } 239 | 240 | for entry in entries.sorted() { 241 | var stack: [Element] = [entry] 242 | 243 | while let edge = stack.last { 244 | visited.append(edge) 245 | 246 | if edge.to == until { 247 | return visited 248 | } 249 | 250 | let nextEdges = 251 | filter { 252 | $0.from == edge.to && !visited.contains($0) 253 | } 254 | .sorted() 255 | 256 | if let nextEdge = nextEdges.first { 257 | stack.append(nextEdge) 258 | } else { 259 | stack.removeLast() 260 | } 261 | } 262 | } 263 | 264 | return visited 265 | } 266 | } 267 | 268 | extension Set: EdgeCollection {} 269 | extension OrderedSet: EdgeCollection {} 270 | 271 | public struct Walker { 272 | let graph: Set 273 | } 274 | -------------------------------------------------------------------------------- /Sources/Helm/Helm+Navigation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 21/01/2022. 6 | // 7 | 8 | import Foundation 9 | import OrderedCollections 10 | import SwiftUI 11 | 12 | // Present-related methods 13 | public extension Helm { 14 | /// Presents a fragment. 15 | /// - seealso: `present(fragment:, id:)` 16 | func present(fragment: N) { 17 | do { 18 | let pathEdge = try presentablePathEdge(for: fragment, 19 | id: AnyHashable?.none) 20 | try present(pathEdge: pathEdge) 21 | } catch { 22 | errors.append(error) 23 | } 24 | } 25 | 26 | /// Presents a fragment. 27 | /// An ingress segue must connect the fragment to one of the already presented fragments. 28 | /// If there is no such segue, the operation fails. 29 | /// If multiple presented origin fragments are available, the search starts with the latest in the path. 30 | /// - parameter fragment: The given fragment. 31 | /// - parameter id: An optional id to distinguish between same fragments displaying different data. 32 | func present(fragment: N, id: ID? = nil) where ID: PathFragmentIdentifier { 33 | do { 34 | let pathEdge = try presentablePathEdge(for: fragment, id: id) 35 | try present(pathEdge: pathEdge) 36 | } catch { 37 | errors.append(error) 38 | } 39 | } 40 | 41 | /// Presents a tag. 42 | /// - seealso: `present(tag:, id:)` 43 | func present(tag: T) where T: SegueTag { 44 | do { 45 | let pathEdge = try presentablePathEdge(with: tag, 46 | id: AnyHashable?.none) 47 | try present(pathEdge: pathEdge) 48 | } catch { 49 | errors.append(error) 50 | } 51 | } 52 | 53 | /// Presents a fragment by triggering a segue with a specific tag. 54 | /// The segue must originate from a presented fragment. 55 | /// If there is no such segue, the operation fails. 56 | /// - parameter tag: The tag to look after. 57 | /// - parameter id: An optional id to distinguish between same fragments displaying different data. 58 | func present(tag: T, id: ID?) where ID: PathFragmentIdentifier, T: SegueTag { 59 | do { 60 | let pathEdge = try presentablePathEdge(with: tag, id: id) 61 | try present(pathEdge: pathEdge) 62 | } catch { 63 | errors.append(error) 64 | } 65 | } 66 | 67 | /// Forwards navigation to the next fragment. 68 | /// - seealso: `forward(id:)` 69 | func forward() { 70 | do { 71 | let pathEdge = try presentableForwardPathEdge(id: AnyHashable?.none) 72 | try present(pathEdge: pathEdge) 73 | } catch { 74 | errors.append(error) 75 | } 76 | } 77 | 78 | /// Presents the next fragment by triggering the sole egress segue of the latest fragment in the path. 79 | /// If the fragment has more than a segue, the operation fails 80 | /// If the fragment has no segue, the operation fails 81 | /// - parameter id: An optional id to distinguish between same fragments displaying different data. 82 | func forward(id: ID?) where ID: PathFragmentIdentifier { 83 | do { 84 | let pathEdge = try presentableForwardPathEdge(id: id) 85 | try present(pathEdge: pathEdge) 86 | } catch { 87 | errors.append(error) 88 | } 89 | } 90 | 91 | /// Checks if a fragment can be presented 92 | /// - parameter fragment: The fragment 93 | func canPresent(fragment: N) -> Bool { 94 | do { 95 | _ = try presentablePathEdge(for: fragment, 96 | id: AnyHashable?.none) 97 | return true 98 | } catch { 99 | return false 100 | } 101 | } 102 | 103 | /// Checks if a tag can be presented 104 | /// - parameter tag: The tag 105 | func canPresent(using tag: T) -> Bool where T: SegueTag { 106 | do { 107 | _ = try presentablePathEdge(with: tag, 108 | id: AnyHashable?.none) 109 | return true 110 | } catch { 111 | return false 112 | } 113 | } 114 | 115 | /// Checks if a fragment is presented. 116 | /// - seealso: isPresented(fragment:, id:) 117 | func isPresented(_ fragment: N) -> Bool { 118 | return isPresented(fragment, id: String?.none) 119 | } 120 | 121 | /// Checks if a fragment with a specific id is presented. 122 | /// - returns: True if the fragment is presented. 123 | func isPresented(_ fragment: N, id: ID?) -> Bool where ID: PathFragmentIdentifier { 124 | return presentedFragments 125 | .contains(PathFragment(fragment, id: id)) 126 | } 127 | 128 | /// Matches all the ids of a specific fragment 129 | /// - returns: The set of ids 130 | func matchAll(_ fragment: N) -> Set where ID: PathFragmentIdentifier { 131 | Set(presentedFragments 132 | .filter { $0.wrappedValue == fragment } 133 | .compactMap { 134 | $0.id?.base as? ID 135 | }) 136 | } 137 | 138 | /// Matches the first presented id 139 | /// - returns: The fragment id, if a match was found 140 | /// - warning: If none or multiple (with different ids) fragments are presented this function returns nil 141 | func matchFirst(_ fragment: N) -> ID? where ID: PathFragmentIdentifier { 142 | let allIds: Set = matchAll(fragment) 143 | guard allIds.count == 1 else { 144 | return nil 145 | } 146 | return allIds.first! 147 | } 148 | 149 | /// A special `isPresented(fragment:)` function that takes multiple fragments and returns a binding with the one that's presented or nil otherwise. 150 | /// Setting the binding value to other fragment is the same thing as calling `present(fragment:)` with the fragment as the parameter. Setting the value to nil will dismiss all fragments. 151 | /// - parameter fragments: The query fragments 152 | /// - returns: The first presented fragment binding, nil if none are presented. 153 | func pickPresented(_ fragments: Set) -> Binding { 154 | return Binding { 155 | fragments.first(where: { self.isPresented($0) }) 156 | } 157 | set: { 158 | if let fragment = $0 { 159 | self.present(fragment: fragment) 160 | } else { 161 | for fragment in fragments { 162 | if self.isPresented(fragment) { 163 | self.dismiss(fragment: fragment) 164 | } 165 | } 166 | } 167 | } 168 | } 169 | 170 | /// A special `isPresented(fragment:)` function that returns a binding. 171 | /// - see also `isPresented(fragment:, id:)` 172 | func isPresented(_ fragment: N) -> Binding { 173 | isPresented(fragment, id: String?.none) 174 | } 175 | 176 | /// A special `isPresented(fragment:)` function that returns a binding. 177 | /// Setting the binding value to false is the same thing as calling `dismiss(fragment:)` with the fragment as the parameter. 178 | /// - parameter fragment: The fragment 179 | /// - returns: A binding, true if the fragment is presented. 180 | func isPresented(_ fragment: N, id: ID?) -> Binding where ID: PathFragmentIdentifier { 181 | Binding { 182 | self.isPresented(fragment, id: id) 183 | } 184 | set: { [self] in 185 | if $0 { 186 | if !isPresented(fragment, id: id) { 187 | present(fragment: fragment, id: id) 188 | } 189 | } else { 190 | if isPresented(fragment, id: id) { 191 | dismiss(fragment: fragment) 192 | } 193 | } 194 | } 195 | } 196 | } 197 | 198 | // Present-related private methods 199 | extension Helm { 200 | func present(pathEdge: PathEdge) throws { 201 | path = OrderedSet(path.prefix(while: { pathEdge != $0 })) 202 | path.append(pathEdge) 203 | 204 | if let pathEdge = try autoPresentablePathEdge(from: pathEdge.to.wrappedValue) { 205 | try present(pathEdge: pathEdge) 206 | } 207 | } 208 | 209 | func presentablePathEdge(for fragment: N, id: ID? = nil) throws -> PathEdge where ID: PathFragmentIdentifier { 210 | if path.isEmpty { 211 | do { 212 | let segue = try nav.inlets.uniqueIngressEdge(for: fragment) 213 | return PathEdge(segue.edge, 214 | sourceId: nil, 215 | targetId: id) 216 | } catch { 217 | throw ConcreteHelmError.missingSegueToFragment(fragment) 218 | } 219 | } else { 220 | let pathEdges = presentedFragments 221 | .reversed() 222 | .flatMap { edge in 223 | nav 224 | .egressEdges(for: edge.wrappedValue) 225 | .ingressEdges(for: fragment) 226 | .map { 227 | PathEdge($0.edge, 228 | sourceId: edge.id, 229 | targetId: id) 230 | } 231 | } 232 | 233 | guard let pathEdge = pathEdges.first else { 234 | throw ConcreteHelmError.missingSegueToFragment(fragment) 235 | } 236 | 237 | return pathEdge 238 | } 239 | } 240 | 241 | func presentablePathEdge(with tag: T, id: ID? = nil) throws -> PathEdge where T: SegueTag, ID: PathFragmentIdentifier { 242 | let pathEdges = presentedFragments 243 | .reversed() 244 | .flatMap { edge in 245 | nav 246 | .egressEdges(for: edge.wrappedValue) 247 | .filter { $0.tag == AnyHashable(tag) } 248 | .map { 249 | PathEdge($0.edge, 250 | sourceId: edge.id, 251 | targetId: id) 252 | } 253 | } 254 | 255 | guard let pathEdge = pathEdges.last else { 256 | throw ConcreteHelmError.missingTaggedSegue(name: AnyHashable(tag)) 257 | } 258 | 259 | return pathEdge 260 | } 261 | 262 | func presentableForwardPathEdge(id: ID? = nil) throws -> PathEdge 263 | where ID: PathFragmentIdentifier 264 | { 265 | let fragment = try presentedFragments.last.unwrap() 266 | do { 267 | let segue = try nav.uniqueEgressEdge(for: fragment.wrappedValue) 268 | return PathEdge(segue.edge, 269 | sourceId: fragment.id, 270 | targetId: id) 271 | } catch { 272 | throw ConcreteHelmError.ambiguousForwardFromFragment(fragment.wrappedValue) 273 | } 274 | } 275 | 276 | func autoPresentablePathEdge(from fragment: N) throws -> PathEdge? { 277 | if path.isEmpty { 278 | return nav 279 | .egressEdges(for: fragment) 280 | .filter { $0.auto } 281 | .first 282 | .map { 283 | PathEdge($0.edge, 284 | sourceId: AnyHashable?.none, 285 | targetId: AnyHashable?.none) 286 | } 287 | } else { 288 | guard let pathEdge = presentedFragments 289 | .reversed() 290 | .compactMap({ edge in 291 | nav 292 | .egressEdges(for: fragment) 293 | .first(where: { $0.auto }) 294 | .map { 295 | PathEdge($0.edge, 296 | sourceId: edge.id, 297 | targetId: nil) 298 | } 299 | }) 300 | .last 301 | else { 302 | return nil 303 | } 304 | return pathEdge 305 | } 306 | } 307 | } 308 | 309 | // Dismiss-related methods 310 | public extension Helm { 311 | /// Dismisses a fragment. 312 | /// If the fragment is not already presented, the operation fails. 313 | /// If the fragment has no dismissable ingress segues, the operation fails. 314 | /// - note: Only the segues in the path (already visited) are considered when searching for the dismissable ingress segue. 315 | /// - parameter fragment: The given fragment. 316 | func dismiss(fragment: N) { 317 | do { 318 | let pathEdge = try dismissablePathEdge(for: fragment) 319 | try dismiss(pathEdge: pathEdge) 320 | } catch { 321 | errors.append(error) 322 | } 323 | } 324 | 325 | /// Dismisses a fragment by triggering (in reverse) a segue with a specific tag. 326 | /// If there is no such segue in the path (already visited) or the segue is not dismissable, the operation fails. 327 | /// - parameter tag: The tag to look after. 328 | func dismiss(tag: T) where T: SegueTag { 329 | do { 330 | let pathEdge = try dismissablePathEdge(with: tag) 331 | try dismiss(pathEdge: pathEdge) 332 | } catch { 333 | errors.append(error) 334 | } 335 | } 336 | 337 | /// Dismisses the last presented fragment. 338 | /// The operation fails if the latest fragment in the path has no dismissable ingress segue. 339 | func dismiss() { 340 | do { 341 | let pathEdge = try dismissableBackwardPathEdge() 342 | try dismiss(pathEdge: pathEdge) 343 | } catch { 344 | errors.append(error) 345 | } 346 | } 347 | 348 | /// Checks if a fragment can be dismissed. 349 | /// - parameter fragment: The fragment 350 | func canDismiss(fragment: N) -> Bool { 351 | do { 352 | _ = try dismissablePathEdge(for: fragment) 353 | return true 354 | } catch { 355 | return false 356 | } 357 | } 358 | 359 | /// Checks if a tag can be dismissed. 360 | /// - parameter tag: The tag 361 | func canDismiss(using tag: T) -> Bool where T: SegueTag { 362 | do { 363 | _ = try dismissablePathEdge(with: tag) 364 | return true 365 | } catch { 366 | return false 367 | } 368 | } 369 | 370 | /// Checks if the last presented fragment can be dismissed. 371 | func canDismiss() -> Bool { 372 | do { 373 | _ = try dismissableBackwardPathEdge() 374 | return true 375 | } catch { 376 | return false 377 | } 378 | } 379 | 380 | /// Replaces the entire current presented path an re-validates it. 381 | /// - parameter path: The new path. 382 | /// - throws: Throws if the new path fails validation. 383 | func replace(path: HelmPath) throws { 384 | self.path = path 385 | try validate() 386 | } 387 | 388 | /// Navigates a transition calling the right method (present, dismiss or replace). 389 | /// - parameter transition: A transition. 390 | /// - throws: Throws if the transition is not valid. 391 | func navigate(transition: HelmTransition) throws { 392 | switch transition { 393 | case let .present(step): 394 | try present(pathEdge: step) 395 | case let .dismiss(step): 396 | try dismiss(pathEdge: step) 397 | case let .replace(path): 398 | try replace(path: path) 399 | } 400 | } 401 | } 402 | 403 | // Dismiss-related private methods 404 | extension Helm { 405 | func dismiss(pathEdge: PathEdge) throws { 406 | try isDismissable(pathEdge: pathEdge) 407 | path = breakPath(pathEdge: pathEdge) 408 | } 409 | 410 | func dismissablePathEdge(for fragment: N) throws -> PathEdge { 411 | let segues = nav 412 | .ingressEdges(for: fragment) 413 | .filter { $0.dismissable } 414 | 415 | guard let pathEdge = path 416 | .reversed() 417 | .first(where: { segues.map(\.edge).contains($0.edge) }) 418 | else { 419 | throw ConcreteHelmError.fragmentMissingDismissableSegue(fragment) 420 | } 421 | 422 | try isDismissable(pathEdge: pathEdge) 423 | 424 | return pathEdge 425 | } 426 | 427 | func dismissablePathEdge(with tag: T) throws -> PathEdge where T: SegueTag { 428 | let segues = nav.filter { $0.tag == AnyHashable(tag) } 429 | 430 | guard let pathEdge = path.reversed().first(where: { segues.map(\.edge).contains($0.edge) }) 431 | else { 432 | throw ConcreteHelmError.missingTaggedSegue(name: AnyHashable(tag)) 433 | } 434 | 435 | try isDismissable(pathEdge: pathEdge) 436 | 437 | return pathEdge 438 | } 439 | 440 | func dismissableBackwardPathEdge() throws -> PathEdge { 441 | guard path.count > 0 else { 442 | throw ConcreteHelmError.emptyPath 443 | } 444 | 445 | for pathEdge in path.reversed() { 446 | do { 447 | try isDismissable(pathEdge: pathEdge) 448 | 449 | return pathEdge 450 | } catch ConcreteHelmError.segueNotDismissable {} 451 | } 452 | 453 | throw ConcreteHelmError.noDismissableSegues 454 | } 455 | 456 | func isDismissable(pathEdge: PathEdge) throws { 457 | guard let segue = try? segue(for: pathEdge.edge) else { 458 | throw ConcreteHelmError.missingSegueForEdge(pathEdge.edge) 459 | } 460 | 461 | guard segue.dismissable else { 462 | throw ConcreteHelmError.segueNotDismissable(segue) 463 | } 464 | 465 | guard path.contains(pathEdge) else { 466 | throw ConcreteHelmError.missingPathEdge(segue) 467 | } 468 | } 469 | } 470 | -------------------------------------------------------------------------------- /Sources/Helm/Helm+Transitions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 21/01/2022. 6 | // 7 | 8 | import Collections 9 | import Foundation 10 | 11 | public extension Helm { 12 | typealias PathFragmentIdentityProvider = (HelmGraphEdge) -> ID? 13 | 14 | /// Get all the possible transitions in the current navigation graph. 15 | /// - seealso: transitions(from:, identityProvider:) 16 | func transitions(from: N? = nil) -> [HelmTransition] { 17 | return transitions(from: from) { _ in 18 | String?.none 19 | } 20 | } 21 | 22 | /// Get all the possible transitions in the current navigation graph. 23 | /// This method does a deepth first search on the navigation graph while respecting all the navigation rules. 24 | /// - parameter from: The fragment to start at. If not provided whole graph is traversed. 25 | /// - parameter identityProvider: A provider that assigns ids to fragments as the graph is traversed. 26 | func transitions(from: N? = nil, 27 | identityProvider: PathFragmentIdentityProvider?) -> [HelmTransition] where ID: PathFragmentIdentifier 28 | { 29 | var result: [HelmTransition] = [] 30 | var visited: Set> = [] 31 | let inlets = OrderedSet( 32 | nav 33 | .egressEdges(for: from ?? entry) 34 | .sorted() 35 | .map { segue in 36 | PathEdge(segue.edge, 37 | sourceId: nil, 38 | targetId: identityProvider.flatMap { $0(segue.edge) }) 39 | } 40 | ) 41 | guard inlets.count > 0 else { 42 | return [] 43 | } 44 | 45 | var stack: [(HelmPath, PathEdge)] = inlets.map { 46 | ([], $0) 47 | } 48 | 49 | while stack.count > 0 { 50 | let (path, pathEdge) = stack.removeLast() 51 | let transition = HelmTransition.present(pathEdge: pathEdge) 52 | 53 | result.append(transition) 54 | visited.insert(pathEdge) 55 | 56 | let nextEdges = OrderedSet( 57 | nav 58 | .egressEdges(for: pathEdge.to.wrappedValue) 59 | .map { segue in 60 | PathEdge(segue.edge, 61 | sourceId: pathEdge.to.id, 62 | targetId: identityProvider.flatMap { $0(segue.edge) }) 63 | } 64 | .filter { !visited.contains($0) } 65 | .sorted() 66 | ) 67 | 68 | if nextEdges.count > 0 { 69 | stack.append(contentsOf: nextEdges.map { 70 | let nextPath = path.union([pathEdge]) 71 | return (nextPath, $0) 72 | }) 73 | } else { 74 | if let (nextPath, _) = stack.last { 75 | result.append(.replace(path: nextPath)) 76 | } 77 | } 78 | } 79 | 80 | return result 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Helm/Helm+Traversal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 21/01/2022. 6 | // 7 | 8 | import Collections 9 | import Foundation 10 | 11 | public extension Helm { 12 | /// The navigation graph's entry point. 13 | /// The entry fragment is always initially presented. 14 | var entry: N { 15 | nav.inlets.map { $0.from }[0] 16 | } 17 | } 18 | 19 | extension Helm { 20 | func segue(for edge: HelmGraphEdge) throws -> HelmSegue { 21 | if let segue = edgeToSegueMap[edge] { 22 | return segue 23 | } 24 | throw ConcreteHelmError.missingSegueForEdge(edge) 25 | } 26 | 27 | func calculatePresentedFragments() -> HelmPathFragments { 28 | var result: HelmPathFragments = [PathFragment(entry)] 29 | var visited: HelmPath = [] 30 | 31 | var degree: Int = 0 32 | while true { 33 | let fragment = result[result.count - 1] 34 | if let nextEdge = path.filter({ $0.from == fragment }).last, 35 | let nextDegree = path.lastIndex(of: nextEdge), 36 | degree <= nextDegree 37 | { 38 | degree = nextDegree 39 | if visited.contains(nextEdge) { 40 | break 41 | } 42 | visited.append(nextEdge) 43 | 44 | guard let segue = try? segue(for: nextEdge.edge) else { 45 | break 46 | } 47 | 48 | if segue.style == .pass { 49 | result.removeLast() 50 | } 51 | result.append(nextEdge.to) 52 | } else { 53 | break 54 | } 55 | } 56 | 57 | return result 58 | } 59 | 60 | func breakPath(pathEdge: PathEdge) -> HelmPath { 61 | var pathCopy = path 62 | let ingressEdges = pathCopy.ingressEdges(for: pathEdge.to) 63 | let egressEdges = pathCopy.egressEdges(for: pathEdge.to) 64 | for edge in ingressEdges.union(egressEdges) { 65 | pathCopy.remove(edge) 66 | } 67 | 68 | let removables = pathCopy 69 | .disconnectedSubgraphs 70 | .filter { 71 | !$0.has(node: pathEdge.from) 72 | } 73 | .flatMap { $0 } 74 | 75 | pathCopy.subtract(removables) 76 | 77 | return pathCopy 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Helm/Helm+Validation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 21/01/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Helm { 11 | func validate() throws { 12 | if nav.isEmpty { 13 | throw ConcreteHelmError.empty 14 | } 15 | 16 | if nav.inlets.count == 0 { 17 | throw ConcreteHelmError.missingInlets 18 | } 19 | 20 | guard Set(nav.inlets.map { $0.from }).count == 1 else { 21 | throw ConcreteHelmError.ambiguousInlets 22 | } 23 | 24 | var edgeToSegue: [HelmGraphEdge: HelmSegue] = [:] 25 | 26 | for segue in nav { 27 | if let other = edgeToSegue[segue.edge] { 28 | throw ConcreteHelmError.oneEdgeToManySegues([other, segue]) 29 | } 30 | edgeToSegue[segue.edge] = segue 31 | } 32 | 33 | let autoSegues = Set(nav.filter { $0.auto }) 34 | if autoSegues.hasCycle { 35 | throw ConcreteHelmError.autoCycleDetected(autoSegues) 36 | } 37 | 38 | guard Set(path.map(\.edge)).isSubset(of: nav.map { $0.edge }) else { 39 | throw ConcreteHelmError.pathMismatch(Set(path.map(\.edge))) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Helm/Helm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 10/12/2021. 6 | // 7 | 8 | import Collections 9 | import Foundation 10 | import SwiftUI 11 | 12 | /// A transition along an edge of the navigation graph. 13 | public enum PathTransition: Hashable { 14 | case present(pathEdge: PathEdge) 15 | case dismiss(pathEdge: PathEdge) 16 | case replace(path: OrderedSet>) 17 | } 18 | 19 | extension PathTransition: CustomDebugStringConvertible, CustomStringConvertible { 20 | public var debugDescription: String { 21 | switch self { 22 | case let .present(pathEdge): 23 | return ".present(\(pathEdge.debugDescription))" 24 | case let .dismiss(pathEdge): 25 | return ".dismiss(\(pathEdge.debugDescription))" 26 | case let .replace(pathEdges): 27 | return ".replace(\(pathEdges.map { $0.debugDescription }.joined(separator: ",")))" 28 | } 29 | } 30 | 31 | public var description: String { 32 | return debugDescription 33 | } 34 | } 35 | 36 | /// A fragment identifier used to distinguish fragments that have the same name, but display different data (i.e. a master detail list) 37 | public protocol PathFragmentIdentifier: Hashable {} 38 | 39 | extension String: PathFragmentIdentifier {} 40 | extension Int: PathFragmentIdentifier {} 41 | extension AnyHashable: PathFragmentIdentifier {} 42 | 43 | /// An edge between fragments in a path. 44 | public struct PathEdge: Hashable, DirectedConnector { 45 | /// The input fragment 46 | public let from: PathFragment 47 | /// The output fragment 48 | public let to: PathFragment 49 | 50 | /// Init using a regular edge and the source/target ids. 51 | public init(_ edge: DirectedEdge, 52 | sourceId: ID?, 53 | targetId: ID?) 54 | where ID: PathFragmentIdentifier 55 | { 56 | from = PathFragment(edge.from, id: sourceId) 57 | to = PathFragment(edge.to, id: targetId) 58 | } 59 | 60 | /// Turns the path edge into a regular edge. 61 | public var edge: DirectedEdge { 62 | DirectedEdge(from: from.wrappedValue, 63 | to: to.wrappedValue) 64 | } 65 | 66 | /// Returns the inverted path edge 67 | public var inverted: Self { 68 | PathEdge(.init(from: to.wrappedValue, 69 | to: from.wrappedValue), 70 | sourceId: from.id, 71 | targetId: to.id) 72 | } 73 | } 74 | 75 | extension PathEdge: CustomStringConvertible, CustomDebugStringConvertible { 76 | public var debugDescription: String { 77 | "\(from)->\(to)" 78 | } 79 | 80 | public var description: String { 81 | return debugDescription 82 | } 83 | } 84 | 85 | /// A fragment in a path. Unlike regular fragments, path fragments have an additional id that can be used to distinguish between fragments with the same name by different data (i.e. in master-detail list `(fragment: .detail, id: 1)` is different from `(fragment: .details, id: 2)`. 86 | public struct PathFragment: Fragment { 87 | public let wrappedValue: N 88 | public let id: AnyHashable? 89 | 90 | /// Init with a fragment 91 | public init(_ fragment: N) { 92 | wrappedValue = fragment 93 | id = nil 94 | } 95 | 96 | /// Init with a fragment and an id 97 | public init(_ fragment: N, id: ID? = nil) 98 | where ID: PathFragmentIdentifier 99 | { 100 | wrappedValue = fragment 101 | self.id = id 102 | } 103 | 104 | public static func < (lhs: PathFragment, rhs: PathFragment) -> Bool { 105 | lhs.wrappedValue < rhs.wrappedValue 106 | } 107 | } 108 | 109 | extension PathFragment: CustomStringConvertible, CustomDebugStringConvertible { 110 | public var debugDescription: String { 111 | "(\(wrappedValue), \(String(describing: id)))" 112 | } 113 | 114 | public var description: String { 115 | return debugDescription 116 | } 117 | } 118 | 119 | /// Helm holds the navigation rules plus the presented path. 120 | /// Has methods to navigate and list all possible transitions. 121 | public final class Helm: ObservableObject { 122 | public typealias HelmSegue = Segue 123 | public typealias HelmGraph = Set 124 | public typealias HelmGraphEdge = DirectedEdge 125 | public typealias HelmTransition = PathTransition 126 | public typealias HelmPath = OrderedSet> 127 | public typealias HelmPathFragments = OrderedSet> 128 | internal typealias ConcreteHelmError = HelmError 129 | 130 | /// The navigation graph describes all the navigation rules. 131 | public let nav: HelmGraph 132 | 133 | /// The presented path. It leads to the currently presented fragments. 134 | public internal(set) var path: HelmPath { 135 | didSet { 136 | presentedFragments = calculatePresentedFragments() 137 | } 138 | } 139 | 140 | /// The presented fragments. 141 | @Published public private(set) var presentedFragments: HelmPathFragments 142 | 143 | /// All the errors triggered when navigating the graph. 144 | @Published public internal(set) var errors: [Swift.Error] 145 | 146 | internal let edgeToSegueMap: [HelmGraphEdge: HelmSegue] 147 | 148 | /// Initializes a new Helm instance. 149 | /// - parameter nav: A directed graph of segues that defines all the navigation rules in the app. 150 | /// - parameter path: The path that leads to the currently presented fragments. 151 | public init(nav: HelmGraph, 152 | path: HelmPath = []) throws 153 | { 154 | errors = [] 155 | presentedFragments = [] 156 | self.nav = nav 157 | edgeToSegueMap = nav 158 | .map { 159 | ($0.edge, $0) 160 | } 161 | .reduce(into: [:]) { $0[$1.0] = $1.1 } 162 | self.path = path 163 | 164 | try validate() 165 | 166 | presentedFragments = calculatePresentedFragments() 167 | 168 | if let autoPathEdge = try autoPresentablePathEdge(from: entry) { 169 | try present(pathEdge: autoPathEdge) 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Sources/Helm/Optional+Unwrap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 04/01/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum OptionalError: Error { 11 | case failedToUnwrap 12 | } 13 | 14 | extension Optional { 15 | func unwrapOr(error: Error) throws -> Wrapped { 16 | if let value = self { 17 | return value 18 | } else { 19 | throw error 20 | } 21 | } 22 | 23 | func unwrap() throws -> Wrapped { 24 | try unwrapOr(error: OptionalError.failedToUnwrap) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Helm/Segue+Graph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 07/01/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Segue { 11 | /// Gets the edge of a segue. 12 | var edge: DirectedEdge { 13 | DirectedEdge(from: from, to: to) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Helm/Segue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 28/12/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Fragments represent full partial screens areas in an app. 11 | public protocol Fragment: Node {} 12 | 13 | /// A handler used in segue queries. 14 | public protocol SegueTag: Hashable {} 15 | 16 | extension AnyHashable: SegueTag {} 17 | 18 | /// Segues are the edges between the navigation graph's fragments. 19 | public struct Segue: DirectedConnector, Equatable { 20 | /// The input fragment 21 | public let from: N 22 | /// The output fragment 23 | public let to: N 24 | /// Specifies the presentation style. 25 | /// - seealso: `SeguePresentationStyle` 26 | public let style: SeguePresentationStyle 27 | /// Whether the segue can be dismissed or not. 28 | public let dismissable: Bool 29 | /// An auto segue will automatically fire towards its destination fragment as soon as the origin fragment has been presented. 30 | public let auto: Bool 31 | /// A tag identifying the segue. 32 | public let tag: AnyHashable? 33 | 34 | /// Initializes a new segue. 35 | /// - parameter from: The input fragment (origin fragment) 36 | /// - parameter to: The output fragment (destination fragment) 37 | /// - parameter style: The presentation style. Defaults to `.pass`. 38 | /// - parameter dismissable: A dismissable segue is allowed to return to the origin fragment. 39 | /// - parameter auto: Sets the auto firing behaviour. A fragment can only have one egress auto segue. Defaults to `false`. 40 | /// - parameter tag: A tag identifying the segue. Defaults to `nil`. 41 | public init(from: N, 42 | to: N, 43 | style: SeguePresentationStyle = .pass, 44 | dismissable: Bool = false, 45 | auto: Bool = false) 46 | { 47 | self.from = from 48 | self.to = to 49 | self.style = style 50 | self.dismissable = dismissable 51 | self.auto = auto 52 | tag = nil 53 | } 54 | 55 | public init(from: N, 56 | to: N, 57 | style: SeguePresentationStyle = .pass, 58 | dismissable: Bool = false, 59 | auto: Bool = false, 60 | tag: T? = nil) 61 | { 62 | self.from = from 63 | self.to = to 64 | self.style = style 65 | self.dismissable = dismissable 66 | self.auto = auto 67 | self.tag = tag 68 | } 69 | 70 | public init(_ edge: DirectedEdge, 71 | style: SeguePresentationStyle = .pass, 72 | dismissable: Bool = false, 73 | auto: Bool = false) 74 | { 75 | from = edge.from 76 | to = edge.to 77 | self.style = style 78 | self.dismissable = dismissable 79 | self.auto = auto 80 | tag = nil 81 | } 82 | 83 | public init(_ edge: DirectedEdge, 84 | style: SeguePresentationStyle = .pass, 85 | dismissable: Bool = false, 86 | auto: Bool = false, 87 | tag: T? = nil) 88 | { 89 | from = edge.from 90 | to = edge.to 91 | self.style = style 92 | self.dismissable = dismissable 93 | self.auto = auto 94 | self.tag = tag 95 | } 96 | 97 | /// Returns a modified auto copy of the segue. 98 | public func makeAuto() -> Self { 99 | Segue(from: from, 100 | to: to, 101 | style: style, 102 | dismissable: dismissable, 103 | auto: true, 104 | tag: tag) 105 | } 106 | 107 | /// Returns a modified dismissable copy of the segue. 108 | public func makeDismissable() -> Self { 109 | Segue(from: from, 110 | to: to, 111 | style: style, 112 | dismissable: true, 113 | auto: auto, 114 | tag: tag) 115 | } 116 | 117 | /// Returns a modified copy of the segue, setting the tag. 118 | public func with(tag: T) -> Self { 119 | Segue(from: from, 120 | to: to, 121 | style: style, 122 | dismissable: dismissable, 123 | auto: auto, 124 | tag: tag) 125 | } 126 | 127 | /// Returns a modified copy of the segue, setting the presentation style. 128 | public func with(style: SeguePresentationStyle) -> Self { 129 | Segue(from: from, 130 | to: to, 131 | style: style, 132 | dismissable: dismissable, 133 | auto: auto, 134 | tag: tag) 135 | } 136 | } 137 | 138 | /// Segue presentation styles define what happens with the origin fragment when presenting other fragment. 139 | public enum SeguePresentationStyle: Hashable { 140 | /// The origin fragment keeps its presented status. Both the origin and the destination fragment will be presented after walking the segue. 141 | case hold 142 | /// The origin fragment loses its presented status. Only the destination fragment will be presented after walking the segue. 143 | case pass 144 | } 145 | -------------------------------------------------------------------------------- /Sources/Playground/Examples/ActionSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 21/01/2022. 6 | // 7 | 8 | import Helm 9 | import SwiftUI 10 | 11 | struct ActionSheetExample: View { 12 | @EnvironmentObject private var _helm: Helm 13 | 14 | var body: some View { 15 | VStack { 16 | Button(action: { _helm.present(fragment: .b) }) { 17 | Text("Open sheet") 18 | } 19 | .sheet(isPresented: _helm.isPresented(.b)) { 20 | Text("Hello there!") 21 | } 22 | if let error = _helm.errors.last { 23 | Text("Error: \(error.localizedDescription)") 24 | .foregroundColor(.red) 25 | } 26 | } 27 | } 28 | } 29 | 30 | struct ActionSheetExample_Previews: PreviewProvider { 31 | struct PreviewWrapper: View { 32 | @StateObject private var _helm: Helm = try! Helm(nav: [ 33 | PlaygroundSegue(.a => .b).makeDismissable(), 34 | ]) 35 | 36 | var body: some View { 37 | ActionSheetExample() 38 | .environmentObject(_helm) 39 | } 40 | } 41 | 42 | static var previews: some View { 43 | PreviewWrapper() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Playground/Examples/Basic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 21/01/2022. 6 | // 7 | 8 | import Helm 9 | import SwiftUI 10 | 11 | /// This is a helper I often use to avoid `if`s. 12 | /// It's optional. 13 | struct FragmentView: View { 14 | @EnvironmentObject private var _helm: Helm 15 | private let _fragment: PlaygroundFragment 16 | private let _builder: () -> V 17 | init(_ fragment: PlaygroundFragment, @ViewBuilder builder: @escaping () -> V) { 18 | _fragment = fragment 19 | _builder = builder 20 | } 21 | 22 | var body: some View { 23 | if _helm.isPresented(_fragment) { 24 | _builder() 25 | } 26 | } 27 | } 28 | 29 | struct BasicExample: View { 30 | @EnvironmentObject private var _helm: Helm 31 | 32 | var body: some View { 33 | HStack { 34 | FragmentView(.b) { 35 | Rectangle() 36 | .frame(width: 75) 37 | .transition(.move(edge: .leading)) 38 | } 39 | Spacer() 40 | Toggle("Toggle menu", 41 | isOn: _helm.isPresented(.b)) 42 | .padding() 43 | .fixedSize() 44 | if let error = _helm.errors.last { 45 | Text("Error: \(error.localizedDescription)") 46 | .foregroundColor(.red) 47 | } 48 | } 49 | .animation(.default, value: _helm.isPresented(.b)) 50 | .frame(maxWidth: .infinity, 51 | maxHeight: .infinity) 52 | } 53 | } 54 | 55 | struct BasicExample_Previews: PreviewProvider { 56 | struct PreviewWrapper: View { 57 | @StateObject private var _helm: Helm = try! Helm(nav: [ 58 | PlaygroundSegue(.a => .b).makeDismissable(), 59 | ]) 60 | 61 | var body: some View { 62 | BasicExample() 63 | .environmentObject(_helm) 64 | } 65 | } 66 | 67 | static var previews: some View { 68 | PreviewWrapper() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Playground/Examples/NavigationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 20/01/2022. 6 | // 7 | 8 | import Helm 9 | import SwiftUI 10 | 11 | struct ContenView: View { 12 | @EnvironmentObject private var _helm: Helm 13 | 14 | let title: String 15 | var body: some View { 16 | Text("This is \(title)") 17 | } 18 | } 19 | 20 | struct NavigationViewExample: View { 21 | @EnvironmentObject private var _helm: Helm 22 | 23 | var body: some View { 24 | VStack { 25 | NavigationView { 26 | List { 27 | Section( 28 | content: { 29 | ForEach(["Porto", "London", "Barcelona"]) { city in 30 | NavigationLink(destination: ContenView(title: city), 31 | isActive: _helm.isPresented(.b, id: city)) { 32 | Text(city) 33 | } 34 | } 35 | Button(action: { _helm.present(fragment: .b, id: "London") }) { 36 | Text("Select London") 37 | } 38 | }, 39 | footer: { 40 | if let error = _helm.errors.last { 41 | Section { 42 | Text("Error: \(error.localizedDescription)") 43 | .foregroundColor(.red) 44 | } 45 | } 46 | } 47 | ) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | struct NavigationViewExample_Previews: PreviewProvider { 55 | struct PreviewWrapper: View { 56 | @StateObject private var _helm: Helm = try! Helm(nav: [ 57 | PlaygroundSegue(.a => .b).makeDismissable(), 58 | ]) 59 | 60 | var body: some View { 61 | NavigationViewExample() 62 | .environmentObject(_helm) 63 | } 64 | } 65 | 66 | static var previews: some View { 67 | PreviewWrapper() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Playground/Examples/TabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 21/01/2022. 6 | // 7 | 8 | import Helm 9 | import SwiftUI 10 | 11 | struct TabViewExample: View { 12 | @EnvironmentObject private var _helm: Helm 13 | 14 | var body: some View { 15 | VStack { 16 | if let error = _helm.errors.last { 17 | Text("Error: \(error.localizedDescription)") 18 | .foregroundColor(.red) 19 | .padding() 20 | } 21 | TabView(selection: _helm.pickPresented([.b, .c, .d])) { 22 | Text("Users view") 23 | .tabItem { 24 | Image(systemName: "person.circle.fill") 25 | Text("Users") 26 | } 27 | .tag(Optional.some(PlaygroundFragment.b)) 28 | Text("Clips view") 29 | .tabItem { 30 | Image(systemName: "paperclip.circle.fill") 31 | Text("Clips") 32 | } 33 | .tag(Optional.some(PlaygroundFragment.c)) 34 | Text("More view") 35 | .tabItem { 36 | Image(systemName: "ellipsis") 37 | Text("More") 38 | } 39 | .tag(Optional.some(PlaygroundFragment.d)) 40 | } 41 | } 42 | } 43 | } 44 | 45 | struct TabViewExample_Previews: PreviewProvider { 46 | struct PreviewWrapper: View { 47 | private static var segues: Set { 48 | let entry: PlaygroundEdge = .a => .c 49 | let forward: Set = .b => .c => .d => .b 50 | let backward: Set = .b => .d => .c => .b 51 | 52 | return Set( 53 | [PlaygroundSegue(entry).makeAuto()] 54 | + forward.map { Segue($0) } 55 | + backward.map { Segue($0) } 56 | ) 57 | } 58 | 59 | @StateObject private var _helm: Helm = try! Helm(nav: segues) 60 | 61 | var body: some View { 62 | TabViewExample() 63 | .environmentObject(_helm) 64 | } 65 | } 66 | 67 | static var previews: some View { 68 | PreviewWrapper() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Playground/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Playground/Media.xcassets/logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "logo.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Playground/Media.xcassets/logo.imageset/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | logo 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/Playground/PlaygroundFragment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 12/12/2021. 6 | // 7 | 8 | import Foundation 9 | import Helm 10 | 11 | typealias PlaygroundSegue = Segue 12 | typealias PlaygroundGraph = Set 13 | typealias PlaygroundEdge = DirectedEdge 14 | 15 | extension String: Identifiable { 16 | public var id: String { 17 | self 18 | } 19 | } 20 | 21 | enum PlaygroundFragment: Fragment { 22 | case a 23 | case b 24 | case c 25 | case d 26 | } 27 | -------------------------------------------------------------------------------- /Tests/HelmTests/Fixtures.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 28/12/2021. 6 | // 7 | 8 | import Foundation 9 | import Helm 10 | 11 | enum TestNode: Fragment { 12 | case a 13 | case b 14 | case c 15 | case d 16 | case e 17 | case f 18 | case g 19 | case h 20 | case i 21 | case j 22 | } 23 | 24 | enum TestTag: SegueTag { 25 | case menu 26 | } 27 | 28 | extension DirectedEdge where N == TestNode { 29 | static var aa: Self { .a => .a } 30 | static var ab: Self { .a => .b } 31 | static var ac: Self { .a => .c } 32 | static var ad: Self { .a => .d } 33 | static var ba: Self { .b => .a } 34 | static var bc: Self { .b => .c } 35 | static var bb: Self { .b => .b } 36 | static var bd: Self { .b => .d } 37 | static var be: Self { .b => .e } 38 | static var cb: Self { .c => .b } 39 | static var cd: Self { .c => .d } 40 | static var ca: Self { .c => .a } 41 | static var ce: Self { .c => .e } 42 | static var ch: Self { .c => .h } 43 | static var cc: Self { .c => .c } 44 | static var db: Self { .d => .b } 45 | static var de: Self { .d => .e } 46 | static var df: Self { .d => .f } 47 | static var dg: Self { .d => .g } 48 | static var dh: Self { .d => .h } 49 | static var eb: Self { .e => .b } 50 | static var hf: Self { .h => .f } 51 | static var hj: Self { .h => .j } 52 | static var jg: Self { .j => .g } 53 | } 54 | 55 | extension PathEdge where N == TestNode { 56 | static var aa: Self { .init(.a => .a, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 57 | static var ab: Self { .init(.a => .b, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 58 | static var ac: Self { .init(.a => .c, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 59 | static var ad: Self { .init(.a => .d, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 60 | static var ba: Self { .init(.b => .a, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 61 | static var bb: Self { .init(.b => .b, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 62 | static var bc: Self { .init(.b => .c, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 63 | static var bd: Self { .init(.b => .d, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 64 | static var be: Self { .init(.b => .e, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 65 | static var ca: Self { .init(.c => .a, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 66 | static var cb: Self { .init(.c => .b, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 67 | static var cc: Self { .init(.c => .c, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 68 | static var cd: Self { .init(.c => .d, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 69 | static var ce: Self { .init(.c => .e, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 70 | static var ch: Self { .init(.c => .h, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 71 | static var db: Self { .init(.d => .b, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 72 | static var de: Self { .init(.d => .e, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 73 | static var df: Self { .init(.d => .f, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 74 | static var dg: Self { .init(.d => .g, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 75 | static var dh: Self { .init(.d => .h, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 76 | static var eb: Self { .init(.e => .b, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 77 | static var hf: Self { .init(.h => .f, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 78 | static var hj: Self { .init(.h => .j, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 79 | static var jg: Self { .init(.j => .g, sourceId: AnyHashable?.none, targetId: AnyHashable?.none) } 80 | } 81 | 82 | extension Segue where N == TestNode { 83 | static var aa: Self { .init(from: .a, to: .a) } 84 | static var ab: Self { .init(from: .a, to: .b) } 85 | static var ac: Self { .init(from: .a, to: .c) } 86 | static var ad: Self { .init(from: .a, to: .d) } 87 | static var ba: Self { .init(from: .b, to: .a) } 88 | static var bc: Self { .init(from: .b, to: .c) } 89 | static var bd: Self { .init(from: .b, to: .d) } 90 | static var bb: Self { .init(from: .b, to: .b) } 91 | static var be: Self { .init(from: .b, to: .e) } 92 | static var cb: Self { .init(from: .c, to: .b) } 93 | static var cd: Self { .init(from: .c, to: .d) } 94 | static var ce: Self { .init(from: .c, to: .e) } 95 | static var ch: Self { .init(from: .c, to: .h) } 96 | static var ca: Self { .init(from: .c, to: .a) } 97 | static var db: Self { .init(from: .d, to: .b) } 98 | static var de: Self { .init(from: .d, to: .e) } 99 | static var df: Self { .init(from: .d, to: .f) } 100 | static var cc: Self { .init(from: .c, to: .c) } 101 | static var dg: Self { .init(from: .d, to: .g) } 102 | static var dh: Self { .init(from: .d, to: .h) } 103 | static var eb: Self { .init(from: .e, to: .b) } 104 | static var hf: Self { .init(from: .h, to: .f) } 105 | static var hj: Self { .init(from: .h, to: .j) } 106 | static var jg: Self { .init(from: .j, to: .g) } 107 | } 108 | 109 | extension Array where Element == Segue { 110 | func makeAuto() -> Self { 111 | map { 112 | $0.makeAuto() 113 | } 114 | } 115 | 116 | func makeDismissable() -> Self { 117 | map { 118 | $0.makeDismissable() 119 | } 120 | } 121 | 122 | func with(rule: SeguePresentationStyle) -> Self { 123 | map { 124 | $0.with(style: rule) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Tests/HelmTests/GraphTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GraphTests.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 11/01/2022. 6 | // 7 | 8 | @testable import Helm 9 | import XCTest 10 | 11 | private typealias TestGraphEdge = DirectedEdge 12 | private typealias TestGraph = Set 13 | private typealias TestError = DirectedEdgeCollectionError 14 | 15 | class GraphTests: XCTestCase { 16 | func testPrintEdge() { 17 | XCTAssertEqual(TestGraphEdge.ab.debugDescription, "a -> b") 18 | } 19 | 20 | func testHasEdge() { 21 | let graph = TestGraph([.ab]) 22 | 23 | XCTAssertTrue(graph.has(edge: .ab)) 24 | XCTAssertFalse(graph.has(edge: .bc)) 25 | } 26 | 27 | func testHasNode() { 28 | let graph = TestGraph([.ab]) 29 | 30 | XCTAssertTrue(graph.has(node: .a)) 31 | XCTAssertTrue(graph.has(node: .b)) 32 | XCTAssertFalse(graph.has(node: .c)) 33 | } 34 | 35 | func testHasCycle() { 36 | let cyclicGraph = TestGraph([.ab, .bc, .cd, .db]) 37 | XCTAssertTrue(cyclicGraph.hasCycle) 38 | 39 | let acyclicGraph = TestGraph([.ab, .bc, .cd, .ad]) 40 | XCTAssertFalse(acyclicGraph.hasCycle) 41 | 42 | let emptyGraph = TestGraph([]) 43 | XCTAssertFalse(emptyGraph.hasCycle) 44 | 45 | let loopGraph = TestGraph([.ab, .ba]) 46 | XCTAssertTrue(loopGraph.hasCycle) 47 | } 48 | 49 | func testFirstCycle() { 50 | let graph = TestGraph([.ab, .bc, .cd, .db]) 51 | XCTAssertEqual(graph.firstCycle, [.bc, .cd, .db]) 52 | } 53 | 54 | func testEgressEdges() { 55 | let graph = TestGraph([.ab, .bc, .bd, .ba]) 56 | XCTAssertEqual(graph.egressEdges(for: .b), [.bc, .bd, .ba]) 57 | XCTAssertEqual(graph.egressEdges(for: [.a, .b, .c]), [.ab, .bc, .bd, .ba]) 58 | XCTAssertEqual(try! graph.uniqueEgressEdge(for: .a), .ab) 59 | 60 | let ambiguousError = TestError.ambiguousEgressEdges([.bc, .bd, .ba], 61 | from: .b) 62 | XCTAssertThrowsError(try graph.uniqueEgressEdge(for: .b), 63 | ambiguousError.localizedDescription) 64 | 65 | let missingError = TestError.missingEgressEdges(from: .d) 66 | XCTAssertThrowsError(try graph.uniqueEgressEdge(for: .d), 67 | missingError.localizedDescription) 68 | } 69 | 70 | func testIngressEdges() { 71 | let graph = TestGraph([.ab, .cb, .db, .ba]) 72 | XCTAssertEqual(graph.ingressEdges(for: .b), [.ab, .cb, .db]) 73 | XCTAssertEqual(graph.ingressEdges(for: [.a, .b, .c]), [.ab, .cb, .db, .ba]) 74 | 75 | let ambiguousError = TestError.ambiguousIngressEdges([.ab, .cb, .db], 76 | to: .b) 77 | XCTAssertThrowsError(try graph.uniqueIngressEdge(for: .b), 78 | ambiguousError.localizedDescription) 79 | 80 | let missingError = TestError.missingIngressEdges(to: .d) 81 | XCTAssertThrowsError(try graph.uniqueIngressEdge(for: .d), 82 | missingError.localizedDescription) 83 | } 84 | 85 | func testInlets() { 86 | let cyclicGraph = TestGraph([.ab, .bc, .cb, .ba]) 87 | XCTAssertEqual(cyclicGraph.inlets, []) 88 | 89 | let emptyGraph = TestGraph([]) 90 | XCTAssertEqual(emptyGraph.inlets, []) 91 | 92 | let graph = TestGraph([.ab, .bc, .cb, .db]) 93 | XCTAssertEqual(graph.inlets, [.ab, .db]) 94 | } 95 | 96 | func testOutlets() { 97 | let cyclicGraph = TestGraph([.ab, .bc, .cb, .ba]) 98 | XCTAssertEqual(cyclicGraph.outlets, []) 99 | 100 | let emptyGraph = TestGraph([]) 101 | XCTAssertEqual(emptyGraph.outlets, []) 102 | 103 | let graph = TestGraph([.ab, .bc, .cb, .bd]) 104 | XCTAssertEqual(graph.outlets, [.bd]) 105 | } 106 | 107 | func testNodes() { 108 | let graph = TestGraph([.ab, .bc, .cb]) 109 | XCTAssertEqual(graph.nodes, [.a, .b, .c]) 110 | } 111 | 112 | func testDisconnectedSubgraphs() { 113 | let emptyGraph = TestGraph([]) 114 | XCTAssertEqual(emptyGraph.disconnectedSubgraphs, []) 115 | 116 | let singleGraph = TestGraph([.ab, .bc, .ac]) 117 | XCTAssertEqual(singleGraph.disconnectedSubgraphs, [[.ab, .bc, .ac]]) 118 | 119 | let multiGraph = TestGraph([.ab, .bc, .ac, .de, .df]) 120 | XCTAssertEqual(multiGraph.disconnectedSubgraphs, 121 | [[.ab, .bc, .ac], [.de, .df]]) 122 | } 123 | 124 | func testDFS() { 125 | let graph = TestGraph([.ab, .ac, .cd, .ch, .df, .hf, .de, .dg, .hj, .jg]) 126 | 127 | XCTAssertEqual(graph.dfs(), 128 | [.ab, .ac, .cd, .de, .df, .dg, .ch, .hf, .hj, .jg]) 129 | } 130 | 131 | func testOTONodeOperator() { 132 | let edge: DirectedEdge = .a => .b 133 | XCTAssertEqual(edge, .ab) 134 | } 135 | 136 | func testOTMNodeOperator() { 137 | let edges: Set> = .a => [.b, .c] 138 | XCTAssertEqual(edges, [.ab, .ac]) 139 | } 140 | 141 | func testMTONodeOperator() { 142 | let edges: Set> = [.b, .c] => .d 143 | XCTAssertEqual(edges, [.bd, .cd]) 144 | } 145 | 146 | func testMTMNodeOperator() { 147 | let edges: Set> = [.a, .b, .c] => [.a, .b, .c] 148 | XCTAssertEqual(edges, [ 149 | .aa, .ab, .ac, 150 | .ba, .bb, .bc, 151 | .ca, .cb, .cc, 152 | ]) 153 | } 154 | 155 | func testOTOEdgeOperator() { 156 | let edges: Set> = .a => .b => .c 157 | XCTAssertEqual(edges, [.ab, .bc]) 158 | } 159 | 160 | func testMTOEdgeOperator() { 161 | let edges: Set> = .a => [.b, .c] => .d 162 | XCTAssertEqual(edges, [.ab, .ac, .bd, .cd]) 163 | } 164 | 165 | func testOTMEdgeOperator() { 166 | let edges: Set> = .a => .b => [.c, .d] 167 | XCTAssertEqual(edges, [.ab, .bc, .bd]) 168 | } 169 | 170 | func testMTMEdgeOperator() { 171 | let edges: Set> = .a => [.b, .c] => [.d, .e] 172 | XCTAssertEqual(edges, [.ab, .ac, .bd, .be, .cd, .ce]) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Tests/HelmTests/HelmTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Valentin Radu on 12/01/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | @testable import Helm 12 | import XCTest 13 | 14 | private typealias TestGraphSegue = Segue 15 | private typealias TestGraph = Set 16 | private typealias TestGraphError = HelmError 17 | 18 | class HelmTests: XCTestCase { 19 | func testInitFail() { 20 | let emptyGraph = TestGraph([]) 21 | XCTAssertThrowsError(try Helm(nav: emptyGraph), 22 | TestGraphError.empty.description) 23 | 24 | let noInletsGraph = TestGraph([.ab, .ba]) 25 | XCTAssertThrowsError(try Helm(nav: noInletsGraph), 26 | TestGraphError.missingInlets.description) 27 | 28 | let multiInletsGraph = TestGraph([.ab, .cd]) 29 | XCTAssertThrowsError(try Helm(nav: multiInletsGraph), 30 | TestGraphError.ambiguousInlets.description) 31 | 32 | let cycle = TestGraph([.bc, .cb].makeAuto()) 33 | let autoCycleGraph = TestGraph( 34 | [.ab] + cycle 35 | ) 36 | XCTAssertThrowsError(try Helm(nav: autoCycleGraph), 37 | TestGraphError.autoCycleDetected(cycle).description) 38 | 39 | let multiSeguesPerEdgeGraph = TestGraph([.ab, .bc] + [.bc].makeAuto()) 40 | XCTAssertThrowsError(try Helm(nav: multiSeguesPerEdgeGraph), 41 | TestGraphError.oneEdgeToManySegues([.bc]).description) 42 | 43 | let mismatchPathGraph = TestGraph([.ab, .bc]) 44 | XCTAssertThrowsError(try Helm(nav: mismatchPathGraph, path: [.ac]), 45 | TestGraphError.pathMismatch([.ac]).description) 46 | } 47 | 48 | func testEntry() throws { 49 | let graph = TestGraph([.ab, .ac, .ad]) 50 | let helm = try Helm(nav: graph) 51 | XCTAssertEqual(helm.entry, .a) 52 | } 53 | 54 | func testAutoEntry() throws { 55 | let graph = TestGraph([.ab.makeAuto(), .ac, .ad]) 56 | let helm = try Helm(nav: graph) 57 | 58 | XCTAssertTrue(helm.isPresented(.b)) 59 | } 60 | 61 | func testAuto() throws { 62 | let graph = TestGraph([.ab, .bc.makeAuto()]) 63 | let helm = try Helm(nav: graph) 64 | 65 | helm.present(fragment: .b) 66 | 67 | print(helm.presentedFragments) 68 | 69 | XCTAssertTrue(helm.isPresented(.c)) 70 | } 71 | 72 | func testCyclicPath() throws { 73 | let graph = TestGraph([.ab, .bc, .cb]) 74 | let helm = try Helm(nav: graph, path: [.ab]) 75 | 76 | helm.present(fragment: .c) 77 | 78 | XCTAssertFalse(helm.isPresented(.b)) 79 | XCTAssertTrue(helm.isPresented(.c)) 80 | 81 | helm.present(fragment: .b) 82 | 83 | XCTAssertTrue(helm.isPresented(.b)) 84 | XCTAssertFalse(helm.isPresented(.c)) 85 | 86 | helm.present(fragment: .c) 87 | 88 | XCTAssertFalse(helm.isPresented(.b)) 89 | XCTAssertTrue(helm.isPresented(.c)) 90 | } 91 | 92 | func testHoldCyclicPath() throws { 93 | let graph = TestGraph([.ab, .bc, .cd.with(style: .hold), .ce, .eb, .be]) 94 | let helm = try Helm(nav: graph, path: [.ab]) 95 | 96 | helm.present(fragment: .c) 97 | helm.present(fragment: .d) 98 | helm.present(fragment: .e) 99 | helm.present(fragment: .b) 100 | helm.present(fragment: .e) 101 | 102 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 103 | XCTAssertFalse(helm.isPresented(.a)) 104 | XCTAssertFalse(helm.isPresented(.b)) 105 | XCTAssertFalse(helm.isPresented(.c)) 106 | XCTAssertFalse(helm.isPresented(.d)) 107 | XCTAssertTrue(helm.isPresented(.e)) 108 | } 109 | 110 | func testIsPresented() throws { 111 | let graph = TestGraph([.ab].makeDismissable()) 112 | let helm = try Helm(nav: graph, path: [.ab]) 113 | 114 | XCTAssertTrue(helm.isPresented(.b)) 115 | XCTAssertFalse(helm.isPresented(.a)) 116 | 117 | let binding: Binding = helm.isPresented(.b) 118 | 119 | binding.wrappedValue = false 120 | 121 | XCTAssertFalse(helm.isPresented(.b)) 122 | XCTAssertTrue(helm.isPresented(.a)) 123 | 124 | binding.wrappedValue = true 125 | 126 | XCTAssertTrue(helm.isPresented(.b)) 127 | XCTAssertFalse(helm.isPresented(.a)) 128 | } 129 | 130 | func testDismissEdgeFail() throws { 131 | let helm = try Helm(nav: TestGraph([.ab] + [.ac].makeDismissable()), path: [.ab]) 132 | XCTAssertThrowsError(try helm.dismiss(pathEdge: .db), 133 | TestGraphError.missingSegueForEdge(.db).description) 134 | XCTAssertThrowsError(try helm.dismiss(pathEdge: .ab), 135 | TestGraphError.segueNotDismissable(.ab).description) 136 | XCTAssertThrowsError(try helm.dismiss(pathEdge: .ac), 137 | TestGraphError.missingPathEdge(.ac).description) 138 | } 139 | 140 | func testDismissEdge() throws { 141 | let graph = TestGraph( 142 | [.ab] 143 | + [.bc].makeDismissable().with(rule: .hold) 144 | + [.cd] 145 | ) 146 | let helm = try Helm(nav: graph, path: [.ab, .bc, .cd]) 147 | 148 | try helm.dismiss(pathEdge: .bc) 149 | 150 | XCTAssertFalse(helm.isPresented(.a)) 151 | XCTAssertTrue(helm.isPresented(.b)) 152 | XCTAssertFalse(helm.isPresented(.d)) 153 | XCTAssertFalse(helm.isPresented(.c)) 154 | XCTAssertEqual(helm.path, [.ab]) 155 | } 156 | 157 | func testDismissLastFail() throws { 158 | let graph = TestGraph([.ab] + [.bc]) 159 | let helm = try Helm(nav: graph) 160 | 161 | helm.dismiss() 162 | helm.present(fragment: .b) 163 | helm.present(fragment: .c) 164 | helm.dismiss() 165 | 166 | XCTAssertEqual(helm.errors as? [TestGraphError], [ 167 | TestGraphError.emptyPath, 168 | TestGraphError.noDismissableSegues, 169 | ]) 170 | } 171 | 172 | func testDismissLast() throws { 173 | let graph = TestGraph([.ab] + [.bc].makeDismissable() + [.cd]) 174 | let helm = try Helm(nav: graph, path: [.ab, .bc, .cd]) 175 | 176 | helm.dismiss() 177 | 178 | XCTAssertTrue(helm.isPresented(.b)) 179 | XCTAssertEqual(helm.path, [.ab]) 180 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 181 | } 182 | 183 | func testDismissTagFail() throws { 184 | let graph = TestGraph([.ab.makeDismissable(), .bc]) 185 | let helm = try Helm(nav: graph, path: [.ab, .bc]) 186 | 187 | let tag = TestTag.menu 188 | helm.dismiss(tag: tag) 189 | 190 | XCTAssertEqual(helm.errors as? [TestGraphError], 191 | [.missingTaggedSegue(name: AnyHashable(tag))]) 192 | } 193 | 194 | func testDismissTag() throws { 195 | let tag = TestTag.menu 196 | let graph = TestGraph([.ab.makeDismissable().with(tag: tag), .bc]) 197 | let helm = try Helm(nav: graph, path: [.ab, .bc]) 198 | 199 | helm.dismiss(tag: tag) 200 | 201 | XCTAssertTrue(helm.isPresented(.a)) 202 | XCTAssertFalse(helm.isPresented(.b)) 203 | XCTAssertFalse(helm.isPresented(.c)) 204 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 205 | } 206 | 207 | func testDismissFragmentFail() throws { 208 | let graph = TestGraph([.ab, .bc]) 209 | let helm = try Helm(nav: graph, path: [.ab, .bc]) 210 | 211 | helm.dismiss(fragment: .c) 212 | XCTAssertEqual(helm.errors as? [TestGraphError], 213 | [.fragmentMissingDismissableSegue(.c)]) 214 | } 215 | 216 | func testDismissFragment() throws { 217 | let graph = TestGraph([.ab] + [.bc].makeDismissable()) 218 | let helm = try Helm(nav: graph, path: [.ab, .bc]) 219 | 220 | helm.dismiss(fragment: .c) 221 | 222 | XCTAssertTrue(helm.isPresented(.b)) 223 | XCTAssertEqual(helm.path, [.ab]) 224 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 225 | } 226 | 227 | func testPresentEdge() throws { 228 | let graph = TestGraph([.ab, .bc, .cd]) 229 | let helm = try Helm(nav: graph, path: [.ab]) 230 | 231 | try helm.present(pathEdge: .bc) 232 | 233 | XCTAssertTrue(helm.isPresented(.c)) 234 | XCTAssertEqual(helm.path, [.ab, .bc]) 235 | } 236 | 237 | func testForwardFail() throws { 238 | let graph = TestGraph([.ab, .ac, .bc, .bd]) 239 | let helm = try Helm(nav: graph) 240 | 241 | helm.forward() 242 | 243 | XCTAssertTrue(helm.isPresented(.a)) 244 | XCTAssertFalse(helm.isPresented(.c)) 245 | XCTAssertFalse(helm.isPresented(.b)) 246 | 247 | helm.present(fragment: .b) 248 | helm.forward() 249 | 250 | XCTAssertTrue(helm.isPresented(.b)) 251 | XCTAssertFalse(helm.isPresented(.c)) 252 | XCTAssertFalse(helm.isPresented(.d)) 253 | XCTAssertEqual(helm.errors as? [TestGraphError], 254 | [ 255 | .ambiguousForwardFromFragment(.a), 256 | .ambiguousForwardFromFragment(.b), 257 | ]) 258 | } 259 | 260 | func testForward() throws { 261 | let graph = TestGraph([.ab, .bc, .cd]) 262 | let helm = try Helm(nav: graph) 263 | 264 | helm.forward() 265 | 266 | XCTAssertFalse(helm.isPresented(.a)) 267 | XCTAssertTrue(helm.isPresented(.b)) 268 | XCTAssertFalse(helm.isPresented(.c)) 269 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 270 | 271 | helm.forward() 272 | 273 | XCTAssertFalse(helm.isPresented(.a)) 274 | XCTAssertFalse(helm.isPresented(.b)) 275 | XCTAssertTrue(helm.isPresented(.c)) 276 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 277 | } 278 | 279 | func testPresentTagFail() throws { 280 | let tag = TestTag.menu 281 | let graph = TestGraph([.ab]) 282 | let helm = try Helm(nav: graph, path: [.ab]) 283 | 284 | helm.present(tag: tag) 285 | 286 | XCTAssertFalse(helm.isPresented(.a)) 287 | XCTAssertTrue(helm.isPresented(.b)) 288 | XCTAssertEqual(helm.errors as? [TestGraphError], 289 | [.missingTaggedSegue(name: AnyHashable(tag))]) 290 | } 291 | 292 | func testPresentTag() throws { 293 | let tag = TestTag.menu 294 | let graph = TestGraph([.ab.with(tag: tag)]) 295 | let helm = try Helm(nav: graph) 296 | 297 | helm.present(tag: tag) 298 | 299 | XCTAssertFalse(helm.isPresented(.a)) 300 | XCTAssertTrue(helm.isPresented(.b)) 301 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 302 | } 303 | 304 | func testPresentFragmentFail() throws { 305 | let graph = TestGraph([.ab, .ac, .cd]) 306 | let helm = try Helm(nav: graph) 307 | 308 | helm.present(fragment: .d) 309 | 310 | XCTAssertTrue(helm.isPresented(.a)) 311 | XCTAssertFalse(helm.isPresented(.d)) 312 | 313 | helm.present(fragment: .c) 314 | helm.present(fragment: .b) 315 | 316 | XCTAssertFalse(helm.isPresented(.a)) 317 | XCTAssertFalse(helm.isPresented(.b)) 318 | XCTAssertTrue(helm.isPresented(.c)) 319 | 320 | XCTAssertEqual(helm.errors as? [TestGraphError], 321 | [.missingSegueToFragment(.d), 322 | .missingSegueToFragment(.b)]) 323 | } 324 | 325 | func testPresentFragment() throws { 326 | let graph = TestGraph([.ab, .ac, .cd]) 327 | let helm = try Helm(nav: graph) 328 | 329 | helm.present(fragment: .c) 330 | 331 | XCTAssertFalse(helm.isPresented(.a)) 332 | XCTAssertFalse(helm.isPresented(.b)) 333 | XCTAssertTrue(helm.isPresented(.c)) 334 | 335 | helm.present(fragment: .d) 336 | 337 | XCTAssertFalse(helm.isPresented(.c)) 338 | XCTAssertTrue(helm.isPresented(.d)) 339 | 340 | XCTAssertEqual(helm.errors as? [TestGraphError], []) 341 | } 342 | 343 | func testPickPresented() throws { 344 | let graph = TestGraph([ 345 | .ab.makeDismissable(), 346 | .ac.makeDismissable(), 347 | .bc, .cb, 348 | ]) 349 | let helm = try Helm(nav: graph, path: [.ab]) 350 | 351 | let binding = helm.pickPresented([.b, .c]) 352 | 353 | XCTAssertEqual(binding.wrappedValue, .b) 354 | 355 | helm.present(fragment: .c) 356 | 357 | XCTAssertEqual(binding.wrappedValue, .c) 358 | 359 | binding.wrappedValue = .b 360 | 361 | XCTAssertFalse(helm.isPresented(.a)) 362 | XCTAssertTrue(helm.isPresented(.b)) 363 | XCTAssertFalse(helm.isPresented(.c)) 364 | 365 | binding.wrappedValue = nil 366 | 367 | XCTAssertTrue(helm.isPresented(.a)) 368 | XCTAssertFalse(helm.isPresented(.b)) 369 | XCTAssertFalse(helm.isPresented(.c)) 370 | } 371 | 372 | func testReplacePathFail() throws { 373 | let graph = TestGraph([.ab, .bc, .cd]) 374 | let helm = try Helm(nav: graph, path: [.ab]) 375 | 376 | XCTAssertThrowsError(try helm.replace(path: [.ac]), 377 | TestGraphError.missingSegueForEdge(.db).description) 378 | } 379 | 380 | func testReplacePath() throws { 381 | let graph = TestGraph([.ab, .ac.with(style: .hold)]) 382 | let helm = try Helm(nav: graph, path: [.ab]) 383 | 384 | try helm.replace(path: [.ac]) 385 | 386 | XCTAssertTrue(helm.isPresented(.a)) 387 | XCTAssertFalse(helm.isPresented(.b)) 388 | XCTAssertTrue(helm.isPresented(.c)) 389 | } 390 | 391 | func testTransitions() throws { 392 | let graph = TestGraph([.ab, .ac, .ad, .bc, .bd, .cd, .db]) 393 | let helm = try Helm(nav: graph) 394 | 395 | let transitions = helm.transitions() 396 | XCTAssertEqual(transitions, 397 | [ 398 | .present(pathEdge: .ad), 399 | .present(pathEdge: .db), 400 | .present(pathEdge: .bd), 401 | .replace(path: [.ad, .db]), 402 | .present(pathEdge: .bc), 403 | .present(pathEdge: .cd), 404 | .replace(path: []), 405 | .present(pathEdge: .ac), 406 | .replace(path: []), 407 | .present(pathEdge: .ab), 408 | ]) 409 | 410 | for transition in transitions { 411 | XCTAssertNoThrow(try helm.navigate(transition: transition)) 412 | } 413 | } 414 | 415 | func testFragmentIdentity() throws { 416 | let graph = TestGraph([.ab.makeDismissable()]) 417 | let helm = try Helm(nav: graph) 418 | 419 | helm.present(fragment: .b, id: 1) 420 | 421 | XCTAssertFalse(helm.isPresented(.a)) 422 | XCTAssertFalse(helm.isPresented(.b)) 423 | XCTAssertTrue(helm.isPresented(.b, id: 1)) 424 | 425 | helm.dismiss(fragment: .b) 426 | 427 | XCTAssertTrue(helm.isPresented(.a)) 428 | XCTAssertFalse(helm.isPresented(.b)) 429 | XCTAssertFalse(helm.isPresented(.b, id: 1)) 430 | } 431 | 432 | func testFragmentIdentityBinding() throws { 433 | let graph = TestGraph([.ab.makeDismissable()]) 434 | let helm = try Helm(nav: graph) 435 | 436 | let binding: Binding = helm.isPresented(.b, id: 1) 437 | 438 | binding.wrappedValue = true 439 | 440 | XCTAssertFalse(helm.isPresented(.a)) 441 | XCTAssertFalse(helm.isPresented(.b)) 442 | XCTAssertTrue(helm.isPresented(.b, id: 1)) 443 | 444 | binding.wrappedValue = false 445 | 446 | XCTAssertTrue(helm.isPresented(.a)) 447 | XCTAssertFalse(helm.isPresented(.b)) 448 | XCTAssertFalse(helm.isPresented(.b, id: 1)) 449 | } 450 | 451 | func testFragmentIdentityMatchFirst() throws { 452 | let graph = TestGraph([.ab.makeDismissable()]) 453 | let helm = try Helm(nav: graph) 454 | 455 | helm.present(fragment: .b, id: 1) 456 | 457 | XCTAssertEqual(helm.matchFirst(.b), 1) 458 | XCTAssertEqual(helm.matchFirst(.a), Int?.none) 459 | } 460 | 461 | func testFragmentIdentityMatchFirstButMany() throws { 462 | let graph = TestGraph([.ab.makeDismissable(), .bb.with(style: .hold)]) 463 | let helm = try Helm(nav: graph) 464 | 465 | helm.present(fragment: .b, id: 1) 466 | helm.present(fragment: .b, id: 2) 467 | 468 | XCTAssertEqual(helm.matchFirst(.b), Int?.none) 469 | XCTAssertEqual(helm.matchFirst(.a), Int?.none) 470 | } 471 | 472 | func testFragmentIdentityMatchAll() throws { 473 | let graph = TestGraph([.ab.makeDismissable(), .bb.with(style: .hold)]) 474 | let helm = try Helm(nav: graph) 475 | 476 | helm.present(fragment: .b, id: 1) 477 | helm.present(fragment: .b, id: 2) 478 | 479 | XCTAssertEqual(helm.matchAll(.b), [1, 2]) 480 | XCTAssertEqual(helm.matchFirst(.a), Int?.none) 481 | } 482 | 483 | func testDisconectedPresentedNodes() throws { 484 | let graph = TestGraph([.ab, .bc, .ce, .cd.with(style: .hold)]) 485 | let helm = try Helm(nav: graph, path: [.ab, .bc, .cd]) 486 | 487 | XCTAssertTrue(helm.isPresented(.c)) 488 | XCTAssertTrue(helm.isPresented(.d)) 489 | 490 | helm.present(fragment: .e) 491 | 492 | XCTAssertFalse(helm.isPresented(.c)) 493 | XCTAssertFalse(helm.isPresented(.d)) 494 | } 495 | } 496 | -------------------------------------------------------------------------------- /flow-no-segues.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | iPhone 8 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | .splash 36 | 37 | 38 | .dashboard 39 | 40 | 41 | .compose 42 | 43 | 44 | .news 45 | 46 | 47 | .library 48 | 49 | 50 | .register 51 | 52 | 53 | .login 54 | 55 | 56 | .forgotPass 57 | 58 | 59 | .gatekeeper 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /flow-with-segues.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | iPhone 8 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | .splash 36 | 37 | 38 | .dashboard 39 | 40 | 41 | .compose 42 | 43 | 44 | .news 45 | 46 | 47 | .library 48 | 49 | 50 | .register 51 | 52 | 53 | .login 54 | 55 | 56 | .forgotPass 57 | 58 | 59 | .gatekeeper 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | Auto 81 | 82 | 83 | Dismissable 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /graphics.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valentinradu/Helm/9e83233160174813b620e734d6e43018e123ed25/graphics.sketch --------------------------------------------------------------------------------