├── .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 | [](https://developer.apple.com/xcode/swiftui)
4 | [](https://swift.org)
5 | [](https://developer.apple.com/xcode)
6 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/flow-with-segues.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/graphics.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/valentinradu/Helm/9e83233160174813b620e734d6e43018e123ed25/graphics.sketch
--------------------------------------------------------------------------------