Helm - A graph-based SwiftUI router

Overview

SwiftUI Swift Xcode MIT

Helm is a declarative, graph-based routing library for SwiftUI. It fully describes all the navigation flows and can handle complex overlapping UI, modals, deeplinking, and much more.

Index

Features

  • lightweight, ~1K lines of code
  • declarative
  • deeplinking-ready, it takes a single call to navigate anywhere
  • snapshot testing ready, iterate through all screens, capture and compare them
  • fully documented interface
  • expressive errors
  • tested, 90%+ coverage
  • zero 3rd party dependencies

Concepts

The navigation graph

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). Segues are directed edges used to specify rules between two fragments, such as the presentation style or the auto flag (more about these below).

The presented path

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.

Transitions

Transitions encapsulate the navigation command from a fragment to another. In Helm there are 3 types of transitions:

  • presenting a new fragment
  • dismissing an already presented fragment
  • fully replacing the presented path

Helm

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.

Segues

Segues are directed edges between fragments with navigation rules:

  • 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.
  • 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).
  • 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.
  • tag: sometimes is convenient to present or dismiss a segue by its tag.

Usage

We first define all the fragments in the app.

enum Section: Fragment {
    // the first screen right after the app starts
    case splash

    // the screen that contains the login, register or forgot password fragments
    case gatekeeper
    // the three fragments of the gatekeeper screen
    case login
    case register
    case forgotPass
    
    // and so on ...
}

We now have:

Next, the navigation graph. Normally we'd have to write down each segue.

let segues: Set<Segue<Section>> = [
    Segue(from: .splash, to: .gatekeeper),
    Segue(from: .splash, to: .dashboard),
    Segue(from: .gatekeeper, to: .login, auto: true)
    Segue(from: .gatekeeper, to: .register)
    //...
]

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.

let edges = Set<DirectedEdge<Section>>()
    .union(.splash => [.gatekeeper, .dashboard])
    .union([.gatekeeper => .login])
    .union(.login => .register => .forgotPass => .login)
    .union(.login => .forgotPass => .register => .login)
    .union([.login, .register] => .dashboard)
    .union(.dashboard => [.news, .compose])
    .union(.library => .news => .library)

let segues = Set(edges.map { (edge: DirectedEdge<Section>) -> Segue<Section> in
    switch edge {
    case .gatekeeper => .login:
        return Segue(edge, style: .hold, auto: true)
    case .dashboard => .news:
        return Segue(edge, style: .hold, auto: true)
    case .dashboard => .compose:
        return Segue(edge, style: .hold, dismissable: true)
    case .dashboard => .library:
        return Segue(edge, style: .hold)
    default:
        // the default is style: .pass, auto: false, dismissable: false
        return Segue(edge)
    }
})

Now we have:

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.

try Helm(nav: segues)
// or
try Helm(nav: segues,
         path: [
             .splash => .gatekeeper,
             .gatekeeper => .register
         ])

Then, we inject Helm into the top-most view:

struct RootView: View {
    @StateObject private var _helm: Helm = ...
    
    var body: some View {
        ZStack {
            //...
        }
        .environmentObject(_helm)
    }
}

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.

struct DashboardView: View {
    @EnvironmentObject private var _helm: Helm<KeyScreen>

    var body: some View {
        VStack {
            HStack {
                Spacer()
                LargeButton(action: { _helm.present(fragment: .compose) }) {
                    Image(systemName: "plus.square.on.square")
                }
            }
            TabView(selection: _helm.pickPresented([.library, .news, .settings])) {
                LibraryView()
                    .tabItem {
                        Label("Library", systemImage: "book.closed")
                    }
                    .tag(Optional.some(KeyScreen.library))
                NewsView()
                    .tabItem {
                        Label("News", systemImage: "newspaper")
                    }
                    .tag(Optional.some(KeyScreen.news))
                SettingsView()
                    .tabItem {
                        Label("Settings", systemImage: "gearshape.fill")
                    }
                    .tag(Optional.some(KeyScreen.settings))
            }
        }
        .sheet(isPresented: _helm.isPresented(.compose)) {
            ComposeView()
        }
    }
}

Error handling

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.

