Git Product home page Git Product logo

swift-composable-navigator's Introduction

Unmaintained

See #80

Composable Navigator

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

test status



Vanilla SwiftUI navigation

A typical, vanilla SwiftUI application manages its navigation state (i.e. is a sheet or a push active) either directly in its Views or in ObservableObjects.

Let's take a look at a simplified example in which we keep all navigation state locally in the view:

struct HomeView: View {
  @State var isSheetActive: Bool = false
  @State var isDetailShown: Bool = false

  var body: some View {
    VStack {
      NavigationLink(
        destination: DetailView(),
        isActive: $isDetailShown,
        label: {
          Text("Go to detail view")
        }
      )

      Button("Go to settings") {
        isSheetActive = true
      }
    }
    .sheet(
      isPresented: $isSheetActive,
      content: {
        SettingsView()
      }
    )
  }
}

Challenges

How do we test that when the user taps the navigation link, we move to the DetailView and not the SettingsView?

As isSheetActive and isDetailShown are kept locally in the View and their values are directly mutated by a binding, we cannot test any navigation logic unless we write UI tests or implement custom bindings that call functions in an ObservableObject mutating the navigation state.

What if I want to show a second sheet with different content?

We can either introduce an additional isOtherSheetActive variable or a hashable enum HomeSheet: Hashable and keep track of the active sheet in a activeSheet: HomeSheet? variable.

What happens if both isSheetActive and isDetailShown are true?

The sheet is shown on top of the current content, meaning that we can end up in a situation in which the settings sheet is presented on top of a detail view.

How do we programmatically navigate after a network request has finished?

To programmatically navigate, we need to keep our navigation state in an ObservableObject that performs asynchronous actions such as network requests. When the request succeeds, we set isDetailShown or isSheetActive to true. We also need to make sure that all other navigation related variables are set to false/nil or else we might end up with an unexpected navigation tree.

What happens if the NavigationLink is contained in a lazily loaded List view and the view we want to navigate to has not yet been initialized?

The answer to this one is simple: SwiftUI will not navigate. Imagine, we have a list of hundreds of entries that the user can scroll through. If we want to programmatically navigate to an entry detail view, the 'cell' containing the NavigationLink needs to be in memory or else the navigation will not be performed.

NavigationLinks do not navigate when I click them

In order to make NavigationLinks work in our view, we need to wrap our view in a NavigationView.

So, at which point in the view hierarchy do we wrap our content in a NavigationView? As wrapping content in a NavigationView twice will lead to two navigation bars, we probably want to avoid having to multiple nested NavigationViews.

Shallow Deeplinking

Vanilla SwiftUI only supports shallow deeplinking, meaning that we can navigate from the ExampleView to the DetailView by setting the initial value of isDetailShown to true. However, we cannot navigate further down into our application as SwiftUI seems to ignore initial values in pushed/presented views.

Why should I use ComposableNavigator?

ComposableNavigator lifts the burden of manually managing navigation state off your shoulders and allows to navigate through applications along navigation paths. ComposableNavigator takes care of embedding your views in NavigationViews, where needed, and always builds a valid view hierarchy. On top of that, ComposableNavigator unlocks advanced navigation patterns like wildcards and conditional navigation paths.

Core components

ComposableNavigator is built on three core components: the navigation tree, the current navigation path, and the navigator.

Navigation Path

The navigation path describes the order of visible screens in the application. It is a first-class representation of the <url-path> defined in RFC1738. A navigation path consists of identified screens.

Screen

A Screen is a first-class representation of the information needed to build a particular view. Screen objects identify the navigation path element and can contain arguments like IDs, initial values, and flags. detail?id=0 directly translates to DetailScreen(id: 0).

Screens define how they are presented. This decouples presentation logic from business logic, as showing a sheet and pushing a view are performed by invoking the same go(to:, on:) function. Changing a screen's (default) presentation style is a single line change. Currently, sheet and push presentation styles are supported.

Navigator

The navigator manages the application's current navigation path and allows mutations on it. The navigator acts as an interface to the underlying data source. The navigator object is accessible via the view environment.

Navigators allow programmatic navigation and can be injected where needed, even into ViewModels.

NavigationTree

The ComposableNavigator is based on the concept of PathBuilder composition in the form of a NavigationTree. A NavigationTree composes PathBuilders to describe all valid navigation paths in an application. That also means that all screens in our application are accessible via a pre-defined navigation path.

