├── Tests ├── LinuxMain.swift └── NavigationStackTests │ ├── XCTestManifests.swift │ └── NavigationStackTests.swift ├── CHANGELOG.md ├── NavigationStack.podspec ├── LICENSE ├── Package.swift ├── .gitignore ├── Sources └── NavigationStack │ ├── NavigationStackView.swift │ ├── Pop.swift │ ├── Push.swift │ └── NavigationStackCompat.swift └── README.md /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import NavigationStackTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += NavigationStackTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(NavigationStackTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationStackTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import NavigationStack 3 | 4 | final class NavigationStackTests: 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("Hello World", "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # swiftui-navigation-stack Changelog 2 | 3 | ## 1.0.6 4 | - Fixed a bug that caused a wrong transition animation on iOS 16. 5 | 6 | ## 1.0.5 7 | - Renamed `NavigationStack` to `NavigationStackCompat` to avoid conflicts with the iOS 16 `NavigationStack` by Apple. 8 | - You can now check whether the NavigationStackCompat already contains a specific view. 9 | 10 | ## 1.0.4 11 | - Code cleaning. 12 | - Improved documentation. 13 | - NavigationStack has now a depth property. 14 | - Added MacOS and WatchOS deployment target to Podspec. 15 | 16 | ## 1.0.3 17 | - Fixed Package.swift: the watchOS platform was missing. 18 | -------------------------------------------------------------------------------- /NavigationStack.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'NavigationStack' 3 | s.version = '1.0.6' 4 | s.summary = 'An alternative SwiftUI NavigationView.' 5 | 6 | s.description = <<-DESC 7 | An alternative SwiftUI NavigationView implementing classic stack-based navigation giving also some more control on animations and programmatic navigation. 8 | DESC 9 | 10 | s.homepage = 'https://github.com/matteopuc/swiftui-navigation-stack' 11 | 12 | s.license = { :type => 'MIT', :file => 'LICENSE' } 13 | s.author = { 'Matteo Puccinelli' => 'matteo.puccinelli@gmail.com' } 14 | s.source = { :git => 'https://github.com/matteopuc/swiftui-navigation-stack.git', :tag => s.version.to_s } 15 | 16 | 17 | s.ios.deployment_target = '13.0' 18 | s.osx.deployment_target = '10.15' 19 | s.watchos.deployment_target = '6.0' 20 | s.swift_version = '5.0' 21 | s.source_files = 'Sources/NavigationStack/**/*' 22 | 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matteo Puccinelli. 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 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: "NavigationStack", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .watchOS(.v6) 12 | ], 13 | products: [ 14 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 15 | .library( 16 | name: "NavigationStack", 17 | targets: ["NavigationStack"]), 18 | ], 19 | dependencies: [ 20 | // Dependencies declare other packages that this package depends on. 21 | // .package(url: /* package url */, from: "1.0.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 25 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 26 | .target( 27 | name: "NavigationStack", 28 | dependencies: []), 29 | .testTarget( 30 | name: "NavigationStackTests", 31 | dependencies: ["NavigationStack"]), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Sources/NavigationStack/NavigationStackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationStackView.swift 3 | // 4 | // 5 | // Created by Matteo Puccinelli on 14/04/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The transition type for the whole NavigationStackView. 11 | public enum NavigationTransition { 12 | /// Transitions won't be animated. 13 | case none 14 | 15 | /// Use the [default transition](x-source-tag://defaultTransition). 16 | case `default` 17 | 18 | /// Use a custom transition (the transition will be applied both to push and pop operations). 19 | case custom(AnyTransition) 20 | 21 | /// A right-to-left slide transition on push, a left-to-right slide transition on pop. 22 | /// - Tag: defaultTransition 23 | public static var defaultTransitions: (push: AnyTransition, pop: AnyTransition) { 24 | let pushTrans = AnyTransition.asymmetric(insertion: .move(edge: .trailing), 25 | removal: .move(edge: .leading)) 26 | let popTrans = AnyTransition.asymmetric(insertion: .move(edge: .leading), 27 | removal: .move(edge: .trailing)) 28 | return (pushTrans, popTrans) 29 | } 30 | } 31 | 32 | /// An alternative SwiftUI NavigationView implementing classic stack-based navigation giving also some more control on animations and programmatic navigation. 33 | /// 34 | public struct NavigationStackView: View where Root: View { 35 | @ObservedObject private var navigationStack: NavigationStackCompat 36 | private let rootView: Root 37 | private let transitions: (push: AnyTransition, pop: AnyTransition) 38 | 39 | /// Creates a NavigationStackView. 40 | /// - Parameters: 41 | /// - transitionType: The type of transition to apply between views in every push and pop operation. 42 | /// - easing: The easing function to apply to every push and pop operation. 43 | /// - rootView: The very first view in the NavigationStack. 44 | public init(transitionType: NavigationTransition = .default, 45 | easing: Animation = NavigationStackCompat.defaultEasing, 46 | @ViewBuilder rootView: () -> Root) { 47 | 48 | self.init(transitionType: transitionType, 49 | navigationStack: NavigationStackCompat(easing: easing), 50 | rootView: rootView) 51 | } 52 | 53 | /// Creates a NavigationStackView with the provided NavigationStackCompat. 54 | /// - Parameters: 55 | /// - transitionType: The type of transition to apply between views in every push and pop operation. 56 | /// - navigationStack: the shared NavigationStackCompat. 57 | /// - rootView: The very first view in the NavigationStack. 58 | public init(transitionType: NavigationTransition = .default, 59 | navigationStack: NavigationStackCompat, 60 | @ViewBuilder rootView: () -> Root) { 61 | 62 | self.rootView = rootView() 63 | self.navigationStack = navigationStack 64 | switch transitionType { 65 | case .none: 66 | self.transitions = (.identity, .identity) 67 | case .custom(let trans): 68 | self.transitions = (trans, trans) 69 | default: 70 | self.transitions = NavigationTransition.defaultTransitions 71 | } 72 | } 73 | 74 | public var body: some View { 75 | let showRoot = navigationStack.currentView == nil 76 | let navigationType = navigationStack.navigationType 77 | 78 | return ZStack { 79 | Group { 80 | if showRoot { 81 | rootView 82 | .transition(navigationType == .push ? transitions.push : transitions.pop) 83 | .environmentObject(navigationStack) 84 | } else { 85 | navigationStack.currentView!.wrappedElement 86 | .transition(navigationType == .push ? transitions.push : transitions.pop) 87 | .environmentObject(navigationStack) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Pop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pop.swift 3 | // 4 | // 5 | // Created by Matteo Puccinelli on 14/04/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Defines the type of a pop operation. 11 | public enum PopDestination { 12 | /// Pop back to the previous view. 13 | case previous 14 | 15 | /// Pop back to the root view (i.e. the first view added to the NavigationStackView during the initialization process). 16 | case root 17 | 18 | /// Pop back to a view identified by a specific ID. 19 | case view(withId: String) 20 | } 21 | 22 | /// A view used to navigate back to a previous view through its enclosing NavigationStackCompat. 23 | public struct PopView: View where Label: View, Tag: Hashable { 24 | @EnvironmentObject private var navigationStack: NavigationStackCompat 25 | private let label: Label 26 | private let destination: PopDestination 27 | private let tag: Tag? 28 | @Binding private var isActive: Bool 29 | @Binding private var selection: Tag? 30 | 31 | /// Creates a PopView that triggers the navigation on tap or when a tag matches a specific value. 32 | /// - Parameters: 33 | /// - destination: The destination type of the pop operation. 34 | /// - tag: A value representing this pop operation. 35 | /// - selection: A binding that triggers the navigation if and when its value matches the tag value. 36 | /// - label: The actual view to tap to trigger the navigation. 37 | public init(destination: PopDestination = .previous, 38 | tag: Tag, selection: Binding, 39 | @ViewBuilder label: () -> Label) { 40 | 41 | self.init(destination: destination, 42 | isActive: Binding.constant(false), 43 | tag: tag, 44 | selection: selection, 45 | label: label) 46 | } 47 | 48 | private init(destination: PopDestination, 49 | isActive: Binding, tag: Tag?, 50 | selection: Binding, 51 | @ViewBuilder label: () -> Label) { 52 | 53 | self.label = label() 54 | self.destination = destination 55 | self._isActive = isActive 56 | self._selection = selection 57 | self.tag = tag 58 | } 59 | 60 | public var body: some View { 61 | if let selection = selection, let tag = tag, selection == tag { 62 | DispatchQueue.main.async { 63 | self.selection = nil 64 | pop() 65 | } 66 | } 67 | if isActive { 68 | DispatchQueue.main.async { 69 | isActive = false 70 | pop() 71 | } 72 | } 73 | return label.onTapGesture { 74 | pop() 75 | } 76 | } 77 | 78 | private func pop() { 79 | navigationStack.pop(to: destination) 80 | } 81 | } 82 | 83 | public extension PopView where Tag == Never { 84 | 85 | /// Creates a PopView that triggers the navigation on tap. 86 | /// - Parameters: 87 | /// - destination: The destination type of the pop operation. 88 | /// - label: The actual view to tap to trigger the navigation. 89 | init(destination: PopDestination = .previous, 90 | @ViewBuilder label: () -> Label) { 91 | 92 | self.init(destination: destination, 93 | isActive: Binding.constant(false), 94 | tag: nil, 95 | selection: Binding.constant(nil), 96 | label: label) 97 | } 98 | 99 | /// Creates a PopView that triggers the navigation on tap or when a boolean value becomes true. 100 | /// - Parameters: 101 | /// - destination: The destination type of the pop operation. 102 | /// - isActive: A boolean binding that triggers the navigation if and when becomes true. 103 | /// - label: The actual view to tap to trigger the navigation. 104 | init(destination: PopDestination = .previous, 105 | isActive: Binding, 106 | @ViewBuilder label: () -> Label) { 107 | 108 | self.init(destination: destination, 109 | isActive: isActive, 110 | tag: nil, 111 | selection: Binding.constant(nil), 112 | label: label) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Push.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Push.swift 3 | // 4 | // 5 | // Created by Matteo Puccinelli on 14/04/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A view used to navigate to another view through its enclosing NavigationStackCompat. 11 | public struct PushView: View where Label: View, Destination: View, Tag: Hashable { 12 | @EnvironmentObject private var navigationStack: NavigationStackCompat 13 | private let label: Label? 14 | private let destinationId: String? 15 | private let destination: Destination 16 | private let tag: Tag? 17 | @Binding private var isActive: Bool 18 | @Binding private var selection: Tag? 19 | 20 | /// Creates a PushView that triggers the navigation on tap or when a tag matches a specific value. 21 | /// - Parameters: 22 | /// - destination: The view to navigate to. 23 | /// - destinationId: The ID of the destination view (used to easily come back to it if needed). 24 | /// - tag: A value representing this push operation. 25 | /// - selection: A binding that triggers the navigation if and when its value matches the tag value. 26 | /// - label: The actual view to tap to trigger the navigation. 27 | public init(destination: Destination, 28 | destinationId: String? = nil, 29 | tag: Tag, 30 | selection: Binding, 31 | @ViewBuilder label: () -> Label) { 32 | 33 | self.init(destination: destination, 34 | destinationId: destinationId, 35 | isActive: Binding.constant(false), 36 | tag: tag, 37 | selection: selection, 38 | label: label) 39 | } 40 | 41 | private init(destination: Destination, 42 | destinationId: String?, 43 | isActive: Binding, 44 | tag: Tag?, 45 | selection: Binding, 46 | @ViewBuilder label: () -> Label) { 47 | 48 | self.label = label() 49 | self.destinationId = destinationId 50 | self._isActive = isActive 51 | self.tag = tag 52 | self.destination = destination 53 | self._selection = selection 54 | } 55 | 56 | public var body: some View { 57 | if let selection = selection, let tag = tag, selection == tag { 58 | DispatchQueue.main.async { 59 | self.selection = nil 60 | push() 61 | } 62 | } 63 | if isActive { 64 | DispatchQueue.main.async { 65 | isActive = false 66 | push() 67 | } 68 | } 69 | return label.onTapGesture { 70 | push() 71 | } 72 | } 73 | 74 | private func push() { 75 | navigationStack.push(destination, withId: destinationId) 76 | } 77 | } 78 | 79 | public extension PushView where Tag == Never { 80 | 81 | /// Creates a PushView that triggers the navigation on tap. 82 | /// - Parameters: 83 | /// - destination: The view to navigate to. 84 | /// - destinationId: The ID of the destination view (used to easily come back to it if needed). 85 | /// - label: The actual view to tap to trigger the navigation. 86 | init(destination: Destination, 87 | destinationId: String? = nil, 88 | @ViewBuilder label: () -> Label) { 89 | 90 | self.init(destination: destination, 91 | destinationId: destinationId, 92 | isActive: Binding.constant(false), 93 | tag: nil, 94 | selection: Binding.constant(nil), 95 | label: label) 96 | } 97 | 98 | /// Creates a PushView that triggers the navigation on tap or when a boolean value becomes true. 99 | /// - Parameters: 100 | /// - destination: The view to navigate to. 101 | /// - destinationId: The ID of the destination view (used to easily come back to it if needed). 102 | /// - isActive: A boolean binding that triggers the navigation if and when becomes true. 103 | /// - label: The actual view to tap to trigger the navigation. 104 | init(destination: Destination, 105 | destinationId: String? = nil, 106 | isActive: Binding, 107 | @ViewBuilder label: () -> Label) { 108 | 109 | self.init(destination: destination, 110 | destinationId: destinationId, 111 | isActive: isActive, 112 | tag: nil, 113 | selection: Binding.constant(nil), 114 | label: label) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Sources/NavigationStack/NavigationStackCompat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationStackCompat.swift 3 | // 4 | // Created by Matteo Puccinelli on 28/11/2019. 5 | 6 | import SwiftUI 7 | 8 | enum NavigationType { 9 | case push 10 | case pop 11 | } 12 | 13 | 14 | /** The manager behind the `NavigationStackView`. It also enables programmatic navigation. 15 | 16 | A `NavigationStackCompat` is automatically injected as an `@EnvironmentObject` into a `NavigationStackView` hierarchy. 17 | 18 | Also, it can be created outside of a `NavigationStackView` hierarchy and injected manually into it during the `NavigationStackView` initialization process. 19 | */ 20 | public class NavigationStackCompat: ObservableObject { 21 | 22 | /// The default easing function for push and pop transitions. 23 | /// - Tag: defaultEasing 24 | public static let defaultEasing = Animation.easeOut(duration: 0.2) 25 | 26 | @Published var currentView: ViewElement? 27 | @Published private(set) var navigationType = NavigationType.push 28 | private let easing: Animation 29 | 30 | /// Creates a NavigationStackCompat. 31 | /// - Parameter easing: The easing function to apply to push and pop transitions. By default, the [default easing function](x-source-tag://defaultEasing) will be used. 32 | public init(easing: Animation = defaultEasing) { 33 | self.easing = easing 34 | } 35 | 36 | private var viewStack = ViewStack() { 37 | didSet { 38 | currentView = viewStack.peek() 39 | } 40 | } 41 | 42 | /// The current depth of the navigation stack. 43 | /// Root has depth = 0 44 | public var depth: Int { 45 | viewStack.depth 46 | } 47 | 48 | /// Returns a Boolean value indicating whether the stack contains a view with the specified ID. 49 | /// - Parameter id: The ID of the view to look for. 50 | /// - Returns: **true** if the stack contains a view with the specified ID; otherwise, **false**. 51 | public func containsView(withId id: String) -> Bool { 52 | viewStack.indexForView(withId: id) != nil 53 | } 54 | 55 | /// Navigates to a view. 56 | /// - Parameters: 57 | /// - element: The destination view. 58 | /// - identifier: The ID of the destination view (used to easily come back to it if needed). 59 | public func push(_ element: Element, withId identifier: String? = nil) { 60 | navigationType = .push 61 | withAnimation(easing) { 62 | viewStack.push(ViewElement(id: identifier == nil ? UUID().uuidString : identifier!, 63 | wrappedElement: AnyView(element))) 64 | } 65 | } 66 | 67 | /// Navigates back to a previous view. 68 | /// - Parameter to: The destination type of the transition operation. 69 | public func pop(to: PopDestination = .previous) { 70 | navigationType = .pop 71 | withAnimation(easing) { 72 | switch to { 73 | case .root: 74 | viewStack.popToRoot() 75 | case .view(let viewId): 76 | viewStack.popToView(withId: viewId) 77 | default: 78 | viewStack.popToPrevious() 79 | } 80 | } 81 | } 82 | } 83 | 84 | private struct ViewStack { 85 | private var views = [ViewElement]() 86 | 87 | func peek() -> ViewElement? { 88 | views.last 89 | } 90 | 91 | var depth: Int { 92 | views.count 93 | } 94 | 95 | mutating func push(_ element: ViewElement) { 96 | guard indexForView(withId: element.id) == nil else { 97 | print("Duplicated view identifier: \"\(element.id)\". You are trying to push a view with an identifier that already exists on the navigation stack.") 98 | return 99 | } 100 | views.append(element) 101 | } 102 | 103 | mutating func popToPrevious() { 104 | _ = views.popLast() 105 | } 106 | 107 | mutating func popToView(withId identifier: String) { 108 | guard let viewIndex = indexForView(withId: identifier) else { 109 | print("Identifier \"\(identifier)\" not found. You are trying to pop to a view that doesn't exist.") 110 | return 111 | } 112 | views.removeLast(views.count - (viewIndex + 1)) 113 | } 114 | 115 | mutating func popToRoot() { 116 | views.removeAll() 117 | } 118 | 119 | func indexForView(withId identifier: String) -> Int? { 120 | views.firstIndex { 121 | $0.id == identifier 122 | } 123 | } 124 | } 125 | 126 | struct ViewElement: Identifiable, Equatable { 127 | let id: String 128 | let wrappedElement: AnyView 129 | 130 | static func == (lhs: ViewElement, rhs: ViewElement) -> Bool { 131 | lhs.id == rhs.id 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swiftui-navigation-stack 2 | An alternative SwiftUI NavigationView implementing classic stack-based navigation giving also some more control on animations and programmatic navigation. 3 | 4 | # NavigationStack 5 | 6 | ## Installation 7 | 8 | ### Swift Package Manager 9 | 10 | The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler: open xCode, click on `File -> Swift Packages -> Add Package dependency...` and use the repository URL (https://github.com/matteopuc/swiftui-navigation-stack.git) to download the package. 11 | 12 | In xCode, when prompted for Version or branch, the suggestion is to use Branch: master. 13 | 14 | Then in your View simply include `import NavigationStack` and follow usage examples below. 15 | 16 | ### CocoaPods 17 | 18 | [CocoaPods](https://cocoapods.org) is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate NavigationStack into your Xcode project using CocoaPods, specify it in your `Podfile`: 19 | 20 | ```swift 21 | pod 'NavigationStack' 22 | ``` 23 | 24 | Then in your View simply include `import NavigationStack` and follow usage examples below. 25 | 26 | ## Usage 27 | 28 | In SwiftUI we have a couple of views to manage the navigation: `NavigationView` and `NavigationLink`. At the moment these views have some limitations: 29 | 30 | - we can't turn off the transition animations; 31 | - we can't customise the transition animations; 32 | - we can't navigate back either to root (i.e. the first app view), or to a specific view; 33 | - we can't push programmatically without using a view; 34 | 35 | `NavigationStackView` is a view that mimics all the behaviours belonging to the standard `NavigationView`, but it adds the features listed here above. You have to wrap your view hierarchy inside a `NavigationStackView`: 36 | 37 | ```swift 38 | import NavigationStack 39 | 40 | struct RootView: View { 41 | var body: some View { 42 | NavigationStackView { 43 | MyHome() 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ![Jan-07-2020 15-40-35](https://user-images.githubusercontent.com/5569047/71903303-12cae980-3164-11ea-939e-f2abcd869484.gif) 50 | 51 | You can even customise transitions and animations in some different ways. The `NavigationStackView` will apply them to the hierarchy: 52 | 53 | - you could decide to go for no transition at all by creating the navigation stack this way `NavigationStackView(transitionType: .none)`; 54 | - you could create the navigation stack with a custom transition: 55 | 56 | ```swift 57 | import NavigationStack 58 | 59 | struct RootView: View { 60 | var body: some View { 61 | NavigationStackView(transitionType: .custom(.scale)) { 62 | MyHome() 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | ![Jan-10-2020 15-31-40](https://user-images.githubusercontent.com/5569047/72160405-9718a900-33be-11ea-8b78-6bcbbf4283d7.gif) 69 | 70 | - `NavigationStackView` has a default easing for transitions. The easing can be customised during the initialisation 71 | ```swift 72 | struct RootView: View { 73 | var body: some View { 74 | NavigationStackView(transitionType: .custom(.scale), easing: .spring(response: 0.5, dampingFraction: 0.25, blendDuration: 0.5)) { 75 | MyHome() 76 | } 77 | } 78 | } 79 | ``` 80 | **Important:** The above is the recommended way to customise the easing function for your transitions. Please, note that you could even specify the easing this other way: 81 | 82 | ```swift 83 | NavigationStackView(transitionType: .custom(AnyTransition.scale.animation(.spring(response: 0.5, dampingFraction: 0.25, blendDuration: 0.5)))) 84 | ``` 85 | 86 | attaching the easing directly to the transition. **Don't do this**. SwiftUI has still some problems with implicit animations attached to transitions, so it may not work. For example, implicit animations attached to a .slide transition won't work. 87 | 88 | ## Push 89 | 90 | In order to navigate forward you have two options: 91 | 92 | - Using the `PushView`; 93 | - Programmatically push accessing the navigation stack directly; 94 | 95 | ### PushView 96 | 97 | The basic usage of `PushView` is: 98 | 99 | ```swift 100 | PushView(destination: ChildView()) { 101 | Text("PUSH") 102 | } 103 | ``` 104 | 105 | which creates a tappable view (in this case a simple `Text`) to navigate to a destination. There are other ways to trigger the navigation using the `PushView`: 106 | 107 | ```swift 108 | struct MyHome: View { 109 | @State private var isActive = false 110 | 111 | var body: some View { 112 | VStack { 113 | PushView(destination: ChildView(), isActive: $isActive) { 114 | Text("PUSH") 115 | } 116 | 117 | Button(action: { 118 | self.isActive.toggle() 119 | }, label: { 120 | Text("Trigger push") 121 | }) 122 | } 123 | } 124 | } 125 | ``` 126 | 127 | this way you have a tappable view as before, but you can even exploit the `isActive` bool to trigger the navigation (also in this case the navigation is triggered through the `PushView`). 128 | 129 | If you have several destinations and you want to avoid having a lot of `@State` booleans you can use this other method: 130 | 131 | ```swift 132 | enum ViewDestinations { 133 | case noDestination 134 | case child1 135 | case child2 136 | case child3 137 | } 138 | 139 | struct MyHome: View { 140 | @ObservedObject var viewModel: ViewModel 141 | @State private var isSelected: ViewDestinations? = .noDestination 142 | 143 | var body: some View { 144 | VStack { 145 | PushView(destination: ChildView1(), tag: ViewDestinations.child1, selection: $isSelected) { 146 | Text("PUSH TO CHILD 1") 147 | } 148 | 149 | PushView(destination: ChildView2(), tag: ViewDestinations.child2, selection: $isSelected) { 150 | Text("PUSH TO CHILD 2") 151 | } 152 | 153 | PushView(destination: ChildView3(), tag: ViewDestinations.child3, selection: $isSelected) { 154 | Text("PUSH TO CHILD 3") 155 | } 156 | 157 | Button(action: { 158 | self.isSelected = self.viewModel.getDestination() 159 | }, label: { 160 | Text("Trigger push") 161 | }) 162 | } 163 | } 164 | } 165 | ``` 166 | 167 | Now you have three tappable views and the chance to trigger the navigation through a `tag` (the navigation is always triggered by the `PushView`). 168 | 169 | ### Push programmatically: 170 | 171 | Inside the `NavigationStackView` you have access to the navigation stack as an `EnvironmentObject`. If you need to trigger the navigation programmatically without relying on a `PushView` (i.e. without having a tappable view) you can do like this: 172 | 173 | ```swift 174 | struct MyHome: View { 175 | @ObservedObject var viewModel: ViewModel 176 | @EnvironmentObject private var navigationStack: NavigationStackCompat 177 | 178 | var body: some View { 179 | Button(action: { 180 | self.viewModel.performBackgroundActivities(withCallback: { 181 | DispatchQueue.main.async { 182 | self.navigationStack.push(ChildView()) 183 | } 184 | }) 185 | }, label: { 186 | Text("START BG ACTIVITY") 187 | }) 188 | } 189 | } 190 | ``` 191 | 192 | ## Specifying an ID 193 | 194 | It's not mandatory, but if you want to come back to a specific view at some point later you need to specify an ID for that view. Both `PushView` and programmatic push allow you to do that: 195 | 196 | ```swift 197 | struct MyHome: View { 198 | private static let childID = "childID" 199 | @ObservedObject var viewModel: ViewModel 200 | @EnvironmentObject private var navigationStack: NavigationStackCompat 201 | 202 | var body: some View { 203 | VStack { 204 | PushView(destination: ChildView(), destinationId: Self.childID) { 205 | Text("PUSH") 206 | } 207 | Button(action: { 208 | self.viewModel.performBackgroundActivities(withCallback: { 209 | DispatchQueue.main.async { 210 | self.navigationStack.push(ChildView(), withId: Self.childID) 211 | } 212 | }) 213 | }, label: { 214 | Text("START BG ACTIVITY") 215 | }) 216 | } 217 | } 218 | } 219 | ``` 220 | 221 | ## Pop 222 | 223 | Pop operation works as the push operation. We have the same two options: 224 | 225 | - Using the `PopView`; 226 | - Programmatically pop accessing the navigation stack directly; 227 | 228 | ### PopView 229 | 230 | The basic usage of `PopView` is: 231 | 232 | ```swift 233 | struct ChildView: View { 234 | var body: some View { 235 | PopView { 236 | Text("POP") 237 | } 238 | } 239 | } 240 | ``` 241 | 242 | which pops to the previous view. You can even specify a destination for your pop operation: 243 | 244 | ```swift 245 | struct ChildView: View { 246 | var body: some View { 247 | VStack { 248 | PopView(destination: .root) { 249 | Text("POP TO ROOT") 250 | } 251 | PopView(destination: .view(withId: "aViewId")) { 252 | Text("POP TO THE SPECIFIED VIEW") 253 | } 254 | PopView { 255 | Text("POP") 256 | } 257 | } 258 | } 259 | } 260 | ``` 261 | 262 | `PopView` has the same features as the `PushView`. You can create a `PopView` that triggers with the `isActive` bool or with the `tag`. Also, you can trigger the navigation programmatically without relying on the `PopView` itself, but accessing the navigation stack directly: 263 | 264 | ```swift 265 | struct ChildView: View { 266 | @ObservedObject var viewModel: ViewModel 267 | @EnvironmentObject private var navigationStack: NavigationStackCompat 268 | 269 | var body: some View { 270 | Button(action: { 271 | self.viewModel.performBackgroundActivities(withCallback: { 272 | self.navigationStack.pop() 273 | }) 274 | }, label: { 275 | Text("START BG ACTIVITY") 276 | }) 277 | } 278 | } 279 | ``` 280 | 281 | ## NavigationStack injection 282 | 283 | By default you can programmatically push and pop only inside the `NavigationStackView` hierarchy (by accessing the `NavigationStackCompat` environment object). If you want to use the `NavigationStackCompat` outside the `NavigationStackView` you need to create your own `NavigationStackCompat` (wherever you want) **and pass it as a parameter to the `NavigationStackView`**. This is useful when you want to decouple your routing logic from views. 284 | 285 | **Important:** Every `NavigationStackCompat` must be associated to a `NavigationStackView`. A `NavigationStackCompat` cannot be shared between multiple `NavigationStackView`. 286 | 287 | For example: 288 | 289 | ```swift 290 | struct RootView: View { 291 | let navigationStack: NavigationStackCompat 292 | 293 | var body: some View { 294 | NavigationStackView(navigationStack: navigationStack) { 295 | HomeScreen(router: MyRouter(navStack: navigationStack)) 296 | } 297 | } 298 | } 299 | 300 | class MyRouter { 301 | private let navStack: NavigationStackCompat 302 | 303 | init(navStack: NavigationStackCompat) { 304 | self.navStack = navStack 305 | } 306 | 307 | func toLogin() { 308 | self.navStack.push(LoginScreen()) 309 | } 310 | 311 | func toSignUp() { 312 | self.navStack.push(SignUpScreen()) 313 | } 314 | } 315 | 316 | struct HomeScreen: View { 317 | let router: MyRouter 318 | 319 | var body: some View { 320 | VStack { 321 | Text("Home") 322 | Button("To Login") { 323 | router.toLogin() 324 | } 325 | Button("To SignUp") { 326 | router.toSignUp() 327 | } 328 | } 329 | } 330 | } 331 | ``` 332 | 333 | ## Important 334 | 335 | Please, note that `NavigationStackView` navigates between views and two views may be smaller than the entire screen. In that case the transition animation won't involve the whole screen, but just the two views. Let's make an example: 336 | 337 | ```swift 338 | struct Root: View { 339 | var body: some View { 340 | NavigationStackView { 341 | A() 342 | } 343 | } 344 | } 345 | 346 | struct A: View { 347 | var body: some View { 348 | VStack(spacing: 50) { 349 | Text("Hello World") 350 | PushView(destination: B()) { 351 | Text("PUSH") 352 | } 353 | } 354 | .background(Color.green) 355 | } 356 | } 357 | 358 | struct B: View { 359 | var body: some View { 360 | PopView { 361 | Text("POP") 362 | } 363 | .background(Color.yellow) 364 | } 365 | } 366 | ``` 367 | 368 | The result is: 369 | 370 | ![Jan-10-2020 15-47-43](https://user-images.githubusercontent.com/5569047/72161560-a0a31080-33c0-11ea-8194-d6cb126953f4.gif) 371 | 372 | The transition animation uses just the minimum amount of space necessary for the views to enter/exit the screen (i.e. in this case the maximum width between view1 and view2) and this is exactly how it is meant to be. 373 | 374 | On the other hand you also probably want to use the `NavgationStackView` to navigate screens. Since in SwiftUI a screen (the old UIKit `ViewController`) it's just a `View` I suggest you create an handy and simple custom view called `Screen` like this: 375 | 376 | ```swift 377 | extension Color { 378 | static let myAppBgColor = Color.white 379 | } 380 | 381 | struct Screen: View where Content: View { 382 | let content: () -> Content 383 | 384 | var body: some View { 385 | ZStack { 386 | Color.myAppBgColor.edgesIgnoringSafeArea(.all) 387 | content() 388 | } 389 | } 390 | } 391 | ``` 392 | 393 | Now we can rewrite the example above using the `Screen` view: 394 | 395 | ```swift 396 | struct Root: View { 397 | var body: some View { 398 | NavigationStackView { 399 | A() 400 | } 401 | } 402 | } 403 | 404 | struct A: View { 405 | var body: some View { 406 | Screen { 407 | VStack(spacing: 50) { 408 | Text("Hello World") 409 | PushView(destination: B()) { 410 | Text("PUSH") 411 | } 412 | } 413 | .background(Color.green) 414 | } 415 | } 416 | } 417 | 418 | struct B: View { 419 | var body: some View { 420 | Screen { 421 | PopView { 422 | Text("POP") 423 | } 424 | .background(Color.yellow) 425 | } 426 | } 427 | } 428 | ``` 429 | 430 | This time the transition animation involves the whole screen: 431 | 432 | ![Jan-10-2020 16-10-59](https://user-images.githubusercontent.com/5569047/72163299-deedff00-33c3-11ea-935f-ce4341afe201.gif) 433 | 434 | ## Issues 435 | 436 | - SwiftUI resets all the properties of a view marked with `@State` every time the view is removed from a view hierarchy. For the `NavigationStackView` this is a problem because when I come back to a previous view (with a pop operation) I want all my view controls to be as I left them before (for example I want my `TextField`s to contain the text I previously typed in). In order to workaround this problem you have to use `@ObservableObject` when you need to make some state persist between push/pop operations. For example: 437 | 438 | ```swift 439 | class ViewModel: ObservableObject { 440 | @Published var text = "" 441 | } 442 | 443 | struct MyView: View { 444 | @ObservedObject var viewModel: ViewModel 445 | 446 | var body: some View { 447 | VStack { 448 | TextField("Type something...", text: $viewModel.text) 449 | PushView(destination: MyView2()) { 450 | Text("PUSH") 451 | } 452 | } 453 | } 454 | } 455 | ``` 456 | 457 | ### Other 458 | 459 | SwiftUI is really new, there are some unexpected behaviours and several API not yet documented. Please, report any issue may arise and feel free to suggest any improvement or changing to this implementation of a navigation stack. 460 | --------------------------------------------------------------------------------