_helm.$errors
    .sink {
        assertionFailure($0.description)
    }
    .store(in: &cancellables)

Deeplinking

The presented path (OrderedSet<DirectedEdge<N>>) 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.

Snapshot Testing

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:

let transitions = _helm.transitions()
for transition in transitions {
    try helm.navigate(transition: transition)
    // mutate state if needed, take a snapshot, compare it
}

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).

Examples

The package contains an extra project called Playground. It's integrating Helm with SwiftUI, including using NavigationViews, sheet modals, TabView, etc.

License

MIT License

Comments
  • (54, 32) Cannot convert value of type 'DirectedEdge<AppFragment>' to expected element type 'PathEdge<AppFragment>'

    (54, 32) Cannot convert value of type 'DirectedEdge' to expected element type 'PathEdge'

    Sorry, not sure correct place to ask advice (if you like I can create a StackOverflow tag). Its probably user error, however I tried to follow the README to instantiate at a given path:

    let helm = try! Helm(nav: AppFragment.segues, path: [
        AppFragment.navigation => AppFragment.spaces
    ])
    

    with Fragments:

    enum AppFragment: Fragment {
        case navigation
        case profile
        case connections
        case spaces
        case notifications
        case spaceMessage
    
        static let edges = Set<DirectedEdge<AppFragment>>()
        .union(.navigation => [.profile, .connections, .spaces, .notifications])
        .union(.spaces => [.spaceMessage])
    
        static let segues = Set(edges.map { (edge: DirectedEdge<AppFragment>) -> Segue<AppFragment> in
            switch edge {
    
            case AppFragment.navigation => AppFragment.profile:
                return Segue(edge, style: .hold, dismissable: true, auto: true)
            case AppFragment.navigation => AppFragment.connections:
                return Segue(edge, style: .hold, dismissable: true, auto: true)
            case AppFragment.navigation => AppFragment.spaces:
                return Segue(edge, style: .hold, dismissable: true, auto: true)
            case AppFragment.navigation => AppFragment.notifications:
                return Segue(edge, style: .hold, dismissable: true, auto: true)
            case AppFragment.spaces => AppFragment.spaceMessage:
                return Segue(edge, style: .hold, dismissable: true)
            default:
                // the default is style: .pass, auto: false, dismissable: false
                return Segue(edge)
            }
        })
    }
    

    And I receive:

    (54, 32) Cannot convert value of type 'DirectedEdge<AppFragment>' to expected element type 'PathEdge<AppFragment>'

    I looked at how tests were doing it, but they're using the TestGraph() function, which I don't quite grok yet.

    opened by jasperblues 7
  • Separate sample app

    Separate sample app

    Excellent lib. Not sure about others, but it would be useful for me if there was a separate standalone sample (either in the repo or outside) to hasten getting started.

    Playground was useful.

    documentation enhancement good first issue 
    opened by jasperblues 7
  • Is Navigation View supported on iPad (columns mode) ?

    Is Navigation View supported on iPad (columns mode) ?

    The NavigationViewExample doesn't work on iPad. It shows the error "No seque from a presented fragment to b"

    (when you show one city, then try to shift to another)

    I'm unclear if this is a limitation on the library - or just that some different edge definitions are required)

    opened by ConfusedVorlon 6
  • Segue 'id' ergonomics: `isPresented` regardless of ID, and access ID inside fragment

    Segue 'id' ergonomics: `isPresented` regardless of ID, and access ID inside fragment

    I'd like to optionally provide a segue 'id' to present a screen as a sheet and be able to retrieve that id from within the screen, but I'm not sure Helm is setup to allow for this, unless I'm missing something.

    The two issues preventing this currently are:

    Both isPresented functions that produce a Binding (to be able to show a sheet) seem to be overly specific – one matches against a screen with no ID, the other matches a screen with a particular ID, but there isn't a way to match a particular screen with 'any ID'. It'd be useful to have some sort of wildcard isPresented for this.

    There isn't an easy way to access the ID for a presented fragment. I suppose helm.presentedFragments.last!.id works, but doesn't feel as nice as (hypothetically) @Environment(\.presentedID) var id

    Any thoughts on resolving these? Thanks!

    opened by sebj 5
  • Name Collisision

    Name Collisision

    Helm is also the name of a core project in the Kubernetes world. It is a package manager for Kubernetes, and the packages are referred to as "Helm charts". Normally I'd say, "Hey, don't worry about it at all", but it's money coming from AWS, Google, Apple, MSFT, Cisco, ATT, Intel, Oracle, ARM, and even Alibaba and Huawei. (The Cloud Native Computing Foundation) When you have that many big names behind a single project, that is foundational to a lot of what those companies are doing in the cloud space, you can bet their legal teams will get bored and find your name eventually.

    Just a heads up. I have had a few projects that flew under the radar until companies made me change them.

    "Mobile Tetris" <- EA Games USA "Mobile Finder" <- Apple

    Just a thing to consider.

    opened by lswank 4
  • [Question] Going backwards w/ Stack Navigation

    [Question] Going backwards w/ Stack Navigation

    With the Stack (list/detail) based navigation in the playground, it wasn't clear how to handle going back.

    I wired it up in a stand-alone app based on the Playground example, and put the following on the tail of the list view:

    .onAppear {
      print("\n\nPresenting these frags: \(helm.presentedFragments)")
    }
    

    . . and noticed that after navigating back, the state was out of sync, according to my Helm the detail view was still being presented. So question . . .

    Question: Is there a pattern you had in mind for this? Just put helmPresent() in the disappear block of the detail view?

    opened by jasperblues 3
  • Analytics

    Analytics

    Traditional analytics tools don't provide graph analytics. Helm would enable this.

    When enabled we could record paths taken by the user, then:

    • Identify common paths. If one exists already, recommend the shorter path to the user.
    • If one doesn't exist, give this feedback to the app developer <-- common navigation paths / usages to improve UX. We could measure how this affects retention.

    Also say there was an on-boarding process, with X screens to complete. We measure the total time, and time on each screen. Then optimize to lessen the time on each screen. We could measure the conversion rate from download -> sign up.

    -- One More: If the app has a free version, and there was a desire to convert customers to the super-duper version analyze what paths users most commonly take to get to the 'sign me up for super-duper' screen, and highlight those paths.

    ^-- So yeah, graph analytics would be a cool feature, especially for the UX folks.

    opened by jasperblues 1
  • Helm needs a way to retrive the id from the fragment

    Helm needs a way to retrive the id from the fragment

    Right now Helm assumes the fragment id can be provided in isPresented(fragment:,id:). This is sometimes cumbersome. Add a match(fragment) method or similar to allow consumers to get the id and match only the fragment.

    enhancement good first issue 
    opened by valentinradu 0
  • Improve missingSegueToFragment

    Improve missingSegueToFragment

    Could the missingSegueToFragment error be updated to include the 'from' fragment that doesn't have the connection instead of just the 'to'?

    Also examples of navigating between views and how to construct a path for the 'replace' functionality would be helpful

    opened by ianlater 0
  • Listening to Services or State Changes

    Listening to Services or State Changes

    Hello Valentin. Pretty cool project!

    I'm wondering how or where I do declare that a certain view is shown based on a condition.

    For example:

    1. After the splash screen I would check the userdefaults to determine whether the user is loggedIn or not and show either the LogIn or the Dashboard Screen.

    2. The user taps on finish sign up, and we show a loading screen, and after that's completed we show the next view.

    Thanks again for the great work!

    opened by fernandocardenasm 0