Let's look at an example NavigationTree:

struct AppNavigationTree: NavigationTree {
  let homeViewModel: HomeViewModel
  let detailViewModel: DetailViewModel
  let settingsViewModel: SettingsViewModel

  var builder: some PathBuilder {
    Screen(
      HomeScreen.self,
      content: {
        HomeView(viewModel: homeViewModel)
      },
      nesting: {
        DetailScreen.Builder(viewModel: detailViewModel)
        SettingsScreen.Builder(viewModel: settingsViewModel)
      }
    )
  }
}

Example Tree

Based on AppNavigationTree, the following navigation paths are valid:

/home
/home/detail?id=0
/home/settings

More information on the NavigationTree and how to compose PathBuilders can be found here.

Vanilla SwiftUI + ComposableNavigator

Let's go back to our vanilla SwiftUI home view and enhance it using the ComposableNavigator.

import ComposableNavigator

struct HomeView: View {
  @Environment(\.navigator) var navigator
  @Environment(\.currentScreenID) var currentScreenID

  var body: some View {
    VStack {
      Button(
        action: goToDetail,
        label: { Text("Show detail screen for 0") }
      )

      Button(
        action: goToSettings,
        label: { Text("Go to settings screen") }
      )
    }
  }

  func goToDetail() {
    navigator.go(
      to: DetailScreen(detailID: "0"),
      on: currentScreenID
    )
  }

  func goToSettings() {
    navigator.go(
      to: SettingsScreen(),
      on: HomeScreen()
    )
  }
}

We can now inject the Navigator and currentScreenID in our tests and cover calls to goToDetail / goToSettings on an ExampleView instance in unit tests.

Integrating ComposableNavigator

import ComposableNavigator
import SwiftUI

struct AppNavigationTree: NavigationTree {
  let homeViewModel: HomeViewModel
  let detailViewModel: DetailViewModel
  let settingsViewModel: SettingsViewModel

  var builder: some PathBuilder {
    Screen(
      HomeScreen.self,
      content: {
        HomeView(viewModel: homeViewModel)
      },
      nesting: {
        DetailScreen.Builder(viewModel: detailViewModel)
        SettingsScreen.Builder(viewModel: settingsViewModel)
      }
    )
  }
}

@main
struct ExampleApp: App {
  let dataSource = Navigator.Datasource(root: HomeScreen())

  var body: some Scene {
    WindowGroup {
      Root(
        dataSource: dataSource,
        pathBuilder: AppNavigationTree(...)
      )
    }
  }
}

Deeplinking

As ComposableNavigator builds the view hierarchy based on navigation paths, it is the ideal companion to implement deeplinking. Deeplinks come in different forms and shapes, however ComposableNavigator abstracts it into a first-class representation in the form of the Deeplink type. The ComposableDeeplinking library that is part of the ComposableNavigator contains a couple of helper types that allow easily replace the current navigation path with a new navigation path based on a Deeplink by defining a DeeplinkHandler and a composable DeeplinkParser.

More information on deeplinking and how to implement it in your own application can be found here.

Dependency injection

ComposableNavigator was inspired by The Composable Architecture (TCA) and its approach to Reducer composition, dependency injection and state management. As all view building closures flow together in one central place, the app navigation tree, ComposableNavigator gives you full control over dependency injection. Currently, the helper package ComposableNavigatorTCA is part of this repository and the main package therefore has a dependency on TCA. This will change in the future when ComposableNavigatorTCA gets extracted into its own repository.

Installation

ComposableNavigator supports Swift Package Manager and contains two products, ComposableNavigator and ComposableDeeplinking.

Swift Package

If you want to add ComposableNavigator to your Swift packages, add it as a dependency to your Package.swift.

dependencies: [
    .package(
      name: "ComposableNavigator",
      url: "https://github.com/Bahn-X/swift-composable-navigator.git",
      from: "0.1.0"
    )
],
targets: [
    .target(
        name: "MyAwesomePackage",
        dependencies: [
            .product(name: "ComposableNavigator", package: "ComposableNavigator"),
            .product(name: "ComposableDeeplinking", package: "ComposableNavigator")
        ]
    ),
]

Xcode

