├── demo.gif
├── Example
├── Example
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── ExampleApp.swift
│ ├── Example.entitlements
│ ├── Info.plist
│ ├── ChildView.swift
│ └── ContentView.swift
└── Example.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── project.pbxproj
├── Tests
├── LinuxMain.swift
└── StackNavigationViewTests
│ ├── XCTestManifests.swift
│ └── StackNavigationViewTests.swift
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── Sources
└── StackNavigationView
│ ├── ModalView.swift
│ ├── CurrentView.swift
│ ├── Environment.swift
│ ├── StackNavigationView.swift
│ └── NavigationLink.swift
├── LICENSE
├── Package.swift
└── README.md
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lbrndnr/StackNavigationView/HEAD/demo.gif
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Example/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import StackNavigationViewTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += StackNavigationViewTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Tests/StackNavigationViewTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(StackNavigationViewTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------
/Example/Example/ExampleApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExampleApp.swift
3 | // Example
4 | //
5 | // Created by Laurin Brandner on 05.01.21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct ExampleApp: App {
12 | var body: some Scene {
13 | WindowGroup {
14 | ContentView()
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Example/Example.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Sources/StackNavigationView/ModalView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModalView.swift
3 | // Nuage
4 | //
5 | // Created by Laurin Brandner on 11.01.21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ModalView: Equatable {
11 |
12 | var item: Binding
13 | var content: AnyView
14 |
15 | static func == (lhs: ModalView, rhs: ModalView) -> Bool { lhs.item.wrappedValue == rhs.item.wrappedValue }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/StackNavigationView/CurrentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrentView.swift
3 | //
4 | //
5 | // Created by Laurin Brandner on 02.01.21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CurrentView: View {
11 |
12 | @Environment(\.currentView) private var currentView: AnyView?
13 |
14 | private var defaultView: AnyView
15 |
16 | var body: some View { currentView ?? defaultView }
17 |
18 | init(defaultView: Content) {
19 | self.defaultView = AnyView(defaultView)
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/Tests/StackNavigationViewTests/StackNavigationViewTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import StackNavigationView
3 |
4 | final class StackNavigationViewTests: XCTestCase {
5 | func testExample() {
6 | // This is an example of a functional test case.
7 | // Use XCTAssert and related functions to verify your tests produce the correct
8 | // results.
9 | XCTAssertEqual(StackNavigationView().text, "Hello, World!")
10 | }
11 |
12 | static var allTests = [
13 | ("testExample", testExample),
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/Example/Example/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSMinimumSystemVersion
22 | $(MACOSX_DEPLOYMENT_TARGET)
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Sources/StackNavigationView/Environment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Environment.swift
3 | //
4 | //
5 | // Created by Laurin Brandner on 02.01.21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | typealias Push = (AnyView, Any?) -> ()
11 | struct PushKey: EnvironmentKey {
12 |
13 | static let defaultValue: Push = { _, _ in }
14 |
15 | }
16 |
17 | struct CurrentViewKey: EnvironmentKey {
18 |
19 | static let defaultValue: AnyView? = nil
20 |
21 | }
22 |
23 | struct ModalViewKey: EnvironmentKey {
24 |
25 | static let defaultValue: ModalView? = nil
26 |
27 | }
28 |
29 | extension EnvironmentValues {
30 |
31 | var push: Push {
32 | get { self[PushKey.self] }
33 | set { self[PushKey.self] = newValue }
34 | }
35 |
36 | var currentView: AnyView? {
37 | get { self[CurrentViewKey.self] }
38 | set { self[CurrentViewKey.self] = newValue }
39 | }
40 |
41 | var modalView: ModalView? {
42 | get { self[ModalViewKey.self] }
43 | set { self[ModalViewKey.self] = newValue }
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Laurin Brandner
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Example/Example/ChildView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChildView.swift
3 | // Example
4 | //
5 | // Created by Laurin Brandner on 05.01.21.
6 | //
7 |
8 | import SwiftUI
9 | import StackNavigationView
10 |
11 | struct ChildView: View {
12 |
13 | private var sidebar: String
14 | private var level: Int
15 |
16 | var body: some View {
17 | VStack {
18 | Text("This is level \(level) in \(sidebar)")
19 | .font(.system(size: 50))
20 | .bold()
21 | Text("Hit next to proceed to level \(level+1)")
22 | .font(.system(size: 20))
23 | Spacer()
24 | .frame(height: 40)
25 | StackNavigationLink("Next", destination: ChildView(sidebar: sidebar, level: level+1))
26 | }
27 | .padding(20)
28 | .navigationTitle("Detail \(level)")
29 | }
30 |
31 | init(sidebar: String, level: Int) {
32 | self.sidebar = sidebar
33 | self.level = level
34 | }
35 |
36 | }
37 |
38 | struct ChildView_Previews: PreviewProvider {
39 | static var previews: some View {
40 | ChildView(sidebar: "AAA", level: 3)
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "mac",
5 | "scale" : "1x",
6 | "size" : "16x16"
7 | },
8 | {
9 | "idiom" : "mac",
10 | "scale" : "2x",
11 | "size" : "16x16"
12 | },
13 | {
14 | "idiom" : "mac",
15 | "scale" : "1x",
16 | "size" : "32x32"
17 | },
18 | {
19 | "idiom" : "mac",
20 | "scale" : "2x",
21 | "size" : "32x32"
22 | },
23 | {
24 | "idiom" : "mac",
25 | "scale" : "1x",
26 | "size" : "128x128"
27 | },
28 | {
29 | "idiom" : "mac",
30 | "scale" : "2x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "scale" : "1x",
36 | "size" : "256x256"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "scale" : "2x",
41 | "size" : "256x256"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "scale" : "1x",
46 | "size" : "512x512"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "scale" : "2x",
51 | "size" : "512x512"
52 | }
53 | ],
54 | "info" : {
55 | "author" : "xcode",
56 | "version" : 1
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "StackNavigationView",
8 | platforms: [.macOS(.v11)],
9 | products: [
10 | // Products define the executables and libraries a package produces, and make them visible to other packages.
11 | .library(
12 | name: "StackNavigationView",
13 | targets: ["StackNavigationView"]),
14 | ],
15 | dependencies: [
16 | // Dependencies declare other packages that this package depends on.
17 | // .package(url: /* package url */, from: "1.0.0"),
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
21 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
22 | .target(
23 | name: "StackNavigationView",
24 | dependencies: []),
25 | .testTarget(
26 | name: "StackNavigationViewTests",
27 | dependencies: ["StackNavigationView"]),
28 | ]
29 | )
30 |
--------------------------------------------------------------------------------
/Example/Example/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Example
4 | //
5 | // Created by Laurin Brandner on 05.01.21.
6 | //
7 |
8 | import SwiftUI
9 | import StackNavigationView
10 |
11 | struct ContentView: View {
12 |
13 | @State private var selection: Int? = 0
14 |
15 | var body: some View {
16 |
17 | return StackNavigationView(selection: $selection) {
18 | List {
19 | SidebarNavigationLink("Apples", destination: rootView(title: "Apples"), tag: 0, selection: $selection)
20 | SidebarNavigationLink("Bananas", destination: rootView(title: "Bananas"), tag: 1, selection: $selection)
21 | SidebarNavigationLink("Clementines", destination: rootView(title: "Clementines"), tag: 2, selection: $selection)
22 | }
23 | Text("Empty Selection")
24 | }
25 | .frame(minWidth: 600, minHeight: 400)
26 | }
27 |
28 | @ViewBuilder private func rootView(title: String) -> some View {
29 | VStack {
30 | Text("This is the root view of \(title)")
31 | .font(.system(size: 50))
32 | .bold()
33 | Spacer()
34 | .frame(height: 40)
35 | StackNavigationLink("Next", destination: ChildView(sidebar: title, level: 1))
36 | }
37 | .padding(20)
38 | .navigationTitle(title)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # StackNavigationView
2 |
3 | [](https://twitter.com/lbrndnr)
4 | [](https://github.com/lbrndnr/StackNavigationView/blob/master/LICENSE)
5 |
6 | ⚠️ SwiftUI now supports [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack), making this repo redundant for new projects. I recommend to only use this if you have to support macOS 11 or 12. ⚠️
7 |
8 | ## About
9 | As of SwiftUI v2, `NavigationView` only supports a simple sidebar selection. This makes it impossible to push new views onto the view hierarchy, as one could do e.g. with `UINavigationController`. This project is a workaround that builds upon `NavigationView` to support complex view hierarchies.
10 |
11 | 
12 |
13 | ## Usage
14 | The interface of `StackNavigationView` is very similar to the one of `NavigationView`, just make sure not to use `NavigationLink` inside of `StackNavigationView` though, it will result in undefined behaviour. You'll find the full example [here](https://github.com/lbrndnr/StackNavigationView/tree/master/Example/Example).
15 |
16 | ```swift
17 | struct ContentView: View {
18 |
19 | @State private var selection: Int? = 0
20 |
21 | var body: some View {
22 |
23 | return StackNavigationView(selection: $selection) {
24 | List {
25 | SidebarNavigationLink("Apples", destination: rootView(title: "Apples"), tag: 0, selection: $selection)
26 | SidebarNavigationLink("Bananas", destination: rootView(title: "Bananas"), tag: 1, selection: $selection)
27 | SidebarNavigationLink("Clementines", destination: rootView(title: "Clementines"), tag: 2, selection: $selection)
28 | }
29 | Text("Empty Selection")
30 | }
31 | .frame(minWidth: 600, minHeight: 400)
32 | }
33 |
34 | @ViewBuilder private func rootView(title: String) -> some View {
35 | VStack {
36 | Text("This is the root view of \(title)")
37 | .font(.system(size: 50))
38 | .bold()
39 | Spacer()
40 | .frame(height: 40)
41 | StackNavigationLink("Next", destination: ChildView(sidebar: title, level: 1))
42 | }
43 | .padding(20)
44 | .navigationTitle(title)
45 | }
46 | }
47 | ```
48 |
49 | ## Requirements
50 | `StackNavigationView` is a SwiftUI component for macOS. macOS Big Sur is required.
51 |
52 | ## Author
53 | I'm Laurin Brandner, I'm on [Twitter](https://twitter.com/lbrndnr).
54 |
55 | ## License
56 | `StackNavigationView` is licensed under the [MIT License](http://opensource.org/licenses/mit-license.php).
57 |
--------------------------------------------------------------------------------
/Sources/StackNavigationView/StackNavigationView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StackNavigationLink.swift
3 | //
4 | //
5 | // Created by Laurin Brandner on 02.01.21.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 |
11 | public struct StackNavigationView: View {
12 |
13 | private var content: Content
14 |
15 | @State private var pushed: [(AnyView?, V?)]
16 | @State private var popped = [(AnyView?, V?)]()
17 |
18 | @Environment(\.modalView) private var modalView: ModalView?
19 |
20 | private var canGoBack: Bool { pushed.count > 1 }
21 | private var canGoForward: Bool { popped.count > 0 }
22 | private var selection: Binding?
23 |
24 | public var body: some View {
25 | NavigationView(content: { content })
26 | .environment(\.push, push)
27 | .environment(\.currentView, pushed.last?.0)
28 | .onChange(of: modalView) { [modalView] newModal in
29 | modalView?.item.wrappedValue = nil
30 | if let view = newModal?.content {
31 | push(view, tag: nil)
32 | }
33 | }
34 | .toolbar {
35 | ToolbarItem(placement: .navigation) {
36 | Button(action: goBack, label: {
37 | Image(systemName: "chevron.left")
38 | })
39 | .disabled(!canGoBack)
40 | .keyboardShortcut("[", modifiers: .command)
41 | }
42 | ToolbarItem(placement: .navigation) {
43 | Button(action: goForward, label: {
44 | Image(systemName: "chevron.right")
45 | })
46 | .disabled(!canGoForward)
47 | .keyboardShortcut("]", modifiers: .command)
48 | }
49 | }
50 | }
51 |
52 | public init(@ViewBuilder content: () -> Content) {
53 | self.content = content()
54 | self._pushed = State(initialValue: [])
55 | }
56 |
57 | public init(selection: Binding, @ViewBuilder content: () -> Content) {
58 | self.content = content()
59 | self.selection = selection
60 | self._pushed = State(initialValue: [(nil, selection.wrappedValue)])
61 | }
62 |
63 | public func stack(item: Binding, @ViewBuilder content: () -> Content) -> some View {
64 | let content = content().id(UUID())
65 | return transformEnvironment(\.modalView) { modalView in
66 | if item.wrappedValue != nil {
67 | modalView = ModalView(item: item, content: AnyView(content))
68 | }
69 | else {
70 | modalView = nil
71 | }
72 | }
73 | }
74 |
75 | private func push(_ content: AnyView, tag: Any?) {
76 | let view = AnyView(content.id(UUID()))
77 |
78 | guard let tag = tag as? V? else { preconditionFailure() }
79 | pushed.append((view, tag))
80 | popped.removeAll()
81 | if let tag = tag {
82 | selection?.wrappedValue = tag
83 | }
84 | }
85 |
86 | private func goBack() {
87 | guard let (content, tag) = pushed.popLast() else { preconditionFailure() }
88 | popped.append((content, tag))
89 | if let tag = pushed.last?.1 {
90 | selection?.wrappedValue = tag
91 | }
92 | if let modalView = modalView {
93 | modalView.item.wrappedValue = nil
94 | }
95 | }
96 |
97 | private func goForward() {
98 | guard let (content, tag) = popped.popLast() else { preconditionFailure() }
99 | pushed.append((content, tag))
100 | if let tag = tag {
101 | selection?.wrappedValue = tag
102 | }
103 | }
104 |
105 | }
106 |
--------------------------------------------------------------------------------
/Sources/StackNavigationView/NavigationLink.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationLink.swift
3 | //
4 | //
5 | // Created by Laurin Brandner on 02.01.21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public struct SidebarNavigationLink