Releases(v0.0.12-beta)
Owner
Valentin Radu
Software engineer. I help startups grow @ techpilot.dev.
Valentin Radu
An experimental navigation router for SwiftUI

SwiftUIRouter ?? An ⚠️ experimental ⚠️ navigation router for SwiftUI Usage ?? Check out ExampleApp for more. Define your routes: import SwiftUIRouter

Orkhan Alikhanov 16 Aug 16, 2022
Crossroad is an URL router focused on handling Custom URL Scheme

Crossroad is an URL router focused on handling Custom URL Scheme. Using this, you can route multiple URL schemes and fetch arguments and parameters easily.

Kohki Miki 331 May 23, 2021
SwiftRouter - A URL Router for iOS, written in Swift

SwiftRouter A URL Router for iOS, written in Swift, inspired by HHRouter and JLRoutes. Installation SwiftRouter Version Swift Version Note Before 1.0.

Chester Liu 259 Apr 16, 2021
A demonstration to the approach of leaving view transition management to a router.

SwiftUI-RouterDemo This is a simplified demonstration to the approach of leaving view transition management to a router.

Elvis Shi 3 May 26, 2021
URLScheme router than supports auto creation of UIViewControllers for associated url parameters to allow creation of navigation stacks

IKRouter What does it do? Once you have made your UIViewControllers conform to Routable you can register them with the parameters that they represent