You can add ComposableNavigator to your project via Xcode. Open your project, click on File → Swift Packages → Add Package Dependency…, enter the repository url (https://github.com/Bahn-X/swift-composable-navigator.git) and add the package products to your app target.

Example application

The ComposableNavigator repository contains an example application showcasing a wide range of library features and path builder patterns that are also applicable in your application. The example app is based on ComposableNavigator + TCA but also shows how to navigate via the navigator contained in a view's environment as you could do it in a Vanilla SwiftUI application.

The Example application contains a UI test suite that is run on every pull request. In that way, we can make sure that, even if SwiftUI changes under the hood, ComposableNavigator behaves as expected.

Documentation

The latest ComposableNavigator documentation is available in the wiki.

Contribution

The contribution process for this repository is described in CONTRIBUTING. We welcome contribution and look forward to your ideas.

License

This library is released under the MIT license. See LICENSE for details.

swift-composable-navigator's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

swift-composable-navigator's Issues

Navigator fails to update view when a variable is passed via the screen

Bug description

Basically on startup the app will show AScreen with a variable value of nil to display a loading screen. The deeplinker will parse the link into the following path [AScreen(value: 20), BScreen()]. Since the deeplinker uses replace(path:) it will successfully replace AScreen but fails at presenting BScreen. Parsing the link so that the value of AScreen is nil will present BScreen fine. So it seems the issue is with the value changing.

Steps to reproduce

Example App

Using the code below seems to solve the issue

extension Screen {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.presentationStyle == rhs.presentationStyle && type(of: lhs) == type(of: rhs)
    }
}

public extension AnyScreen {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.screen == rhs.screen
    }
}

Expected behavior

App should navigate to BScreen even when the value in AScreen changes

Environment

  • Xcode 13
  • Swift 5.3
  • OS: iOS 15
  • Package v0.2.0

Additional context

Related: #74 (comment)

Replace Path / go(to path:) do not correctly navigate back

Replace path and go to path in the navigator should navigate back to the last matching path element and only then show the non-matching part of the routing path.

Currently, both functions do not set the hasAppeared flag to false. This behaviour can be tested in the example app by opening detail settings 1 and tapping on "Go to detail settings 0".

Expected behaviour:
Application navigates back to Home Screen, then pushes detail 0, presents detail settings 0.

Observed behaviour:
Application navigates back to Detail Screen, replacing detail 1 with detail 0, navigation no longer works as routing path is broken.

Fix:
Set hasAppeared of the last matching path element to false, if it is not the last element in the routing path.

Extract ComposableNavigatorTCA into its own repository

ComposableNavigator currently has a dependency on TCA which is only necessary for the ComposableNavigatorTCA target. We should extract ComposableNavigatorTCA into its own repository so that people can choose whether they want to include those helpers or not.

Get notified when a ScreenID is no longer valid?

Question

Is it possible to find out when the screen for a particular ScreenID is permanently dismissed? By "permanently" I mean that if the same screen is presented again, it would have a different ScreenID?

Problem description

I'm wrapping an analytics library that has strong ties to UIKit. In particular, it has the concept of a "page" object that is tied to the lifecycle of a UIViewController in memory. I'm trying to replicate this behavior with SwiftUI Views. I can't use onDisappear because that is triggered if a view is covered by a nav push, and in UIKit, views don't get deallocated when they're covered by navigation.

I tried attaching a @StateObject to my views, and having it notify on deinit, but @StateObject seems not to make guarantees about when or if it will deallocate things.

My current thinking is to keep a dictionary that maps ScreenIDs to my analytics page objects, and remove them from the array when a ScreenID becomes invalid. But I would need the composable navigator to tell me when that happens, and I'm not sure if that's possible. I'm thinking something like:

// naming subject to discussion
Navigator.Datasource(root: someRoot, screenIDDidBecomeInvalid: { screenID in
})

I'm not super familiar with the composable navigator's API surface, so maybe there's a better place to put it, but that's the gist of what I'm looking for.

Improve test coverage

Idea

Improve test coverage.

Problem description

The current test coverage is ~17%, which isn't great. Let's add some more unit tests to improve this.

Add Providers

Idea

Add providers for dependencies / ViewModels. Inspired by Flutter's providers, let's add a Provider view that initialises a dependency when needed and keeps its reference. Also inspired by TCA's WithViewStore, that moves all observable object observation out of the observing view and instead wraps the observation in WithViewStore, updating it's content whenever the store emits a change.

Problem description

Currently, ComposableNavigator is very TCA focused and assumes that people "hand-down" their view models into the NavigationTree. Flutter solves this by wrapping Widgets in ProviderWidgets that take care of initialising the Widget's dependencies and handing it down the WidgetTree through the context.

