bahn-x / swift-composable-navigator Goto Github PK
View Code? Open in Web Editor NEWAn open source library for building deep-linkable SwiftUI applications with composition, testing and ergonomics in mind
License: MIT License
An open source library for building deep-linkable SwiftUI applications with composition, testing and ergonomics in mind
License: MIT License
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.
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.
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)
}
}
)
}
}
}
[//]: # 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)
[//]: # 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
A typical state change in SwiftUI is to set detail state to nil when a screen is dismissed. Let's add a way to perform actions when a path element is dismissed.
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.
Clone open the workspace in Example folder. Hit run.
Example should build and run.
Add support for full screen covers.
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.
What would cause a view not to appear after pushing a screen onto the nav stack?
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
.
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
?
It references ComposableNavigator.framework which does not exist. Putting up a PR shortly that references the local SPM package instead.
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.
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.
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
[//]: It feels useful to be able to go to a screen without specifying the previous screen.
[//]: 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.
[//]: 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)
How to ensure that ViewModel
would be initialized once?
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)
}
)
}
}
}
Add a github action to automatically run swiftformat on every push.
Look into ResultBuilders for PathBuilders.
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.
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.
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 {
...
}
}
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)
[//]: # Using goBack
to navigate back to a previous screen doesn't dismiss all of the views when each view is presented modally.
[//]: # 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")
}
}
}
}
[//]: I would expect the original screen to be fully visible and all of the modal views to be dismissed.
Currently, we only support two screen presentation styles: push and sheet. We could add a way to define custom screen presentation styles.
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.
Demonstrate how to hook up ComposableDeeplinking in the example app. Also, add more documentation on deeplinking and how to build deeplink parsers.
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
?
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 View
s. 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 ScreenID
s 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.
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.
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
}
}
App should navigate to BScreen
even when the value in AScreen
changes
Related: #74 (comment)
GITHUB_TOKEN does not have write permission, i.e. cannot comment on issues, in PRs triggered from forks.
Open a pull request from a fork.
Danger fails.
Danger succeeds and posts a comment.
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)
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)
}
}
}
Improve test coverage.
The current test coverage is ~17%, which isn't great. Let's add some more unit tests to improve this.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
๐ Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. ๐๐๐
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google โค๏ธ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.