Ian Keen 94 Feb 28, 2022
Interface-oriented router for discovering modules, and injecting dependencies with protocol in Objective-C and Swift.

ZIKRouter An interface-oriented router for managing modules and injecting dependencies with protocol. The view router can perform all navigation types

Zuik 631 Dec 26, 2022
An extremely lean implementation on the classic iOS router pattern.

Beeline is a very small library that aims to provide a lean, automatic implementation of the classic iOS router pattern.

Tim Oliver 9 Jul 25, 2022
A bidirectional Vapor router with more type safety and less fuss.

vapor-routing A routing library for Vapor with a focus on type safety, composition, and URL generation. Motivation Getting started Documentation Licen

Point-Free 68 Jan 7, 2023
A bidirectional router with more type safety and less fuss.

swift-url-routing A bidirectional URL router with more type safety and less fuss. This library is built with Parsing. Motivation Getting started Docum

Point-Free 242 Jan 4, 2023
Easy and maintainable app navigation with path based routing for SwiftUI.

Easy and maintainable app navigation with path based routing for SwiftUI.

Freek Zijlmans 278 Jun 7, 2021
RxFlow is a navigation framework for iOS applications based on a Reactive Flow Coordinator pattern

About Navigation concerns RxFlow aims to Installation The key principles How to use RxFlow Tools and dependencies GitHub Actions Frameworks Platform L

RxSwift Community 1.5k May 26, 2021
A splendid route-matching, block-based way to handle your deep links.

DeepLink Kit Overview DeepLink Kit is a splendid route-matching, block-based way to handle your deep links. Rather than decide how to format your URLs

Button 3.4k Dec 30, 2022
URL routing library for iOS with a simple block-based API

JLRoutes What is it? JLRoutes is a URL routing library with a simple block-based API. It is designed to make it very easy to handle complex URL scheme

Joel Levin 5.6k Jan 6, 2023
DZURLRoute is an Objective-C implementation that supports standard-based URLs for local page jumps.

DZURLRoute Example To run the example project, clone the repo, and run pod install from the Example directory first. Requirements s.dependency 'DZVie

yishuiliunian 72 Aug 23, 2022
Eugene Kazaev 713 Dec 25, 2022
An open source library for building deep-linkable SwiftUI applications with composition, testing and ergonomics in mind

Composable Navigator An open source library for building deep-linkable SwiftUI applications with composition, testing and ergonomics in mind Vanilla S

Bahn-X 538 Dec 8, 2022
MVC for SwiftUI (yes, seriously)

ViewController's for SwiftUI. The core idea is that the ViewController is owning, or at least driving, the View(s). Not the other way around. Blog ent

ZeeZide 75 Nov 28, 2022
Native, declarative routing for SwiftUI applications.

SwiftfulRouting ?? Native, declarative routing for SwiftUI applications Setup time: 1 minute Sample project: https://github.com/SwiftfulThinking/Swift

Nick Sarno 13 Dec 24, 2022
Monarch Router is a Declarative URL- and state-based router written in Swift.

Monarch Router is a declarative routing handler that is capable of managing complex View Controllers hierarchy transitions automatically, decoupling View Controllers from each other via Coordinator and Presenters. It fits right in with Redux style state flow and reactive frameworks.

Eliah Snakin 31 May 19, 2021
An experimental navigation router for SwiftUI

SwiftUIRouter ?? An ⚠️ experimental ⚠️ navigation router for SwiftUI Usage ?? Check out ExampleApp for more. Define your routes: import SwiftUIRouter

Orkhan Alikhanov 16 Aug 16, 2022