Considered solutions

  • Provider exposing the initialised dependency via the environment
    -> Would need all dependencies to be ObservableObjects, potentially crashing the app when an EnvironmentObject is not initialised.
  • Explicitly passing the dependency into a build content closure ✅
struct DetailScreen: Screen {
  let presentationStyle: ScreenPresentationStyle = .push
  
  struct Builder<ViewModel: DetailViewModel & ObservableObject>: NavigationTree {
     let viewModel: () -> ViewModel
     
     var builder: some PathBuilder {
        Screen(
          DetailScreen.self,
          content: { 
            Provider(
              observing: viewModel,
              content: { viewModel in 
                // DetailView.init(viewModel: DetailViewModel), no ObservableObject needed here.
                DetailView(viewModel: viewModel) 
              }
          }
        ) 
     }
  }
}

Add ResultBuilder for building PathBuilders

Idea

Look into ResultBuilders for PathBuilders.

Problem description

The current way of PathBuilder composition requires users to type PathBuilders.[insertNameOfPathBuilderHere] and seems a bit clunky. Maybe we can improve this with PathBuilders?

Limitation: ResultBuilder is only available in Swift 5.4 and Xcode 12.5 and up.

Navigation not causing view to appear

Question

What would cause a view not to appear after pushing a screen onto the nav stack?

Problem description

I added debug() to my navigator. On the left is the initial navigation event that happens when the app loads. On the right is after sending a navigation event like this:

environment.navigator.go(to: TabNavigationScreen(), on: screenID)

where screenID is the ID of the screen that has the button. It also happens to be the root screen, i.e. 00000000-0000-0000-0000-000000000000.

image

I don't have a concise code sample to post, and I'll be making a sample project as my next debugging step, but I wanted to post this in case there was something obvious I was missing. How can I get hasAppeared: false to turn into hasAppeared: true?

Can a screen have itself nested inside?

Question

[//]: # Is it possible to have a Screen that contains itself in its nesting?

(I'm not sure if this is more of a question or a bug)

Problem description

[//]: # I first observed this behavior when I had a screen (A) that could nest a screen (B) that could then nest the original screen (A) again. The compiler would crash with the error Illegal instruction: 4. I was able to reproduce the issue in the example and it happens if you simply try to nest screen A inside of screen A.

Is there a recommended approach to solve this issue?

struct DetailScreen: Screen {
  let train: Train
  let presentationStyle: ScreenPresentationStyle = .push
  
  struct Builder: NavigationTree {
    var builder: some PathBuilder {
      Screen(
        content: { (screen: DetailScreen) in
          DetailView(train: screen.train)
        },
        nesting: {
          CapacityScreen.Builder()
            DetailScreen.Builder()
        }
      )
    }
  }
}

I forked the example to modify it and show the issue here:
https://github.com/andrewjmeier/swift-composable-navigator/blob/main/Example/Vanilla%20SwiftUI%20Example/Vanilla%20SwiftUI%20Example/DetailView.swift#L16

Allow navigation via Screen objects

Currently all navigation actions require an ID to navigate. As Screen Objects are hashable and therefore can be used as unique identifiers (or built in such a way that they are unique), let's add navigation via Screen objects.

Methods to be added:
go<S: Screen, Parent: Screen>(to screen: S, on parent: Parent)
go<Parent: Screen>(to path: [AnyScreen], on parent: Parent)
dismiss<S: Screen>(screen: S)
dismissSuccessor<Parent: Screen>(of parent: Parent)

Can you go to screen without specifying the previous screen?

Idea

[//]: It feels useful to be able to go to a screen without specifying the previous screen.

Problem description

[//]: One example of this would be a sheet that you want to be able to show from anywhere in the app and if you deep link to that sheet you'd want to just pop it up from whatever previous screen was open.

Apple Music has this behavior with its now playing bar that can pop up a sheet of the current song from anywhere in the app.

Considered solutions

[//]: I've currently hacked around this like this:

guard let screen = dataSource.path.current.last else { return }
navigator.go(to: EpisodeScreen(episode: episode), on: screen.id)

Tabbed path builders

We currently do not support Tabbed Navigation out of the box. Let's add a Tabbed path builder that takes a list of identified path builders.

Something along the lines of:

public extension PathBuilders {
   struct IdentifiedTab<Identifier: Hashable> {
      let id: Identifier
      let builder: PathBuilder
   }

   static func tabbed<ID: Hashable>(_ tabs: IdentifiedTab<ID>) -> some PathBuilder {
      ...
   }
}

Add custom screen presentation styles

Idea

Currently, we only support two screen presentation styles: push and sheet. We could add a way to define custom screen presentation styles.

Problem description

Custom screen presentation styles are a bit tricky as they involve local state for animation. SwiftUI defines animations on a view basis and we could come up with something along the lines of

protocol ScreenTransition: Hashable {
  func animatedContent<Content: View>(content: Content, isVisible: Bool) -> some View
}

struct FullScreenCoverTransition: ScreenTransition {
   func animatedContent<Content: View>(content: Content, isVisible: Bool) -> some View {
    content
      .animationModifiers() // do whatever you want here 
  }
}

We would then need to plug this into NavigationNode and make sure that we properly perform the animation on show and dismiss.

Add contribution guidelines

We welcome contributions to the ComposableNavigator and therefore should define some contribution guidelines.

As part of this issue, let's set up Pull Request and Issue Templates as well.

Initializer called multiple times

Question

How to ensure that ViewModel would be initialized once?

Problem description

I am wondering what should I do to be sure that ViewModel object would be initialized once, because in my current implementation it doesn't work as I expect.

Here is my Screen:

struct TestScreen: Screen {
    var presentationStyle: ScreenPresentationStyle = .push
    
    struct Builder: NavigationTree {
        var builder: some PathBuilder {
            Screen(
                TestScreen.self,
                content: {
                    TestView(viewModel: .init()) // <- called multiple times.
                },
                nesting: {
                    TestSecondScreen.Builder()
                }
            )
        }
    }
}

So I changed implementation of Screen to:

struct TestScreen: Screen {
    var presentationStyle: ScreenPresentationStyle = .push
    
    struct Builder: NavigationTree {
        let viewModel: TestViewModel = .init()

        var builder: some PathBuilder {
            Screen(
                TestScreen.self,
                content: {
                    TestView(viewModel: viewModel) // <- called multiple times, but ViewModel is the same.
                },
                nesting: {
                    TestSecondScreen.Builder()
                }
            )
        }
    }
}

But when it comes to initialize ViewModel that expects data previous implementation won't work. Here is the example of what I have in that case:

struct TestSecondScreen: Screen {
    let title: String
    let id: String
    
    var presentationStyle: ScreenPresentationStyle = .push
    
    struct Builder: NavigationTree {
        var builder: some PathBuilder {
            Screen(
                content: { (screen: TestSecondScreen) in
                    TestSecondView(viewModel: .init(id: screen.id), title: screen.title)
                }
            )
        }
    }
}

Xcode project file is not committed

Bug description

The main Xcode project file is not committed into the repo. When cloning and opening the Example workspace there is a missing Xcode project file reference.

Steps to reproduce

Clone open the workspace in Example folder. Hit run.

Expected behavior

Example should build and run.

Environment

  • Xcode 12.4 / 12.5 beta 3
  • Swift Xcode version.

Add support for full screen covers

Idea

Add support for full screen covers.

Problem description

Since iOS 14, SwiftUI supports fullscreen covers. Let's add support to NavigationNode that allows screen to be shown as a fullscreen cover. As this is not supported in iOS13, we could try to wrap the content in a FullScreenCoverTransition described in #51. For iOS14, let's just stick to what we SwiftUI provides.

Setup swiftformat

Add a github action to automatically run swiftformat on every push.

Add UI tests in Example app

As ComposableNavigator heavily relies on SwiftUI to work as expected, we should add UI tests to the example app to check if all navigation actions work as expected.

Flows to cover:

--> Adding elements
Root -> Push
Root -> Sheet

Root -> Push -> Push
Root -> Push -> Sheet

Root -> Sheet -> Push
Root -> Sheet -> Sheet

--> Removing elements
Root -> Push
[Remove Push]
Root

Root -> Sheet
[Remove Sheet]
Root

Root -> Push -> Push
[Remove Push]
Root -> Push

Root -> Push -> Sheet
[Remove Sheet]
Root -> Push

Root -> Sheet -> Push
[Remove Push]
Root -> Sheet

Root -> Sheet -> Sheet
[Remove Sheet]
Root -> Sheet

--> Replacing paths (that's a lot of permutations)
Root -> Push
Root -> Sheet
Root -> Push -> Push
Root -> Push -> Sheet
Root -> Sheet -> Push
Root -> Sheet -> Sheet
[Replace path]
Root -> Push
Root -> Sheet
Root -> Push -> Push
Root -> Push -> Sheet
Root -> Sheet -> Push
Root -> Sheet -> Sheet

Add replace(content: Content, for id: ScreenID)

When wildcards are triggered, they currently do not replace the element in the routing path element they 'caught'. Let's add replace(content: Content, for id: ScreenID) to mitigate this and replace the content for the current screen ID whenever a Wildcard Wrapper View appears.

struct WildcardView<Content: View, Wildcard: Screen> {
   @Environment(\.navigator) var navigator
   @Environment(\.currentScreenID) var id
   let wildcard: Wildcard
   let content: Content
   
   var body: some View {
       content
           .uiKitOnAppear {
              // check if content == wildcard and only replace if not.
               navigator.replace(content: wildcard, for id: id)
           }
   } 
}

Danger fails on PRs from forks

Bug description

GITHUB_TOKEN does not have write permission, i.e. cannot comment on issues, in PRs triggered from forks.

Steps to reproduce

Open a pull request from a fork.
Danger fails.

Expected behavior

Danger succeeds and posts a comment.

Solution

Add a bot user with write permission on issues / PRs and expose a personal access token of said user to the PR workflows.
(See Danger docs)

goBack fails to dismiss multiple views correctly.

Bug description

[//]: # Using goBack to navigate back to a previous screen doesn't dismiss all of the views when each view is presented modally.

Steps to reproduce

[//]: # Open a series of at least 3 modal views and then try to navigate back to the original view. One modal view will remain.

struct RootView: View {
    
    var body: some View {
        let dataSource = Navigator.Datasource(root: MainScreen())
        let navigator = Navigator(dataSource: dataSource)
        
        return Root(dataSource: dataSource, navigator: navigator, pathBuilder: MainScreen.Builder())
    }
    
}

struct MainScreen: Screen {
    
    var presentationStyle: ScreenPresentationStyle = .push
    
    struct Builder: NavigationTree {
        var builder: some PathBuilder {
            Screen(
                content: { (_: MainScreen) in MainView() },
                nesting: { ModalScreen.Builder().eraseCircularNavigationPath() }
            )
        }
    }
}

struct MainView: View {
    @Environment(\.navigator) private var navigator
    @Environment(\.currentScreenID) private var currentScreenID
    
    var body: some View {
        VStack {
            Button {
                navigator.go(to: ModalScreen(viewCount: 1, onDismiss: {
                    print(currentScreenID)
                    navigator.goBack(to: currentScreenID)
                }), on: currentScreenID)
            } label: {
                Text("Show new view")
            }
        }
    }
}

struct ModalScreen: Screen {
    
    var presentationStyle: ScreenPresentationStyle = .sheet(allowsPush: true)
    var viewCount: Int
    var onDismiss: () -> Void
    
    struct Builder: NavigationTree {
        var builder: some PathBuilder {
            Screen(
                content: { (screen: ModalScreen) in ModalView(viewCount: screen.viewCount, onDismiss: screen.onDismiss) },
                nesting: { ModalScreen.Builder().eraseCircularNavigationPath() }
            )
        }
    }
}

extension ModalScreen: Equatable {
    
    static func == (lhs: ModalScreen, rhs: ModalScreen) -> Bool {
        return lhs.viewCount == rhs.viewCount
    }
    
}

extension ModalScreen: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(viewCount)
    }
}

struct ModalView: View {
    @Environment(\.navigator) private var navigator
    @Environment(\.currentScreenID) private var currentScreenID
    
    var viewCount: Int
    var onDismiss: () -> Void
    
    var body: some View {
        VStack {
            Text("View \(viewCount)")
            Button {
                navigator.go(to: ModalScreen(viewCount: viewCount + 1, onDismiss: onDismiss), on: currentScreenID)
            } label: {
                Text("Show new view")
            }
            Button {
                onDismiss()
            } label: {
                Text("dismiss all views")
            }
        }
    }
    
}

Expected behavior

[//]: I would expect the original screen to be fully visible and all of the modal views to be dismissed.

Screenshots

[//]: Simulator Screen Recording - iPod touch (7th generation) - 2021-05-14 at 10 13 21

Environment

  • Xcode 12.5
  • Swift 5.4
  • iOS 14

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.