├── .swift-version ├── docs ├── img │ ├── gh.png │ ├── carat.png │ ├── dash.png │ └── spinner.gif ├── docsets │ ├── NavigationStack.tgz │ └── NavigationStack.docset │ │ └── Contents │ │ ├── Resources │ │ ├── docSet.dsidx │ │ └── Documents │ │ │ ├── img │ │ │ ├── gh.png │ │ │ ├── carat.png │ │ │ ├── dash.png │ │ │ └── spinner.gif │ │ │ └── js │ │ │ ├── jazzy.js │ │ │ └── jazzy.search.js │ │ └── Info.plist ├── badge.svg └── js │ ├── jazzy.js │ └── jazzy.search.js ├── Pods ├── SwiftLint │ ├── swiftlint │ └── LICENSE ├── SwiftFormat │ ├── CommandLineTool │ │ └── swiftformat │ └── LICENSE.md ├── Target Support Files │ ├── Pods-NavigationStack │ │ ├── Pods-NavigationStack.modulemap │ │ ├── Pods-NavigationStack-dummy.m │ │ ├── Pods-NavigationStack-umbrella.h │ │ ├── Pods-NavigationStack.debug.xcconfig │ │ ├── Pods-NavigationStack.release.xcconfig │ │ ├── Pods-NavigationStack-Info.plist │ │ ├── Pods-NavigationStack-acknowledgements.markdown │ │ └── Pods-NavigationStack-acknowledgements.plist │ ├── Pods-NavigationStackExample │ │ ├── Pods-NavigationStackExample.modulemap │ │ ├── Pods-NavigationStackExample-dummy.m │ │ ├── Pods-NavigationStackExample-umbrella.h │ │ ├── Pods-NavigationStackExample.debug.xcconfig │ │ ├── Pods-NavigationStackExample.release.xcconfig │ │ ├── Pods-NavigationStackExample-Info.plist │ │ ├── Pods-NavigationStackExample-acknowledgements.markdown │ │ └── Pods-NavigationStackExample-acknowledgements.plist │ ├── SwiftLint │ │ ├── SwiftLint.debug.xcconfig │ │ └── SwiftLint.release.xcconfig │ └── SwiftFormat │ │ ├── SwiftFormat.debug.xcconfig │ │ └── SwiftFormat.release.xcconfig └── Manifest.lock ├── img ├── customTransitions.gif ├── swiftuiTransitions.gif └── animationTransitions.gif ├── Sources ├── NavigationStackExample │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── NavigationStackExample.entitlements │ │ └── Base.lproj │ │ │ └── LaunchScreen.storyboard │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── ExampleViews │ │ ├── ContentView4.swift │ │ ├── Helper │ │ │ └── DismissTopContentButton.swift │ │ ├── SubviewExamples.swift │ │ ├── ContentView3.swift │ │ ├── OnDidAppearExample.swift │ │ ├── ContentView2.swift │ │ └── TransitionExamples.swift │ ├── AppDelegate.swift │ ├── Experiments │ │ ├── Experiment4.swift │ │ ├── Experiment2.swift │ │ ├── Experiment5.swift │ │ ├── Experiment11.swift │ │ ├── Experiment1.swift │ │ ├── Experiment3.swift │ │ ├── Helper │ │ │ └── Commons.swift │ │ ├── Experiment9.swift │ │ ├── Experiment10.swift │ │ ├── Experiment8.swift │ │ ├── Experiment7.swift │ │ ├── Experiment6.swift │ │ └── Experiment0.swift │ └── SceneDelegate.swift └── NavigationStack │ ├── NavigationStack.h │ ├── Transitions │ ├── ClipShapeModifier.swift │ ├── Static.swift │ ├── RectangleShape.swift │ ├── Brightness.swift │ ├── Saturation.swift │ ├── HueRotation.swift │ ├── Contrast.swift │ ├── Blur.swift │ ├── CircleShape.swift │ ├── TiltAndFly.swift │ └── StripesShape.swift │ ├── Core │ ├── NavigationViewLifecycleActionWrapper.swift │ ├── Types.swift │ ├── NavigationAnimations.swift │ ├── NavigationAnimation.swift │ ├── NavigationStackModelConvenienceMethods.swift │ ├── NavigationStackNode.swift │ └── NavigationStackView.swift │ └── ViewLifecycle │ ├── OnDidAppear.swift │ └── OnAnimationCompleted.swift ├── Tests ├── LinuxMain.swift ├── NavigationStackTests │ ├── XCTestManifests.swift │ ├── NavigationStackViewTests │ │ └── NavigationStackViewInitTests.swift │ ├── NavigationModelTests │ │ ├── NavigationModelInitTests.swift │ │ ├── NavigationModelStub.swift │ │ ├── NavigationModelConvenienceMethodsTests.swift │ │ ├── NavigationModelStackViewCallTests.swift │ │ └── NavigationModelStateTests.swift │ ├── NavigationStackNodeTests │ │ ├── NavigationStackNodeInitTests.swift │ │ ├── NavigationStackNodeGetNodeTests.swift │ │ ├── NavigationStackNodeGetLeafNodeTests.swift │ │ └── NavigationStackNodePublishedTests.swift │ └── NavigationAnimationTests │ │ └── NavigationAnimationInitTests.swift ├── NavigationStackExampleUITests │ ├── Helper │ │ └── XCTestCaseExtensions.swift │ ├── Experiment10Tests.swift │ ├── SubviewExampleTests.swift │ ├── OnDidAppearTests.swift │ ├── TransitionExampleTests.swift │ ├── ExperimentTests.swift │ └── ContentViewTests.swift └── Readme.md ├── Gemfile ├── Scripts ├── generateDocs.sh ├── swiftFormatCodePaths.sh ├── updateDependencies.sh ├── extractSwiftVersion.sh ├── releaseVersion.sh ├── hooks │ └── pre-commit ├── updateVersion.sh └── copyGitHooks.sh ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── NavigationStack.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── Format Code.xcscheme │ ├── NavigationStack.xcscheme │ └── NavigationStackExample.xcscheme ├── .slather.yml ├── NavigationStack.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── WorkspaceSettings.xcsettings ├── .swiftformat ├── Podfile.lock ├── .swiftlint.yml ├── .gitignore ├── ReleaseSteps.md ├── Config ├── NavigationStackTests │ └── Info.plist ├── NavigationStackExampleUITests │ └── Info.plist ├── NavigationStack │ └── Info.plist └── NavigationStackExample │ └── Info.plist ├── .travis.yml ├── Changelog.md ├── LICENSE ├── Podfile ├── Package.swift ├── .jazzy.yaml ├── NavStack.podspec └── Gemfile.lock /.swift-version: -------------------------------------------------------------------------------- 1 | 5.4 2 | -------------------------------------------------------------------------------- /docs/img/gh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/img/gh.png -------------------------------------------------------------------------------- /docs/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/img/carat.png -------------------------------------------------------------------------------- /docs/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/img/dash.png -------------------------------------------------------------------------------- /docs/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/img/spinner.gif -------------------------------------------------------------------------------- /Pods/SwiftLint/swiftlint: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/Pods/SwiftLint/swiftlint -------------------------------------------------------------------------------- /img/customTransitions.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/img/customTransitions.gif -------------------------------------------------------------------------------- /img/swiftuiTransitions.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/img/swiftuiTransitions.gif -------------------------------------------------------------------------------- /img/animationTransitions.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/img/animationTransitions.gif -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/docsets/NavigationStack.tgz -------------------------------------------------------------------------------- /Pods/SwiftFormat/CommandLineTool/swiftformat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/Pods/SwiftFormat/CommandLineTool/swiftformat -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/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 NavigationStackTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += NavigationStackTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem 'slather' # needed to prepare codecov data 6 | gem 'jazzy' # needed to create documentation 7 | -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Resources/docSet.dsidx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/docsets/NavigationStack.docset/Contents/Resources/docSet.dsidx -------------------------------------------------------------------------------- /Scripts/generateDocs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Generate the lib's documentation. 3 | # Intended to be run manually from within the project folder. 4 | 5 | # Generate the documentation. 6 | bundle exec jazzy 7 | -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/gh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/gh.png -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/carat.png -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/dash.png -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indieSoftware/NavigationStack/HEAD/docs/docsets/NavigationStack.docset/Contents/Resources/Documents/img/spinner.gif -------------------------------------------------------------------------------- /NavigationStack.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack.modulemap: -------------------------------------------------------------------------------- 1 | framework module Pods_NavigationStack { 2 | umbrella header "Pods-NavigationStack-umbrella.h" 3 | 4 | export * 5 | module * { export * } 6 | } 7 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack-dummy.m: -------------------------------------------------------------------------------- 1 | #import 2 | @interface PodsDummy_Pods_NavigationStack : NSObject 3 | @end 4 | @implementation PodsDummy_Pods_NavigationStack 5 | @end 6 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | [ 6 | testCase(NavigationStackTests.allTests) 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Scripts/swiftFormatCodePaths.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Sets the folder paths to the sources which to format automatically via SwiftFormat. 3 | # Intended to be run from within Xcode before running SwiftFormat. 4 | swiftFormatCodePaths=("Sources" "Tests") 5 | -------------------------------------------------------------------------------- /Scripts/updateDependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Updates all local project dependencies, i.e. cocoapods. 3 | 4 | # Update gems. 5 | bundle update 6 | # Install missing pods. 7 | pod install 8 | # Update pods to newer version. 9 | pod update 10 | -------------------------------------------------------------------------------- /.slather.yml: -------------------------------------------------------------------------------- 1 | coverage_service: cobertura_xml 2 | workspace: NavigationStack.xcworkspace 3 | xcodeproj: NavigationStack.xcodeproj 4 | scheme: NavigationStack 5 | output_directory: reports 6 | ignore: 7 | - Sources/NavigationStackExample/* 8 | - Tests/* 9 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample.modulemap: -------------------------------------------------------------------------------- 1 | framework module Pods_NavigationStackExample { 2 | umbrella header "Pods-NavigationStackExample-umbrella.h" 3 | 4 | export * 5 | module * { export * } 6 | } 7 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample-dummy.m: -------------------------------------------------------------------------------- 1 | #import 2 | @interface PodsDummy_Pods_NavigationStackExample : NSObject 3 | @end 4 | @implementation PodsDummy_Pods_NavigationStackExample 5 | @end 6 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Resources/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 | -------------------------------------------------------------------------------- /Scripts/extractSwiftVersion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Writes the current swift version into a file. 3 | # Intended to be run by Xcdoe to extract the swift version used by SwiftFormat. 4 | 5 | swift -version | grep -Eo 'Swift version [0-9]+(\.[0-9])*' | grep -Eo '[0-9]+(\.[0-9])*' | tee .swift-version 6 | -------------------------------------------------------------------------------- /NavigationStack.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /NavigationStack.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /NavigationStack.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # https://github.com/nicklockwood/SwiftFormat 2 | 3 | # Rules 4 | # https://github.com/nicklockwood/SwiftFormat/blob/master/Rules.md 5 | --maxwidth 160 6 | --enable isEmpty 7 | --indent tab 8 | --tabwidth 4 9 | --commas inline 10 | --wraparguments before-first 11 | --wrapparameters before-first 12 | #--wrapcollections before-first 13 | -------------------------------------------------------------------------------- /NavigationStack.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Resources/NavigationStackExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Sources/NavigationStack/NavigationStack.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | //! Project version number for NavigationStack. 4 | FOUNDATION_EXPORT double NavigationStackVersionNumber; 5 | 6 | //! Project version string for NavigationStack. 7 | FOUNDATION_EXPORT const unsigned char NavigationStackVersionString[]; 8 | 9 | // In this header, you should import all the public headers of your framework using statements like #import 10 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack-umbrella.h: -------------------------------------------------------------------------------- 1 | #ifdef __OBJC__ 2 | #import 3 | #else 4 | #ifndef FOUNDATION_EXPORT 5 | #if defined(__cplusplus) 6 | #define FOUNDATION_EXPORT extern "C" 7 | #else 8 | #define FOUNDATION_EXPORT extern 9 | #endif 10 | #endif 11 | #endif 12 | 13 | 14 | FOUNDATION_EXPORT double Pods_NavigationStackVersionNumber; 15 | FOUNDATION_EXPORT const unsigned char Pods_NavigationStackVersionString[]; 16 | 17 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationStackViewTests/NavigationStackViewInitTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationStackViewInitTests: XCTestCase { 6 | // MARK: - Tests 7 | 8 | // Test that the provided ID is set. 9 | func testId() throws { 10 | let identifier = "Foo" 11 | let result = NavigationStackView(identifier) { EmptyView() } 12 | 13 | XCTAssertEqual(identifier, result.identifier) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - SwiftFormat/CLI (0.48.4) 3 | - SwiftLint (0.43.1) 4 | 5 | DEPENDENCIES: 6 | - SwiftFormat/CLI 7 | - SwiftLint 8 | 9 | SPEC REPOS: 10 | https://github.com/CocoaPods/Specs.git: 11 | - SwiftFormat 12 | - SwiftLint 13 | 14 | SPEC CHECKSUMS: 15 | SwiftFormat: c6f52d7e4a91b01d7941172135ef7761d7e36615 16 | SwiftLint: 99f82d07b837b942dd563c668de129a03fc3fb52 17 | 18 | PODFILE CHECKSUM: ddaccfd6ff5caed4ec952cb62f9f4e4ac404d15d 19 | 20 | COCOAPODS: 1.10.1 21 | -------------------------------------------------------------------------------- /Pods/Manifest.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - SwiftFormat/CLI (0.48.4) 3 | - SwiftLint (0.43.1) 4 | 5 | DEPENDENCIES: 6 | - SwiftFormat/CLI 7 | - SwiftLint 8 | 9 | SPEC REPOS: 10 | https://github.com/CocoaPods/Specs.git: 11 | - SwiftFormat 12 | - SwiftLint 13 | 14 | SPEC CHECKSUMS: 15 | SwiftFormat: c6f52d7e4a91b01d7941172135ef7761d7e36615 16 | SwiftLint: 99f82d07b837b942dd563c668de129a03fc3fb52 17 | 18 | PODFILE CHECKSUM: ddaccfd6ff5caed4ec952cb62f9f4e4ac404d15d 19 | 20 | COCOAPODS: 1.10.1 21 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample-umbrella.h: -------------------------------------------------------------------------------- 1 | #ifdef __OBJC__ 2 | #import 3 | #else 4 | #ifndef FOUNDATION_EXPORT 5 | #if defined(__cplusplus) 6 | #define FOUNDATION_EXPORT extern "C" 7 | #else 8 | #define FOUNDATION_EXPORT extern 9 | #endif 10 | #endif 11 | #endif 12 | 13 | 14 | FOUNDATION_EXPORT double Pods_NavigationStackExampleVersionNumber; 15 | FOUNDATION_EXPORT const unsigned char Pods_NavigationStackExampleVersionString[]; 16 | 17 | -------------------------------------------------------------------------------- /Scripts/releaseVersion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Releases a new version of the lib. 3 | # Intended to be run manually from within the project folder. 4 | # The current branch is the master branch and the code has been pushed. 5 | 6 | # Get the lib's marketing version. 7 | LIB_VERSION=$(agvtool what-marketing-version -terse1) 8 | echo "Releasing v$LIB_VERSION" 9 | # Create a new git tag with the lib's version. 10 | git tag $LIB_VERSION 11 | # Push tags. 12 | git push --tags 13 | # Update pod. 14 | pod trunk push NavStack.podspec 15 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/realm/SwiftLint 2 | # https://github.com/realm/SwiftLint/blob/master/Rules.md 3 | 4 | disabled_rules: # rule identifiers to exclude from running 5 | - nesting 6 | - todo 7 | 8 | line_length: 160 # instead of 120 9 | 10 | type_name: 11 | min_length: 12 | error: 3 # error instead of warning 13 | max_length: 14 | error: 40 # error instead of warning 15 | identifier_name: 16 | excluded: 17 | - id 18 | 19 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle) 20 | -------------------------------------------------------------------------------- /Tests/NavigationStackExampleUITests/Helper/XCTestCaseExtensions.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | extension XCTestCase { 4 | // Source: https://www.swiftbysundell.com/articles/reducing-flakiness-in-swift-tests/ 5 | func wait(forElement element: XCUIElement, timeout: TimeInterval) { 6 | let predicate = NSPredicate(format: "exists == 1") 7 | 8 | // This will make the test runner continously evalulate the 9 | // predicate, and wait until it matches. 10 | expectation(for: predicate, evaluatedWith: element) 11 | waitForExpectations(timeout: timeout) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Autosave files 2 | *~ 3 | 4 | # globs 5 | Makefile.in 6 | *.DS_Store 7 | *.sln.cache 8 | *.suo 9 | *.cache 10 | *.pidb 11 | *.userprefs 12 | *.usertasks 13 | config.log 14 | config.make 15 | config.status 16 | aclocal.m4 17 | install-sh 18 | autom4te.cache/ 19 | *.user 20 | *.tar.gz 21 | tarballs/ 22 | test-results/ 23 | Thumbs.db 24 | .vs/ 25 | 26 | # Mac bundle stuff 27 | *.dmg 28 | *.app 29 | 30 | # Xcode 31 | *.xccheckout 32 | *.xcscmblueprint 33 | *.moved-aside 34 | *.xcscmblueprint 35 | *.xcuserstate 36 | xcuserdata/ 37 | 38 | # CI 39 | reports 40 | build 41 | 42 | # Jazzy 43 | docs/undocumented.json 44 | -------------------------------------------------------------------------------- /ReleaseSteps.md: -------------------------------------------------------------------------------- 1 | # Steps to release a new version 2 | 3 | 1. Use the script `Scripts/updateDependencies.sh` to update the gems and pods to the latest version. 4 | 2. Apply any code changes (don't forget to update [Changelog.md](Changelog.md)). 5 | 3. Run the `Scripts/updateVersion.sh` script and pass the new marketing version (e.g. `1.2.3`) as argument to prepare a new release version. 6 | 4. Run the `Scripts/generateDocs.sh` script to update the documentation. 7 | 4. Merge branch with master and push to origin. 8 | 5. Run the `Scripts/releaseVersion.sh` script to create and push a new git tag for the lib's new version and to update cocoapods. 9 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample.debug.xcconfig: -------------------------------------------------------------------------------- 1 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 2 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 3 | LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' 4 | PODS_BUILD_DIR = ${BUILD_DIR} 5 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 6 | PODS_PODFILE_DIR_PATH = ${SRCROOT}/. 7 | PODS_ROOT = ${SRCROOT}/Pods 8 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 9 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 10 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample.release.xcconfig: -------------------------------------------------------------------------------- 1 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 2 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 3 | LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' 4 | PODS_BUILD_DIR = ${BUILD_DIR} 5 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 6 | PODS_PODFILE_DIR_PATH = ${SRCROOT}/. 7 | PODS_ROOT = ${SRCROOT}/Pods 8 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 9 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 10 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationModelTests/NavigationModelInitTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationModelInitTests: XCTestCase { 6 | // MARK: - Tests 7 | 8 | // Test the default values are set when initializing a new model. 9 | func testDefaultValues() throws { 10 | let result = NavigationModel() 11 | 12 | XCTAssertFalse(result.silenceErrors) 13 | } 14 | 15 | // Test the provided values are set when initializing a new model. 16 | func testCustomValues() throws { 17 | let result = NavigationModel(silenceErrors: true) 18 | 19 | XCTAssertTrue(result.silenceErrors) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack.debug.xcconfig: -------------------------------------------------------------------------------- 1 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 2 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 3 | LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks' 4 | PODS_BUILD_DIR = ${BUILD_DIR} 5 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 6 | PODS_PODFILE_DIR_PATH = ${SRCROOT}/. 7 | PODS_ROOT = ${SRCROOT}/Pods 8 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 9 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 10 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack.release.xcconfig: -------------------------------------------------------------------------------- 1 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 2 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 3 | LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks' 4 | PODS_BUILD_DIR = ${BUILD_DIR} 5 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 6 | PODS_PODFILE_DIR_PATH = ${SRCROOT}/. 7 | PODS_ROOT = ${SRCROOT}/Pods 8 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 9 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 10 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/ClipShapeModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /** 4 | The modifier wrapper for the corresponding SwiftUI function. 5 | 6 | See: [SwiftUI doc for clipshape](https://developer.apple.com/documentation/swiftui/emptyview/clipshape(_:style:)) 7 | */ 8 | public struct ClipShapeModifier: ViewModifier { 9 | /// The clipping shape to use for this view. The shape fills the view’s frame, while maintaining its aspect ratio. 10 | public let shape: S 11 | /// The fill style to use when rasterizing shape. 12 | public let style: FillStyle 13 | public func body(content: Content) -> some View { 14 | content.clipShape(shape, style: style) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Pods/Target Support Files/SwiftLint/SwiftLint.debug.xcconfig: -------------------------------------------------------------------------------- 1 | APPLICATION_EXTENSION_API_ONLY = YES 2 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 3 | CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SwiftLint 4 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 5 | PODS_BUILD_DIR = ${BUILD_DIR} 6 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 7 | PODS_ROOT = ${SRCROOT} 8 | PODS_TARGET_SRCROOT = ${PODS_ROOT}/SwiftLint 9 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 10 | PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} 11 | SKIP_INSTALL = YES 12 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 13 | -------------------------------------------------------------------------------- /Pods/Target Support Files/SwiftLint/SwiftLint.release.xcconfig: -------------------------------------------------------------------------------- 1 | APPLICATION_EXTENSION_API_ONLY = YES 2 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 3 | CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SwiftLint 4 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 5 | PODS_BUILD_DIR = ${BUILD_DIR} 6 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 7 | PODS_ROOT = ${SRCROOT} 8 | PODS_TARGET_SRCROOT = ${PODS_ROOT}/SwiftLint 9 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 10 | PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} 11 | SKIP_INSTALL = YES 12 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 13 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Core/NavigationViewLifecycleActionWrapper.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// The type of action performed on lifecycle events of views embedded in a NavigationView. 4 | public typealias NavigationViewLifecycleAction = () -> Void 5 | 6 | /// Wrapps a lifecycle action paired with an ID to make it equatable. 7 | struct NavigationViewLifecycleActionWrapper: Equatable, Identifiable { 8 | /// A unique ID for each wrapper to make the associated action comparable. 9 | let id = UUID() 10 | /// The wrapped action. 11 | let action: NavigationViewLifecycleAction 12 | 13 | static func == (lhs: NavigationViewLifecycleActionWrapper, rhs: NavigationViewLifecycleActionWrapper) -> Bool { 14 | lhs.id == rhs.id 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Pods/Target Support Files/SwiftFormat/SwiftFormat.debug.xcconfig: -------------------------------------------------------------------------------- 1 | APPLICATION_EXTENSION_API_ONLY = YES 2 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 3 | CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SwiftFormat 4 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 5 | PODS_BUILD_DIR = ${BUILD_DIR} 6 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 7 | PODS_ROOT = ${SRCROOT} 8 | PODS_TARGET_SRCROOT = ${PODS_ROOT}/SwiftFormat 9 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 10 | PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} 11 | SKIP_INSTALL = YES 12 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 13 | -------------------------------------------------------------------------------- /Pods/Target Support Files/SwiftFormat/SwiftFormat.release.xcconfig: -------------------------------------------------------------------------------- 1 | APPLICATION_EXTENSION_API_ONLY = YES 2 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO 3 | CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SwiftFormat 4 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 5 | PODS_BUILD_DIR = ${BUILD_DIR} 6 | PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 7 | PODS_ROOT = ${SRCROOT} 8 | PODS_TARGET_SRCROOT = ${PODS_ROOT}/SwiftFormat 9 | PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates 10 | PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier} 11 | SKIP_INSTALL = YES 12 | USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES 13 | -------------------------------------------------------------------------------- /Tests/NavigationStackExampleUITests/Experiment10Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | // Ensures Issue1 is fixed 4 | class Experiment10Tests: XCTestCase { 5 | private var app: XCUIApplication! 6 | 7 | override func setUpWithError() throws { 8 | continueAfterFailure = false 9 | app = XCUIApplication() 10 | } 11 | 12 | // MARK: - Tests 13 | 14 | // This starts Experiment10 and checks if the second screen becomes shown. 15 | func testStartExperiment10() throws { 16 | for _ in 0 ... 10 { 17 | app.launchArguments = ["Experiment10"] 18 | app.launch() 19 | XCTAssertTrue(app.staticTexts["Screen 1"].exists) 20 | XCTAssertTrue(app.staticTexts["Screen 2"].waitForExistence(timeout: 3)) 21 | app.terminate() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Scripts/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Runs the SwiftFormat on the source code provided by the code path script and returns an error code if formatting is needed. 3 | 4 | # Include swfit format code path variable. 5 | . Scripts/swiftFormatCodePaths.sh 6 | # Run SwiftFormat in lint mode which returns an exit code we save in a variable. 7 | Pods/SwiftFormat/CommandLineTool/swiftformat ${swiftFormatCodePaths[*]} --lint --exclude **/Generated 8 | result=$? 9 | if [[ $result -gt 0 ]] 10 | then 11 | # SwiftFormat needs to format anything so print an error message for that. 12 | echo "Please run SwiftFormat before committing!" 13 | fi 14 | # Switch back to the pre-commit base folder and propagate the SwiftFormat result. 15 | cd .. 16 | exit $result 17 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationStackNodeTests/NavigationStackNodeInitTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationStackNodeInitTests: XCTestCase { 6 | // MARK: - Tests 7 | 8 | // Test the default values are set when initializing a new node. 9 | func testInit() throws { 10 | let result = NavigationStackNode(identifier: "Foo", alternativeView: { AnyView(EmptyView()) }) 11 | 12 | XCTAssertEqual("Foo", result.identifer) 13 | XCTAssertFalse(result.isAlternativeViewShowing) 14 | XCTAssertFalse(result.isAlternativeViewShowingPrecede) 15 | XCTAssertNotNil(result.alternativeView()) 16 | XCTAssertNil(result.transitionAnimation) 17 | XCTAssertNil(result.nextNode) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.jazzy.navigationstack 7 | CFBundleName 8 | NavigationStack 9 | DocSetPlatformFamily 10 | navigationstack 11 | isDashDocset 12 | 13 | dashIndexFilePath 14 | index.html 15 | isJavaScriptEnabled 16 | 17 | DashDocSetFamily 18 | dashtoc 19 | 20 | 21 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationModelTests/NavigationModelStub.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationModelStub: NavigationModel { 6 | var showViewStub: (_ identifier: String, _ animation: NavigationAnimation?) -> Void = { _, _ in XCTFail() } 7 | 8 | override func showView(_ identifier: String, animation: NavigationAnimation? = nil, alternativeView _: @escaping () -> Content) 9 | where Content: View 10 | { 11 | showViewStub(identifier, animation) 12 | } 13 | 14 | var hideViewStub: (_ identifier: String, _ animation: NavigationAnimation?) -> Void = { _, _ in XCTFail() } 15 | 16 | override func hideView(_ identifier: String, animation: NavigationAnimation? = nil) { 17 | hideViewStub(identifier, animation) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Config/NavigationStackTests/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.1.1 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Config/NavigationStackExampleUITests/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.1.1 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/Static.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A transition which doesn't apply any visible changes, but is not the `identity` because using SwiftUI's `identity` leads to results 6 | not expected when used in combination with a second transition animation executed simultaniously by the other content view. 7 | This should be used for one view to remain unchanged while the counter-part view uses a visible transition animation. 8 | */ 9 | static var `static`: AnyTransition { 10 | .modifier( 11 | // The contrast values for active and identity have to differ otherwise SwiftUI ignores this transition completely. 12 | active: ContrastModifier(contrast: 0.99), // Only a small change which shouldn't be noticable. 13 | identity: ContrastModifier(contrast: 1.0) 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Config/NavigationStack/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.1.1 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: swift 2 | os: osx 3 | osx_image: xcode12 4 | xcode_workspace: NavigationStack.xcworkspace 5 | cache: 6 | bundler: true 7 | cocoapods: true 8 | jobs: 9 | include: 10 | - name: Test Lib on iPhone 8 (13.0) 11 | xcode_scheme: NavigationStack 12 | xcode_destination: platform=iOS Simulator,OS=13.0,name=iPhone 8 13 | - name: Test Lib on iPhone 11 (14.0) and upload code coverage 14 | xcode_scheme: NavigationStack 15 | xcode_destination: platform=iOS Simulator,OS=14.0,name=iPhone 11 16 | after_success: 17 | - bundle exec slather 18 | - bash <(curl -s https://codecov.io/bash) -f reports/cobertura.xml -X coveragepy -X gcov -X xcode 19 | - name: Test Example on iPhone 11 (14.0) 20 | xcode_scheme: NavigationStackExample 21 | xcode_destination: platform=iOS Simulator,OS=14.0,name=iPhone 11 22 | 23 | branches: 24 | only: 25 | - master 26 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### v1.1.1 4 | 5 | - Added CocoaPods and SPM support for macOS projects. 6 | - Added `Experiment11` to prove that the navigation still works with a timed action. 7 | 8 | ### v1.1.0 9 | 10 | - Introduced the `onDidAppear` view modifier to get informed when a view transition has finished. 11 | - Introduced the `onAnimationCompleted` view modifier to get informed when an animation in general has completed. 12 | - Fixed that the node was retained by the binding. 13 | - Added a UI test to confirm issue #1 was fixed by iOS 14.5. 14 | 15 | ### v1.0.2 16 | 17 | - Renamed the internal property name `name` to `identifier` and made it of a generic type. 18 | - Added some more tests. 19 | - Improved the documentation. 20 | 21 | ### v1.0.1 22 | 23 | - Minor readme corrections. 24 | - Fixed some pipeline scripts. 25 | - Folder and name refactoring for Carthage and SPM. 26 | 27 | ### v1.0.0 28 | 29 | Initial Version -------------------------------------------------------------------------------- /Sources/NavigationStack/Core/Types.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// The content view is put into a closure to defer its rendering until it's needed instead of evaluating it immediately on construction. 4 | /// Also when using `AnyView` directly instead of wrapping it then a created binding by the model would need a reference to the model itself. 5 | /// The content view's type is erased by wrapping it into an `AnyView`, however, for navigation it's not needed anyway. 6 | typealias AnyViewBuilder = () -> AnyView 7 | 8 | /// A concrete type of navigation model which uses strings as identifier. 9 | public typealias NavigationModel = NavigationStackModel 10 | 11 | extension Float { 12 | /// Progress towards the default view being fully visible, equals to 0. 13 | static let progressToDefaultView: Float = 0 14 | /// Progress towards the alternative view being fully visible, equals to 1 15 | static let progressToAlternativeView: Float = 1 16 | } 17 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/RectangleShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A custom transition using a scaling rectangle. 6 | */ 7 | static var rectangleShape: AnyTransition { 8 | AnyTransition.modifier( 9 | active: ClipShapeModifier(shape: RectangleShape(animatableData: 1), style: FillStyle()), 10 | identity: ClipShapeModifier(shape: RectangleShape(animatableData: 0), style: FillStyle()) 11 | ) 12 | } 13 | } 14 | 15 | /** 16 | A rectangle shape which size can be animated. 17 | 18 | Source inspired by [SwiftUI-Lab](https://swiftui-lab.com/advanced-transitions) 19 | */ 20 | public struct RectangleShape: Shape { 21 | public var animatableData: CGFloat 22 | 23 | public func path(in rect: CGRect) -> Path { 24 | var path = Path() 25 | 26 | path.addRect(rect.insetBy(dx: animatableData * rect.width / 2.0, dy: animatableData * rect.height / 2.0)) 27 | 28 | return path 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIdentifier 10 | ${PRODUCT_BUNDLE_IDENTIFIER} 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | ${PRODUCT_NAME} 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | ${CURRENT_PROJECT_VERSION} 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIdentifier 10 | ${PRODUCT_BUNDLE_IDENTIFIER} 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | ${PRODUCT_NAME} 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | ${CURRENT_PROJECT_VERSION} 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Scripts/updateVersion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Prepares the project for a new version of the lib. 3 | # Intended to be run manually from within the project folder. 4 | # Arguments: 5 | # string: The new marketing version, e.g. 1.2 6 | 7 | # Assure 1 argument is passed. 8 | if [ $# -ne 1 ] 9 | then 10 | echo "Requires marketing version as argument." 11 | exit 1 12 | fi 13 | 14 | # Increment the lib's version number. 15 | agvtool next-version 16 | # Sets the lib's marketing version to the value passed as argument. 17 | agvtool new-marketing-version $1 18 | # Replace the 3rd line in the podspec to update the version number. 19 | sed -i '' "3s/.*/ spec.version = \"$1\" # auto-generated/" NavStack.podspec 20 | # Replace the 4th line in the podspec to update the swift version read from the file. 21 | SWIFT_VERSION=$(<.swift-version) 22 | sed -i '' "4s/.*/ spec.swift_version = \"$SWIFT_VERSION\" # auto-generated/" NavStack.podspec 23 | # Lint podspec to be sure everything is still valid. 24 | pod lib lint NavStack.podspec 25 | -------------------------------------------------------------------------------- /Scripts/copyGitHooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copies the git hook files into the local .git/hooks folder backing up any existing files. 3 | # Intended to be run via Xcode during the build phase to prevent any unformatted code commits. 4 | 5 | # The version number of the hook scripts. Increment if something has been changed in the scripts. 6 | scriptVersion="1" 7 | # Some constants for the script. 8 | pathToGitHooks="../.git/hooks" # The relative path from the folder where this script is in to the git hook folder. 9 | pathToSourcHooks="hooks" # The relative path to the hooks folder from where to copy the files from. 10 | backupPostfix="_v$scriptVersion" # A postfix added to a backup file 11 | 12 | # Make sure the git hooks folder exists. 13 | mkdir -p $pathToGitHooks 14 | # Make a backup of a previous hook script if necessary and copy the new one to the hooks folder. 15 | scriptName="pre-commit" 16 | mv -n $pathToGitHooks/$scriptName $pathToGitHooks/$scriptName$backupPostfix 17 | cp -n $pathToSourcHooks/$scriptName $pathToGitHooks/ 18 | -------------------------------------------------------------------------------- /Sources/NavigationStack/ViewLifecycle/OnDidAppear.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension View { 4 | /** 5 | Calls the action when the given view has fully transitioned in. 6 | The action will be performed after the transition animation has finished, but will only be performed when the transition is animated. 7 | 8 | - parameter action: The action to perform after the animation has finished. 9 | - returns: The modified view with the applied modifier. 10 | */ 11 | func onDidAppear(_ action: @escaping NavigationViewLifecycleAction) -> some View { 12 | preference(key: OnDidAppearPreferenceKey.self, value: NavigationViewLifecycleActionWrapper(action: action)) 13 | } 14 | } 15 | 16 | /// A preference key for passing an action for `onDidAppear` upwards the view hierarchy. 17 | struct OnDidAppearPreferenceKey: PreferenceKey { 18 | static var defaultValue = NavigationViewLifecycleActionWrapper {} 19 | 20 | static func reduce(value: inout NavigationViewLifecycleActionWrapper, nextValue: () -> NavigationViewLifecycleActionWrapper) { 21 | value = nextValue() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/NavigationStackExampleUITests/SubviewExampleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class SubviewExampleTests: XCTestCase { 4 | private var app: XCUIApplication! 5 | 6 | override func setUpWithError() throws { 7 | continueAfterFailure = false 8 | app = XCUIApplication() 9 | app.launch() 10 | 11 | app.buttons["SubviewExamplesButton"].tap() 12 | sleep(1) 13 | } 14 | 15 | private func pressButton(_ name: String) { 16 | app.buttons[name].tap() 17 | sleep(1) 18 | } 19 | 20 | // MARK: - Tests 21 | 22 | // This shows the sub-view navigation how it works when used in the correct order. 23 | func testSubviewWorking() throws { 24 | pressButton("Subview1Button") 25 | pressButton("Subview2Button") 26 | pressButton("ResetButton") 27 | pressButton("BackButton") 28 | } 29 | 30 | // This shows the same sub-view navigation, but used in a different order and results in different end state. 31 | func testSubviewProblem() throws { 32 | pressButton("Subview2Button") 33 | pressButton("Subview1Button") 34 | pressButton("ResetButton") 35 | pressButton("BackButton") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/Brightness.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A transition which uses a brighness effect. 6 | 7 | See also: [SwiftUI doc for brightness](https://developer.apple.com/documentation/swiftui/emptyview/brightness(_:)) 8 | 9 | - parameter amount: A value between 0 (no effect) and 1 (full white brightening) that represents the intensity of the brightness effect. 10 | Defaults to 1. Should not be 0. 11 | - returns: The constructed transition. 12 | */ 13 | static func brightness(_ amount: Double = 1) -> AnyTransition { 14 | .modifier(active: BrightnessModifier(amount: amount), identity: BrightnessModifier(amount: 0)) 15 | } 16 | } 17 | 18 | /** 19 | The modifier wrapper for the corresponding SwiftUI function. 20 | 21 | See: [SwiftUI doc](https://developer.apple.com/documentation/swiftui/emptyview/brightness(_:)) 22 | */ 23 | public struct BrightnessModifier: ViewModifier { 24 | /// The intensity of the brightness effect. 25 | public let amount: Double 26 | public func body(content: Content) -> some View { 27 | content.brightness(amount) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sven Korset 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 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/Saturation.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A transition which uses a brighness effect. 6 | 7 | See also: [SwiftUI doc for saturation](https://developer.apple.com/documentation/swiftui/emptyview/saturation(_:)) 8 | 9 | - parameter amount: A value between 0 (no saturation = gray) and 1 (full saturation = full color) that represents the amount of saturation to apply. 10 | Defaults to 0. Should not be 1. 11 | - returns: The constructed transition. 12 | */ 13 | static func saturation(_ amount: Double = 0) -> AnyTransition { 14 | .modifier(active: SaturationModifier(amount: amount), identity: SaturationModifier(amount: 1)) 15 | } 16 | } 17 | 18 | /** 19 | The modifier wrapper for the corresponding SwiftUI function. 20 | 21 | See: [SwiftUI doc](https://developer.apple.com/documentation/swiftui/emptyview/saturation(_:)) 22 | */ 23 | public struct SaturationModifier: ViewModifier { 24 | /// The amount of saturation to apply. 25 | public let amount: Double 26 | public func body(content: Content) -> some View { 27 | content.saturation(amount) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Pods/SwiftFormat/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Nick Lockwood 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 | -------------------------------------------------------------------------------- /Pods/SwiftLint/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Realm Inc. 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 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/HueRotation.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A transition which uses a brighness effect. 6 | 7 | See also: [SwiftUI doc for huerotation](https://developer.apple.com/documentation/swiftui/emptyview/huerotation(_:)) 8 | 9 | - parameter degree: The hue rotation angle to apply to the colors in this view. 0° means no shift and 180° a total shift. 10 | Defaults to 180°. Should not be zero. 11 | - returns: The constructed transition. 12 | */ 13 | static func hueRotation(_ angle: Angle = .degrees(180)) -> AnyTransition { 14 | .modifier(active: HueRotationModifier(angle: angle), identity: HueRotationModifier(angle: .zero)) 15 | } 16 | } 17 | 18 | /** 19 | The modifier wrapper for the corresponding SwiftUI function. 20 | 21 | See: [SwiftUI doc](https://developer.apple.com/documentation/swiftui/emptyview/huerotation(_:)) 22 | */ 23 | public struct HueRotationModifier: ViewModifier { 24 | /// The hue rotation angle to apply to the colors in this view. 25 | public let angle: Angle 26 | public func body(content: Content) -> some View { 27 | content.hueRotation(angle) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/NavigationStackExampleUITests/OnDidAppearTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class OnDidAppearTests: XCTestCase { 4 | private var app: XCUIApplication! 5 | 6 | override func setUpWithError() throws { 7 | continueAfterFailure = false 8 | app = XCUIApplication() 9 | app.launch() 10 | 11 | app.buttons["OnDidAppearExamplesButton"].tap() 12 | XCTAssertTrue(app.staticTexts["OnDidAppearExample"].waitForExistence(timeout: 3)) 13 | } 14 | 15 | // MARK: - Tests 16 | 17 | // Pushes View2 and goes back to view1 and checks that the view2 state is presented accordingly. 18 | func testAllStatesWereShownDuringTransition() throws { 19 | XCTAssertTrue(app.staticTexts["View2 state: none"].waitForExistence(timeout: 1)) 20 | app.buttons["PushView2"].tap() 21 | XCTAssertTrue(app.staticTexts["View2 state: appearing"].waitForExistence(timeout: 1)) 22 | XCTAssertTrue(app.staticTexts["View2 state: present"].waitForExistence(timeout: 5)) 23 | app.buttons["PopView2"].tap() 24 | XCTAssertTrue(app.staticTexts["View2 state: disappearing"].waitForExistence(timeout: 1)) 25 | XCTAssertTrue(app.staticTexts["View2 state: none"].waitForExistence(timeout: 5)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/Contrast.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A transition which changes the contrast of a view. 6 | 7 | See also: [SwiftUI doc for contrast](https://developer.apple.com/documentation/swiftui/emptyview/contrast(_:)) 8 | 9 | - parameter contast: The intensity of color contrast to apply. Negative values invert colors in addition to applying contrast. 10 | Ranges from 1 (normal contast) to -1 (inverted contrast), defaults to 0 (no contrast). Should not be 1. 11 | - returns: The constructed transition. 12 | */ 13 | static func contrast(_ contrast: Double = 0) -> AnyTransition { 14 | .modifier(active: ContrastModifier(contrast: contrast), identity: ContrastModifier(contrast: 1)) 15 | } 16 | } 17 | 18 | /** 19 | The modifier wrapper for the corresponding SwiftUI function. 20 | 21 | See: [SwiftUI doc](https://developer.apple.com/documentation/swiftui/emptyview/contrast(_:)) 22 | */ 23 | public struct ContrastModifier: ViewModifier { 24 | /// The intensity of color contrast to apply. 25 | public let contrast: Double 26 | public func body(content: Content) -> some View { 27 | content.contrast(contrast) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/ExampleViews/ContentView4.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A typical SwiftUI view with a presentation binding to toggle its visibility. 4 | struct ContentView4: View { 5 | @Binding var isPresented: Bool 6 | 7 | var body: some View { 8 | HStack { 9 | VStack(alignment: .leading, spacing: 20) { 10 | Text("ContentView4") 11 | 12 | Button(action: { 13 | withAnimation(.easeOut) { 14 | isPresented.toggle() 15 | } 16 | }, label: { 17 | Text("Dismiss View 4 w/ animation") 18 | }) 19 | .accessibility(identifier: "DismissView4Animated") 20 | 21 | Button(action: { 22 | isPresented.toggle() 23 | }, label: { 24 | Text("Dismiss View 4 w/o animation") 25 | }) 26 | .accessibility(identifier: "DismissView4NoAnimation") 27 | 28 | Spacer() 29 | } 30 | Spacer() 31 | } 32 | .padding() 33 | .background(Color(UIColor.lightGray).opacity(1.0)) 34 | .onDidAppear { 35 | print("\(String(describing: Self.self)) did appear") 36 | } 37 | } 38 | } 39 | 40 | struct ContentView4_Previews: PreviewProvider { 41 | static var previews: some View { 42 | ContentView4(isPresented: .constant(true)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 100% 23 | 24 | 25 | 100% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | source 'https://github.com/CocoaPods/Specs.git' 2 | platform :ios, '13.0' 3 | use_frameworks! 4 | 5 | # Pods used for development only. 6 | def development_pods 7 | # A linter to gather code metrics and enforce Swift style and conventions. 8 | # https://github.com/realm/SwiftLint 9 | # License: MIT 10 | pod 'SwiftLint' 11 | 12 | # Formats the Swift code to be consistent with the coding style guidelines. 13 | # https://github.com/nicklockwood/SwiftFormat 14 | # License: MIT 15 | pod 'SwiftFormat/CLI' 16 | end 17 | 18 | 19 | # Targets 20 | 21 | target 'NavigationStack' do 22 | development_pods 23 | end 24 | 25 | target 'NavigationStackExample' do 26 | development_pods 27 | end 28 | 29 | 30 | # Post install 31 | 32 | post_install do |installer| 33 | installer.pods_project.targets.each do |target| 34 | target.build_configurations.each do |config| 35 | # Ignore any warnings from pods. 36 | config.build_settings['GCC_WARN_INHIBIT_ALL_WARNINGS'] = "YES" 37 | config.build_settings['SWIFT_SUPPRESS_WARNINGS'] = "YES" 38 | config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = "YES" 39 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = "13.0" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /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: "NavigationStack", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "NavigationStack", 16 | targets: ["NavigationStack"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "NavigationStack", 27 | dependencies: []), 28 | .testTarget( 29 | name: "NavigationStackTests", 30 | dependencies: ["NavigationStack"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /Tests/Readme.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | ## NavigationStackExampleUITests 4 | 5 | Contains UI Tests which run on the NavigationStackExample project. 6 | 7 | Add usage examples of how to use the lib to the example project and verify via UI tests that they work as expected. 8 | 9 | To run the tests switch to the NavigationStackExample scheme and run the tests via "Product" → "Test". 10 | 11 | To run the example project switch to the NavigationStackExample scheme and run via "Product" → "Run". However, make sure that in the scheme's run "Arguments" the flag "ExperimentX" is off. To run a specific experiment rather than the examples turn on that flag and replace "X" with the experiment's number. 12 | 13 | When running a UI test for an experiment then pass the "ExperimentX" string (with X replaced by the experiment number) as launch argument, i.e.: 14 | 15 | app = XCUIApplication() 16 | app.launchArguments = ["Experiment10"] 17 | app.launch() 18 | 19 | ## NavigationStackTests 20 | 21 | Contains UnitTests which run on the NavigationStack library code. 22 | 23 | Add functionality to the library's code and verify via UnitTests that the logic works as expected. 24 | 25 | To run the tests switch to the NavigationStack scheme and run the tests via "Product" → "Test". 26 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @main 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 6 | // Override point for customization after application launch. 7 | true 8 | } 9 | 10 | // MARK: UISceneSession Lifecycle 11 | 12 | func application( 13 | _: UIApplication, 14 | configurationForConnecting connectingSceneSession: UISceneSession, 15 | options _: UIScene.ConnectionOptions 16 | ) -> UISceneConfiguration { 17 | // Called when a new scene session is being created. 18 | // Use this method to select a configuration to create the new scene with. 19 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 20 | } 21 | 22 | func application(_: UIApplication, didDiscardSceneSessions _: Set) { 23 | // Called when the user discards a scene session. 24 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 25 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | github_url: https://github.com/indieSoftware/NavigationStack 2 | author: Sven Korset 3 | theme: fullwidth 4 | title: NavigationStack 5 | module: NavigationStack 6 | clean: true 7 | output: ./docs 8 | readme: README.md 9 | skip_undocumented: false 10 | hide_documentation_coverage: false 11 | min_acl: public 12 | sdk: iphone 13 | objc: false 14 | swift_build_tool: xcodebuild 15 | xcodebuild_arguments: 16 | - build 17 | - -workspace 18 | - NavigationStack.xcworkspace 19 | - -scheme 20 | - NavigationStack 21 | custom_categories: 22 | - name: NavigationStack 23 | children: 24 | - NavigationAnimation 25 | - NavigationModel 26 | - NavigationStackModel 27 | - NavigationStackView 28 | - name: Transition Animations 29 | children: 30 | - AnyTransition 31 | - name: View Lifecycle 32 | children: 33 | - NavigationViewLifecycleAction 34 | - View 35 | - name: ViewModifiers 36 | children: 37 | - BlurModifier 38 | - BrightnessModifier 39 | - ClipShapeModifier 40 | - ContrastModifier 41 | - HueRotationModifier 42 | - SaturationModifier 43 | - name: ClipShapeModifier Shapes 44 | children: 45 | - CircleShape 46 | - RectangleShape 47 | - StripesShape 48 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/Blur.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A transition which uses a blurring effect. 6 | 7 | See also: [SwiftUI doc for blur](https://developer.apple.com/documentation/swiftui/emptyview/blur(radius:opaque:)) 8 | 9 | - parameter radius: The radial size of the blur. A blur is more diffuse when its radius is large. 10 | Noticable values are greater than 1. Should not be 0. 11 | - returns: The constructed transition. 12 | */ 13 | static func blur(radius: Double) -> AnyTransition { 14 | .modifier(active: BlurModifier(radius: radius, opaque: false), identity: BlurModifier(radius: 0, opaque: false)) 15 | } 16 | } 17 | 18 | /** 19 | The modifier wrapper for the corresponding SwiftUI function. 20 | 21 | See: [SwiftUI doc](https://developer.apple.com/documentation/swiftui/emptyview/blur(radius:opaque:)) 22 | */ 23 | public struct BlurModifier: ViewModifier { 24 | /// The radial size of the blur. 25 | public let radius: Double 26 | /// A Boolean value that indicates whether the blur renderer permits transparency in the blur output. 27 | public let opaque: Bool 28 | public func body(content: Content) -> some View { 29 | content.blur(radius: (CGFloat)(radius), opaque: opaque) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/CircleShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A custom transition using a scaling circle. 6 | */ 7 | static var circleShape: AnyTransition { 8 | .modifier( 9 | active: ClipShapeModifier(shape: CircleShape(animatableData: 0), style: FillStyle()), 10 | identity: ClipShapeModifier(shape: CircleShape(animatableData: 1), style: FillStyle()) 11 | ) 12 | } 13 | } 14 | 15 | /** 16 | A circle shape which size can be animated. 17 | 18 | Source inspired by 19 | - [Paul Hudson: Hacking with Swift](https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-a-custom-transition) 20 | - [SwiftUI-Lab](https://swiftui-lab.com/advanced-transitions) 21 | */ 22 | public struct CircleShape: Shape { 23 | public var animatableData: CGFloat 24 | 25 | public func path(in rect: CGRect) -> Path { 26 | let maximumCircleDiameter = sqrt(rect.width * rect.width + rect.height * rect.height) 27 | let circleDiameter = maximumCircleDiameter * animatableData 28 | 29 | let posX = rect.midX - circleDiameter / 2.0 30 | let posY = rect.midY - circleDiameter / 2.0 31 | 32 | let circleRect = CGRect(x: posX, y: posY, width: circleDiameter, height: circleDiameter) 33 | 34 | return Circle().path(in: circleRect) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /NavStack.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "NavStack" 3 | spec.version = "1.1.1" # auto-generated 4 | spec.swift_version = "5.4" # auto-generated 5 | spec.summary = "A custom SwiftUI navigation framework." 6 | spec.description = <<-DESC 7 | NavigationStack is a custom SwiftUI solution for navigating between views. It's a more flexible alternative to SwiftUI's own navigation. 8 | DESC 9 | 10 | spec.homepage = "https://github.com/indieSoftware/NavigationStack" 11 | spec.screenshots = "https://github.com/indieSoftware/NavigationStack/blob/master/img/swiftuiTransitions.gif?raw=true", "https://github.com/indieSoftware/NavigationStack/blob/master/img/animationTransitions.gif?raw=true", "https://github.com/indieSoftware/NavigationStack/blob/master/img/customTransitions.gif?raw=true" 12 | spec.license = { :type => "MIT", :file => "LICENSE" } 13 | spec.author = { "Sven Korset" => "sven.korset@indie-software.com" } 14 | spec.ios.deployment_target = "13.0" 15 | spec.osx.deployment_target = "10.15" 16 | spec.source = { :git => "https://github.com/indieSoftware/NavigationStack.git", :tag => "#{spec.version}" } 17 | spec.source_files = "Sources/NavigationStack", "Sources/NavigationStack/**/*.{swift}" 18 | spec.module_name = 'NavigationStack' 19 | end 20 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationAnimationTests/NavigationAnimationInitTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationAnimationInitTests: XCTestCase { 6 | // MARK: - Tests 7 | 8 | // Test the default values are set when initializing a new node. 9 | func testDefaultValues() throws { 10 | let result = NavigationAnimation() 11 | 12 | XCTAssertEqual(.default, result.animation) 13 | XCTAssertNotNil(result.defaultViewTransition) 14 | XCTAssertNotNil(result.alternativeViewTransition) 15 | XCTAssertEqual(NavigationAnimation.zIndexOfBehind, result.defaultViewZIndex) 16 | XCTAssertEqual(NavigationAnimation.zIndexOfInFront, result.alternativeViewZIndex) 17 | } 18 | 19 | // Test custom values provided are set. 20 | func testCustomValues() throws { 21 | let animation = Animation.easeIn 22 | let result = NavigationAnimation( 23 | animation: animation, 24 | defaultViewTransition: .slide, 25 | alternativeViewTransition: .scale, 26 | defaultViewZIndex: 43, 27 | alternativeViewZIndex: 12 28 | ) 29 | 30 | XCTAssertEqual(animation, result.animation) 31 | XCTAssertNotNil(result.defaultViewTransition) 32 | XCTAssertNotNil(result.alternativeViewTransition) 33 | XCTAssertEqual(43, result.defaultViewZIndex) 34 | XCTAssertEqual(12, result.alternativeViewZIndex) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Core/NavigationAnimations.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension NavigationAnimation { 4 | /// A transition animation suitable for a typical push animation. 5 | static let push = NavigationAnimation( 6 | animation: .easeOut, 7 | defaultViewTransition: .move(edge: .leading), 8 | alternativeViewTransition: .move(edge: .trailing) 9 | ) 10 | 11 | /// A transition animation suitable for a typical pop animation. 12 | static let pop = NavigationAnimation( 13 | animation: .easeOut, 14 | defaultViewTransition: .move(edge: .leading), 15 | alternativeViewTransition: .move(edge: .trailing) 16 | ) 17 | 18 | /// A transition animation suitable for a typical modal present animation. 19 | static let present = NavigationAnimation( 20 | animation: .easeOut, 21 | defaultViewTransition: .static, 22 | alternativeViewTransition: .move(edge: .bottom) 23 | ) 24 | 25 | /// A transition animation suitable for a typical modal dismiss animation. 26 | static let dismiss = NavigationAnimation( 27 | animation: .easeOut, 28 | defaultViewTransition: .static, 29 | alternativeViewTransition: .move(edge: .bottom) 30 | ) 31 | 32 | /// A transition animation used to blend the new view into the old view. 33 | static let fade = NavigationAnimation( 34 | animation: .easeInOut, 35 | defaultViewTransition: .static, 36 | alternativeViewTransition: .opacity 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/ExampleViews/Helper/DismissTopContentButton.swift: -------------------------------------------------------------------------------- 1 | import NavigationStack 2 | import SwiftUI 3 | 4 | struct DismissTopContentButton: View { 5 | @EnvironmentObject var navigationModel: NavigationModel 6 | 7 | /// Freezes the state of `navigationModel.hasAlternativeViewShowing` to prevent transition animation glitches. 8 | let hasAlternativeViewShowing: Bool 9 | 10 | var body: some View { 11 | HStack { 12 | // Don't use `hasAlternativeViewShowing` from the model to show different sub-views because this will lead to animation glitches! 13 | if !hasAlternativeViewShowing { 14 | Text("Not possible: ") 15 | .accessibility(identifier: "NotPossibleLabel") 16 | } 17 | // Dangerous access to `isAlternativeViewShowing` to show different sub-views, because this might lead to animation glitches. 18 | // However, here it's fine because the access doesn't happen in the subview itself. 19 | if navigationModel.isAlternativeViewShowing("Subview") { 20 | Text("Subview") 21 | .transition(AnyTransition.move(edge: .leading).combined(with: .opacity)) 22 | } 23 | Button(action: { 24 | navigationModel.hideTopViewWithReverseAnimation() 25 | }, label: { 26 | Text("Back") 27 | }) 28 | .accessibility(identifier: "Back") 29 | } 30 | } 31 | } 32 | 33 | struct DismissTopContentButton_Previews: PreviewProvider { 34 | static var previews: some View { 35 | DismissTopContentButton(hasAlternativeViewShowing: true) 36 | .environmentObject(NavigationModel()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/TiltAndFly.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A custom transition using a 3D geometry effect. 6 | 7 | Source by [SwiftUI-Lab](https://swiftui-lab.com/advanced-transitions) 8 | */ 9 | static var tiltAndFly: AnyTransition { 10 | AnyTransition.modifier(active: TiltAndFlyEffect(animatableData: 0), identity: TiltAndFlyEffect(animatableData: 1)) 11 | } 12 | } 13 | 14 | /** 15 | A custom geomentry effect tilting a view and let it fly away. 16 | 17 | Source by [SwiftUI-Lab](https://swiftui-lab.com/advanced-transitions) 18 | */ 19 | struct TiltAndFlyEffect: GeometryEffect { 20 | public var animatableData: Double 21 | 22 | public func effectValue(size: CGSize) -> ProjectionTransform { 23 | let rotationPercent = animatableData 24 | let angle = CGFloat(Angle(degrees: 90 * (1 - rotationPercent)).radians) 25 | 26 | var transform3d = CATransform3DIdentity 27 | transform3d.m34 = -1 / max(size.width, size.height) 28 | 29 | transform3d = CATransform3DRotate(transform3d, angle, 1, 0, 0) 30 | transform3d = CATransform3DTranslate(transform3d, -size.width / 2.0, -size.height / 2.0, 0) 31 | 32 | let affineTransform1 = ProjectionTransform(CGAffineTransform(translationX: size.width / 2.0, y: size.height / 2.0)) 33 | let affineTransform2 = ProjectionTransform(CGAffineTransform(scaleX: CGFloat(animatableData * 2), y: CGFloat(animatableData * 2))) 34 | 35 | if animatableData <= 0.5 { 36 | return ProjectionTransform(transform3d).concatenating(affineTransform2).concatenating(affineTransform1) 37 | } else { 38 | return ProjectionTransform(transform3d).concatenating(affineTransform1) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationStackNodeTests/NavigationStackNodeGetNodeTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationStackNodeGetNodeTests: XCTestCase { 6 | var node: NavigationStackNode! 7 | 8 | let nodeId = "Foo" 9 | 10 | override func setUp() { 11 | node = NavigationStackNode(identifier: nodeId, alternativeView: { AnyView(EmptyView()) }) 12 | } 13 | 14 | // MARK: - Tests 15 | 16 | // Returns nil when the node is not of the searched name. 17 | func testNotNamed() throws { 18 | let result = node.getNode("Bar") 19 | 20 | XCTAssertNil(result) 21 | } 22 | 23 | // Returns nil when the stack has no node of the searched name. 24 | func testNoNodeInStack() throws { 25 | node.nextNode = NavigationStackNode(identifier: "SomeName", alternativeView: { AnyView(EmptyView()) }) 26 | 27 | let result = node.getNode("Bar") 28 | 29 | XCTAssertNil(result) 30 | } 31 | 32 | // The searched node is the node on which the call was invoced. 33 | func testThisNode() throws { 34 | let result = node.getNode(nodeId) 35 | 36 | XCTAssertTrue(result === node) 37 | } 38 | 39 | // The first node with the same name in the list is found. 40 | func testFistNode() throws { 41 | let nextNode = NavigationStackNode(identifier: nodeId, alternativeView: { AnyView(EmptyView()) }) 42 | node.nextNode = nextNode 43 | 44 | let result = node.getNode(nodeId) 45 | 46 | XCTAssertTrue(result === node) 47 | } 48 | 49 | // Finds the node down the stack with the searched name. 50 | func testNodeInStack() throws { 51 | let nextNode = NavigationStackNode(identifier: "Bar", alternativeView: { AnyView(EmptyView()) }) 52 | node.nextNode = nextNode 53 | 54 | let result = node.getNode("Bar") 55 | 56 | XCTAssertTrue(result === nextNode) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment4.swift: -------------------------------------------------------------------------------- 1 | // This experiment uses the ternary operator instead of an if-else branch to return different views with the different transitions applied, 2 | // but that doesn't work. 3 | import NavigationStack 4 | import SwiftUI 5 | 6 | struct Experiment4: View { 7 | @State var animationIndex = 0 8 | @State var transitionIndex = 0 9 | @State var optionIndex = 0 10 | 11 | @State var showAlternativeContent = false 12 | 13 | @State var animation = Animation.linear 14 | @State var defaultEdge = Edge.leading 15 | @State var alternativeEdge = Edge.trailing 16 | 17 | var body: some View { 18 | VStack(spacing: 20) { 19 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 20 | 21 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 22 | switch animationIndex { 23 | case 0: 24 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 25 | case 1: 26 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 27 | default: 28 | break 29 | } 30 | 31 | switch optionIndex { 32 | case 0: 33 | defaultEdge = .leading 34 | alternativeEdge = .trailing 35 | case 1: 36 | defaultEdge = .top 37 | alternativeEdge = .bottom 38 | default: 39 | break 40 | } 41 | 42 | withAnimation(animation) { 43 | showAlternativeContent.toggle() 44 | } 45 | } 46 | 47 | if !showAlternativeContent { 48 | // Using the ternary operator "?:" doesn't work, we have to use real if-else branches. 49 | transitionIndex == 0 ? DefaultContent().transition(.move(edge: defaultEdge)) : DefaultContent() 50 | .transition(.scale(scale: CGFloat(optionIndex * 2))) 51 | } 52 | if showAlternativeContent { 53 | transitionIndex == 0 ? AlternativeContent().transition(.move(edge: alternativeEdge)) : AlternativeContent() 54 | .transition(.scale(scale: CGFloat(optionIndex * 2))) 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment2.swift: -------------------------------------------------------------------------------- 1 | // This experiment tries to use an if-else branch to switch the default and alternative content view, but that doesn't work. 2 | import NavigationStack 3 | import SwiftUI 4 | 5 | struct Experiment2: View { 6 | @State var animationIndex = 0 7 | @State var transitionIndex = 0 8 | @State var optionIndex = 0 9 | 10 | @State var showAlternativeContent = false 11 | 12 | @State var animation = Animation.linear 13 | @State var defaultEdge = Edge.leading 14 | @State var alternativeEdge = Edge.trailing 15 | 16 | var body: some View { 17 | VStack(spacing: 20) { 18 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 19 | 20 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 21 | switch animationIndex { 22 | case 0: 23 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 24 | case 1: 25 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 26 | default: 27 | break 28 | } 29 | 30 | switch optionIndex { 31 | case 0: 32 | defaultEdge = .leading 33 | alternativeEdge = .trailing 34 | case 1: 35 | defaultEdge = .top 36 | alternativeEdge = .bottom 37 | default: 38 | break 39 | } 40 | 41 | withAnimation(animation) { 42 | showAlternativeContent.toggle() 43 | } 44 | } 45 | 46 | if !showAlternativeContent { 47 | if transitionIndex == 0 { 48 | DefaultContent().transition(.move(edge: defaultEdge)) 49 | } else { 50 | DefaultContent().transition(.scale(scale: CGFloat(optionIndex * 2))) 51 | } 52 | } else if showAlternativeContent { // Using an else-branch doesn't work, we have to separate both if-statement. 53 | if transitionIndex == 0 { 54 | AlternativeContent().transition(.move(edge: alternativeEdge)) 55 | } else { 56 | AlternativeContent().transition(.scale(scale: CGFloat(optionIndex * 2))) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment5.swift: -------------------------------------------------------------------------------- 1 | // This experiment uses the same set up as Experiment4, but instead of using the ternary operator to return back two different views 2 | // here we use it to just return the different transitions, but that doesn't work either. 3 | import NavigationStack 4 | import SwiftUI 5 | 6 | struct Experiment5: View { 7 | @State var animationIndex = 0 8 | @State var transitionIndex = 0 9 | @State var optionIndex = 0 10 | 11 | @State var showAlternativeContent = false 12 | 13 | @State var animation = Animation.linear 14 | @State var defaultEdge = Edge.leading 15 | @State var alternativeEdge = Edge.trailing 16 | 17 | var body: some View { 18 | VStack(spacing: 20) { 19 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 20 | 21 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 22 | switch animationIndex { 23 | case 0: 24 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 25 | case 1: 26 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 27 | default: 28 | break 29 | } 30 | 31 | switch optionIndex { 32 | case 0: 33 | defaultEdge = .leading 34 | alternativeEdge = .trailing 35 | case 1: 36 | defaultEdge = .top 37 | alternativeEdge = .bottom 38 | default: 39 | break 40 | } 41 | 42 | withAnimation(animation) { 43 | showAlternativeContent.toggle() 44 | } 45 | } 46 | 47 | if !showAlternativeContent { 48 | // We have to separate both transitions in their separate branches, just returning a different transition doesn't work. 49 | DefaultContent().transition(transitionIndex == 0 ? .move(edge: defaultEdge) : .scale(scale: CGFloat(optionIndex * 2))) 50 | } 51 | if showAlternativeContent { 52 | AlternativeContent().transition(transitionIndex == 0 ? .move(edge: alternativeEdge) : .scale(scale: CGFloat(optionIndex * 2))) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/js/jazzy.js: -------------------------------------------------------------------------------- 1 | window.jazzy = {'docset': false} 2 | if (typeof window.dash != 'undefined') { 3 | document.documentElement.className += ' dash' 4 | window.jazzy.docset = true 5 | } 6 | if (navigator.userAgent.match(/xcode/i)) { 7 | document.documentElement.className += ' xcode' 8 | window.jazzy.docset = true 9 | } 10 | 11 | function toggleItem($link, $content) { 12 | var animationDuration = 300; 13 | $link.toggleClass('token-open'); 14 | $content.slideToggle(animationDuration); 15 | } 16 | 17 | function itemLinkToContent($link) { 18 | return $link.parent().parent().next(); 19 | } 20 | 21 | // On doc load + hash-change, open any targetted item 22 | function openCurrentItemIfClosed() { 23 | if (window.jazzy.docset) { 24 | return; 25 | } 26 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 27 | $content = itemLinkToContent($link); 28 | if ($content.is(':hidden')) { 29 | toggleItem($link, $content); 30 | } 31 | } 32 | 33 | $(openCurrentItemIfClosed); 34 | $(window).on('hashchange', openCurrentItemIfClosed); 35 | 36 | // On item link ('token') click, toggle its discussion 37 | $('.token').on('click', function(event) { 38 | if (window.jazzy.docset) { 39 | return; 40 | } 41 | var $link = $(this); 42 | toggleItem($link, itemLinkToContent($link)); 43 | 44 | // Keeps the document from jumping to the hash. 45 | var href = $link.attr('href'); 46 | if (history.pushState) { 47 | history.pushState({}, '', href); 48 | } else { 49 | location.hash = href; 50 | } 51 | event.preventDefault(); 52 | }); 53 | 54 | // Clicks on links to the current, closed, item need to open the item 55 | $("a:not('.token')").on('click', function() { 56 | if (location == this.href) { 57 | openCurrentItemIfClosed(); 58 | } 59 | }); 60 | 61 | // KaTeX rendering 62 | if ("katex" in window) { 63 | $($('.math').each( (_, element) => { 64 | katex.render(element.textContent, element, { 65 | displayMode: $(element).hasClass('m-block'), 66 | throwOnError: false, 67 | trust: true 68 | }); 69 | })) 70 | } 71 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment11.swift: -------------------------------------------------------------------------------- 1 | // Issue report: https://github.com/indieSoftware/NavigationStack/issues/2 2 | // Is it possible to use a timer or a dispatched action to trigger a navigation? 3 | 4 | import NavigationStack 5 | import SwiftUI 6 | 7 | struct Experiment11: View { 8 | var body: some View { 9 | ContentView() 10 | .environmentObject(NavigationModel()) 11 | } 12 | } 13 | 14 | private class ViewModel: ObservableObject { 15 | private var timer: Timer? 16 | @Published var ticks = 0 17 | func startTimer(navigationModel: NavigationModel) { 18 | ticks = 3 19 | timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in 20 | self.ticks -= 1 21 | if self.ticks == 0 { 22 | timer.invalidate() 23 | self.timer = nil 24 | navigationModel.popContent(ContentView.id) 25 | } 26 | } 27 | } 28 | } 29 | 30 | private struct SecondScreen: View { 31 | @EnvironmentObject var navigationModel: NavigationModel 32 | @ObservedObject var model = ViewModel() 33 | var body: some View { 34 | ZStack { 35 | Color.orange 36 | VStack { 37 | Text("Timer: \(model.ticks)") 38 | Button(action: { // Don't press the button multiple times or multiple timers will be created 39 | print("Timer started") 40 | model.startTimer(navigationModel: navigationModel) 41 | }, label: { 42 | Text("Start pop in 3 seconds") 43 | }) 44 | } 45 | } 46 | } 47 | } 48 | 49 | private struct ContentView: View { 50 | static let id = String(describing: Self.self) 51 | @EnvironmentObject var navigationModel: NavigationModel 52 | 53 | var body: some View { 54 | NavigationStackView(ContentView.id) { 55 | ZStack { 56 | Color.yellow 57 | Button(action: { // Don't press the button multiple times or the app will crash 58 | print("Timer started") 59 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { 60 | print("Push executing") 61 | self.navigationModel.pushContent(ContentView.id) { 62 | SecondScreen() 63 | } 64 | } 65 | }, label: { 66 | Text("Start push in 3 seconds") 67 | }) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Resources/Documents/js/jazzy.js: -------------------------------------------------------------------------------- 1 | window.jazzy = {'docset': false} 2 | if (typeof window.dash != 'undefined') { 3 | document.documentElement.className += ' dash' 4 | window.jazzy.docset = true 5 | } 6 | if (navigator.userAgent.match(/xcode/i)) { 7 | document.documentElement.className += ' xcode' 8 | window.jazzy.docset = true 9 | } 10 | 11 | function toggleItem($link, $content) { 12 | var animationDuration = 300; 13 | $link.toggleClass('token-open'); 14 | $content.slideToggle(animationDuration); 15 | } 16 | 17 | function itemLinkToContent($link) { 18 | return $link.parent().parent().next(); 19 | } 20 | 21 | // On doc load + hash-change, open any targetted item 22 | function openCurrentItemIfClosed() { 23 | if (window.jazzy.docset) { 24 | return; 25 | } 26 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 27 | $content = itemLinkToContent($link); 28 | if ($content.is(':hidden')) { 29 | toggleItem($link, $content); 30 | } 31 | } 32 | 33 | $(openCurrentItemIfClosed); 34 | $(window).on('hashchange', openCurrentItemIfClosed); 35 | 36 | // On item link ('token') click, toggle its discussion 37 | $('.token').on('click', function(event) { 38 | if (window.jazzy.docset) { 39 | return; 40 | } 41 | var $link = $(this); 42 | toggleItem($link, itemLinkToContent($link)); 43 | 44 | // Keeps the document from jumping to the hash. 45 | var href = $link.attr('href'); 46 | if (history.pushState) { 47 | history.pushState({}, '', href); 48 | } else { 49 | location.hash = href; 50 | } 51 | event.preventDefault(); 52 | }); 53 | 54 | // Clicks on links to the current, closed, item need to open the item 55 | $("a:not('.token')").on('click', function() { 56 | if (location == this.href) { 57 | openCurrentItemIfClosed(); 58 | } 59 | }); 60 | 61 | // KaTeX rendering 62 | if ("katex" in window) { 63 | $($('.math').each( (_, element) => { 64 | katex.render(element.textContent, element, { 65 | displayMode: $(element).hasClass('m-block'), 66 | throwOnError: false, 67 | trust: true 68 | }); 69 | })) 70 | } 71 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationStackNodeTests/NavigationStackNodeGetLeafNodeTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationStackNodeGetLeafNodeTests: XCTestCase { 6 | var node: NavigationStackNode! 7 | 8 | let nodeId = "Foo" 9 | 10 | override func setUp() { 11 | node = NavigationStackNode(identifier: nodeId, alternativeView: { AnyView(EmptyView()) }) 12 | } 13 | 14 | // MARK: - Tests 15 | 16 | // Returns nil when the node is not active. 17 | func testNotActive() throws { 18 | let result = node.getLeafNode() 19 | 20 | XCTAssertNil(result) 21 | } 22 | 23 | // Returns nil when the stack has no active node. 24 | func testNoActiveNodeInStack() throws { 25 | node.nextNode = NavigationStackNode(identifier: "Bar", alternativeView: { AnyView(EmptyView()) }) 26 | 27 | let result = node.getLeafNode() 28 | 29 | XCTAssertNil(result) 30 | } 31 | 32 | // Returns nil when the first node in the stack is not active, but one down might be. 33 | func testNoActiveRootNodeInStack() throws { 34 | let nextNode = NavigationStackNode(identifier: "Bar", alternativeView: { AnyView(EmptyView()) }) 35 | nextNode.isAlternativeViewShowing = true 36 | node.nextNode = nextNode 37 | 38 | let result = node.getLeafNode() 39 | 40 | XCTAssertNil(result) 41 | } 42 | 43 | // Returns the root node if this is the only one active in the stack. 44 | func testActiveRootNode() throws { 45 | let nextNode = NavigationStackNode(identifier: "Bar", alternativeView: { AnyView(EmptyView()) }) 46 | node.nextNode = nextNode 47 | node.isAlternativeViewShowing = true 48 | 49 | let result = node.getLeafNode() 50 | 51 | XCTAssertTrue(result === node) 52 | } 53 | 54 | // Returns the last node of the stack if all nodes are active. 55 | func testActiveLeafNode() throws { 56 | let nextNode = NavigationStackNode(identifier: "Bar", alternativeView: { AnyView(EmptyView()) }) 57 | nextNode.isAlternativeViewShowing = true 58 | node.nextNode = nextNode 59 | node.isAlternativeViewShowing = true 60 | 61 | let result = node.getLeafNode() 62 | 63 | XCTAssertTrue(result === nextNode) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment1.swift: -------------------------------------------------------------------------------- 1 | // This experiment includes knowledge from experiment 2 to 5 to get the transition animation working as expected. 2 | import NavigationStack 3 | import SwiftUI 4 | 5 | struct Experiment1: View { 6 | @State var animationIndex = 0 7 | @State var transitionIndex = 0 8 | @State var optionIndex = 0 9 | 10 | @State var showAlternativeContent = false 11 | 12 | @State var animation = Animation.linear 13 | @State var defaultEdge = Edge.leading 14 | @State var alternativeEdge = Edge.trailing 15 | 16 | var body: some View { 17 | VStack(spacing: 20) { 18 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 19 | 20 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 21 | // Set the animation and the transition before the withAnimation block to update it before visualising it 22 | // and to decouple these states from the picker variables. 23 | switch animationIndex { 24 | case 0: 25 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 26 | case 1: 27 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 28 | default: 29 | break 30 | } 31 | 32 | switch optionIndex { 33 | case 0: 34 | defaultEdge = .leading 35 | alternativeEdge = .trailing 36 | case 1: 37 | defaultEdge = .top 38 | alternativeEdge = .bottom 39 | default: 40 | break 41 | } 42 | 43 | withAnimation(animation) { 44 | showAlternativeContent.toggle() 45 | } 46 | } 47 | 48 | if !showAlternativeContent { 49 | if transitionIndex == 0 { 50 | DefaultContent().transition(.move(edge: defaultEdge)) 51 | } else { 52 | DefaultContent().transition(.scale(scale: CGFloat(optionIndex * 2))) 53 | } 54 | } 55 | if showAlternativeContent { 56 | if transitionIndex == 0 { 57 | AlternativeContent().transition(.move(edge: alternativeEdge)) 58 | } else { 59 | AlternativeContent().transition(.scale(scale: CGFloat(optionIndex * 2))) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/NavigationStackExampleUITests/TransitionExampleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class TransitionExampleTests: XCTestCase { 4 | private var app: XCUIApplication! 5 | 6 | override func setUpWithError() throws { 7 | continueAfterFailure = false 8 | app = XCUIApplication() 9 | app.launch() 10 | 11 | app.buttons["TransitionExamplesButton"].tap() 12 | sleep(1) 13 | } 14 | 15 | private func playTransition(_ name: String) { 16 | app.buttons[name + "Button"].tap() 17 | sleep(1) 18 | app.buttons["BackButton"].tap() 19 | sleep(1) 20 | } 21 | 22 | // MARK: - Tests 23 | 24 | // Plays all transition animations of the TransitionExample view in sequence. 25 | func testTransitionAnimations() throws { 26 | playTransition("Move") 27 | playTransition("Scale") 28 | playTransition("Offset") 29 | playTransition("Opacity") 30 | playTransition("Slide") 31 | playTransition("Identity") 32 | 33 | playTransition("Static") 34 | 35 | playTransition("Blur") 36 | playTransition("Brightness") 37 | playTransition("Contrast") 38 | playTransition("HueRotation") 39 | playTransition("Saturation") 40 | 41 | playTransition("TiltAndFly") 42 | playTransition("CircleShape") 43 | playTransition("RectangleShape") 44 | playTransition("StripesHorizontalDown") 45 | playTransition("StripesHorizontalUp") 46 | playTransition("StripesVerticalRight") 47 | playTransition("StripesVerticalLeft") 48 | } 49 | 50 | func testSwiftUiTransitions() throws { 51 | playTransition("Move") 52 | playTransition("Scale") 53 | playTransition("Offset") 54 | playTransition("Opacity") 55 | playTransition("Slide") 56 | } 57 | 58 | func testAnimationTransitions() throws { 59 | playTransition("Blur") 60 | playTransition("Brightness") 61 | playTransition("Contrast") 62 | playTransition("HueRotation") 63 | playTransition("Saturation") 64 | } 65 | 66 | func testCustomTransitions() throws { 67 | playTransition("TiltAndFly") 68 | playTransition("CircleShape") 69 | playTransition("RectangleShape") 70 | playTransition("StripesHorizontalDown") 71 | playTransition("StripesHorizontalUp") 72 | playTransition("StripesVerticalRight") 73 | playTransition("StripesVerticalLeft") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Config/NavigationStackExample/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.1.1 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UIApplicationSupportsIndirectInputEvents 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment3.swift: -------------------------------------------------------------------------------- 1 | // This experiment tries to save the transition into a state property, but that doesn't work. 2 | import NavigationStack 3 | import SwiftUI 4 | 5 | struct Experiment3: View { 6 | @State var animationIndex = 0 7 | @State var transitionIndex = 0 8 | @State var optionIndex = 0 9 | 10 | @State var showAlternativeContent = false 11 | 12 | @State var animation = Animation.linear 13 | @State var defaultEdge = Edge.leading 14 | @State var alternativeEdge = Edge.trailing 15 | @State var defaultTransition = AnyTransition.identity 16 | @State var alternativeTransition = AnyTransition.identity 17 | 18 | var body: some View { 19 | VStack(spacing: 20) { 20 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 21 | 22 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 23 | switch animationIndex { 24 | case 0: 25 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 26 | case 1: 27 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 28 | default: 29 | break 30 | } 31 | 32 | switch optionIndex { 33 | case 0: 34 | defaultEdge = .leading 35 | alternativeEdge = .trailing 36 | case 1: 37 | defaultEdge = .top 38 | alternativeEdge = .bottom 39 | default: 40 | break 41 | } 42 | 43 | switch transitionIndex { 44 | case 0: 45 | defaultTransition = .move(edge: defaultEdge) 46 | alternativeTransition = .move(edge: alternativeEdge) 47 | case 1: 48 | defaultTransition = .scale(scale: CGFloat(optionIndex * 2)) 49 | alternativeTransition = .scale(scale: CGFloat(optionIndex * 2)) 50 | default: 51 | break 52 | } 53 | 54 | withAnimation(animation) { 55 | showAlternativeContent.toggle() 56 | } 57 | } 58 | 59 | if !showAlternativeContent { 60 | // Using a saved transition doesn't work, we have to write it explicitely out. 61 | DefaultContent().transition(defaultTransition) 62 | } 63 | if showAlternativeContent { 64 | AlternativeContent().transition(alternativeTransition) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /docs/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | var $typeahead = $('[data-typeahead]'); 3 | var $form = $typeahead.parents('form'); 4 | var searchURL = $form.attr('action'); 5 | 6 | function displayTemplate(result) { 7 | return result.name; 8 | } 9 | 10 | function suggestionTemplate(result) { 11 | var t = '
'; 12 | t += '' + result.name + ''; 13 | if (result.parent_name) { 14 | t += '' + result.parent_name + ''; 15 | } 16 | t += '
'; 17 | return t; 18 | } 19 | 20 | $typeahead.one('focus', function() { 21 | $form.addClass('loading'); 22 | 23 | $.getJSON(searchURL).then(function(searchData) { 24 | const searchIndex = lunr(function() { 25 | this.ref('url'); 26 | this.field('name'); 27 | this.field('abstract'); 28 | for (const [url, doc] of Object.entries(searchData)) { 29 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 30 | } 31 | }); 32 | 33 | $typeahead.typeahead( 34 | { 35 | highlight: true, 36 | minLength: 3, 37 | autoselect: true 38 | }, 39 | { 40 | limit: 10, 41 | display: displayTemplate, 42 | templates: { suggestion: suggestionTemplate }, 43 | source: function(query, sync) { 44 | const lcSearch = query.toLowerCase(); 45 | const results = searchIndex.query(function(q) { 46 | q.term(lcSearch, { boost: 100 }); 47 | q.term(lcSearch, { 48 | boost: 10, 49 | wildcard: lunr.Query.wildcard.TRAILING 50 | }); 51 | }).map(function(result) { 52 | var doc = searchData[result.ref]; 53 | doc.url = result.ref; 54 | return doc; 55 | }); 56 | sync(results); 57 | } 58 | } 59 | ); 60 | $form.removeClass('loading'); 61 | $typeahead.trigger('focus'); 62 | }); 63 | }); 64 | 65 | var baseURL = searchURL.slice(0, -"search.json".length); 66 | 67 | $typeahead.on('typeahead:select', function(e, result) { 68 | window.location = baseURL + result.url; 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/ExampleViews/SubviewExamples.swift: -------------------------------------------------------------------------------- 1 | import NavigationStack 2 | import SwiftUI 3 | 4 | // This example tries to use the framework to navigate between sub-views. 5 | // However, this not really a navigation and leads to problems because the navigation is designed to hold a nested tree, 6 | // therefore, it's not recommended to use the navigation framework for switching sub-views. 7 | struct SubviewExamples: View { 8 | let subview1Name = "Subview1" 9 | let subview2Name = "Subview2" 10 | 11 | @EnvironmentObject private var navigationModel: NavigationModel 12 | 13 | var body: some View { 14 | HStack { 15 | VStack(alignment: .leading, spacing: 20) { 16 | Button(action: { 17 | navigationModel.hideTopViewWithReverseAnimation() 18 | }, label: { 19 | Text("Back") 20 | }) 21 | .accessibility(identifier: "BackButton") 22 | 23 | Button(action: { 24 | navigationModel.showView(subview1Name, animation: NavigationAnimation.push) { 25 | ColoredSubview(color: .blue) 26 | } 27 | }, label: { 28 | Text("Push Subview1") 29 | }) 30 | .accessibility(identifier: "Subview1Button") 31 | NavigationStackView(subview1Name) { 32 | ColoredSubview(color: .black) 33 | } 34 | 35 | Button(action: { 36 | navigationModel.showView(subview2Name, animation: NavigationAnimation.push) { 37 | ColoredSubview(color: .red) 38 | } 39 | }, label: { 40 | Text("Push Subview2") 41 | }) 42 | .accessibility(identifier: "Subview2Button") 43 | NavigationStackView(subview2Name) { 44 | ColoredSubview(color: .black) 45 | } 46 | 47 | Button(action: { 48 | navigationModel.hideViewWithReverseAnimation(subview2Name) 49 | }, label: { 50 | Text("Reset Subview2") 51 | }) 52 | .accessibility(identifier: "ResetButton") 53 | 54 | Spacer() 55 | } 56 | Spacer() 57 | } 58 | .padding() 59 | .background(Color.white) 60 | } 61 | } 62 | 63 | struct SubviewExamples_Previews: PreviewProvider { 64 | static var previews: some View { 65 | SubviewExamples() 66 | .environmentObject(NavigationModel()) 67 | } 68 | } 69 | 70 | private struct ColoredSubview: View { 71 | let color: UIColor 72 | 73 | var body: some View { 74 | Color(color) 75 | .frame(width: 200, height: 150) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Helper/Commons.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | let experimentAnimationSpeedFactor = 0.75 4 | let contentBackgroundOpacity = 1.0 5 | 6 | struct DefaultContent: View { 7 | var body: some View { 8 | ZStack { 9 | Color(.green) 10 | .opacity(contentBackgroundOpacity) 11 | Text("Default Content (Green)") 12 | } 13 | } 14 | } 15 | 16 | struct AlternativeContent: View { 17 | var body: some View { 18 | ZStack { 19 | Color(.orange) 20 | .opacity(contentBackgroundOpacity) 21 | Text("Alternative Content (Orange)") 22 | } 23 | } 24 | } 25 | 26 | struct Pickers: View { 27 | @Binding var animationIndex: Int 28 | @Binding var transitionIndex: Int 29 | @Binding var optionIndex: Int 30 | 31 | var body: some View { 32 | HStack { 33 | Text("Animation") 34 | Picker("", selection: self.$animationIndex) { 35 | Text("Linear").tag(0) 36 | Text("Spring").tag(1) 37 | } 38 | .pickerStyle(SegmentedPickerStyle()) 39 | .accessibility(identifier: "Picker_0") 40 | } 41 | .padding(8) 42 | 43 | HStack { 44 | Text("Transition") 45 | Picker("", selection: self.$transitionIndex) { 46 | Text("Move").tag(0) 47 | Text("Scale").tag(1) 48 | } 49 | .pickerStyle(SegmentedPickerStyle()) 50 | .accessibility(identifier: "Picker_1") 51 | } 52 | .padding(8) 53 | 54 | HStack { 55 | if transitionIndex == 0 { 56 | Text("Tra. Edge") 57 | Picker("", selection: self.$optionIndex) { 58 | Text("Horizontal").tag(0) 59 | Text("Vertical").tag(1) 60 | } 61 | .pickerStyle(SegmentedPickerStyle()) 62 | .accessibility(identifier: "Picker_2") 63 | } else { 64 | Text("Scaling") 65 | Picker("", selection: self.$optionIndex) { 66 | Text("x0").tag(0) 67 | Text("x2").tag(1) 68 | } 69 | .pickerStyle(SegmentedPickerStyle()) 70 | .accessibility(identifier: "Picker_2") 71 | } 72 | } 73 | .padding(8) 74 | } 75 | } 76 | 77 | struct ToggleContentButton: View { 78 | @Binding var showAlternativeContent: Bool 79 | let buttonAction: () -> Void 80 | 81 | var body: some View { 82 | Button(action: buttonAction, label: { 83 | Text("Toggle content (show \(showAlternativeContent ? "Default" : "Alternative") Content)") 84 | }) 85 | .accessibility(identifier: "ToggleContentButton") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationModelTests/NavigationModelConvenienceMethodsTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationModelConvenienceMethodsTests: XCTestCase { 6 | var model: NavigationModelStub! 7 | 8 | override func setUp() { 9 | model = NavigationModelStub(silenceErrors: true) 10 | } 11 | 12 | // MARK: - Tests 13 | 14 | func testPushContent() throws { 15 | let modelExpectation = expectation(description: "Model") 16 | model.showViewStub = { id, _ in 17 | XCTAssertEqual("Foo", id) 18 | modelExpectation.fulfill() 19 | } 20 | 21 | model.pushContent("Foo") { EmptyView() } 22 | 23 | waitForExpectations(timeout: 1.0) 24 | } 25 | 26 | func testPopContent() throws { 27 | let modelExpectation = expectation(description: "Model") 28 | model.hideViewStub = { id, _ in 29 | XCTAssertEqual("Foo", id) 30 | modelExpectation.fulfill() 31 | } 32 | 33 | model.popContent("Foo") 34 | 35 | waitForExpectations(timeout: 1.0) 36 | } 37 | 38 | func testPresentContent() throws { 39 | let modelExpectation = expectation(description: "Model") 40 | model.showViewStub = { id, _ in 41 | XCTAssertEqual("Foo", id) 42 | modelExpectation.fulfill() 43 | } 44 | 45 | model.presentContent("Foo") { EmptyView() } 46 | 47 | waitForExpectations(timeout: 1.0) 48 | } 49 | 50 | func testDismissContent() throws { 51 | let modelExpectation = expectation(description: "Model") 52 | model.hideViewStub = { id, _ in 53 | XCTAssertEqual("Foo", id) 54 | modelExpectation.fulfill() 55 | } 56 | 57 | model.dismissContent("Foo") 58 | 59 | waitForExpectations(timeout: 1.0) 60 | } 61 | 62 | func testFadeInContent() throws { 63 | let modelExpectation = expectation(description: "Model") 64 | model.showViewStub = { id, _ in 65 | XCTAssertEqual("Foo", id) 66 | modelExpectation.fulfill() 67 | } 68 | 69 | model.fadeInContent("Foo") { EmptyView() } 70 | 71 | waitForExpectations(timeout: 1.0) 72 | } 73 | 74 | func testFadeOutContent() throws { 75 | let modelExpectation = expectation(description: "Model") 76 | model.hideViewStub = { id, _ in 77 | XCTAssertEqual("Foo", id) 78 | modelExpectation.fulfill() 79 | } 80 | 81 | model.fadeOutContent("Foo") 82 | 83 | waitForExpectations(timeout: 1.0) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /docs/docsets/NavigationStack.docset/Contents/Resources/Documents/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | var $typeahead = $('[data-typeahead]'); 3 | var $form = $typeahead.parents('form'); 4 | var searchURL = $form.attr('action'); 5 | 6 | function displayTemplate(result) { 7 | return result.name; 8 | } 9 | 10 | function suggestionTemplate(result) { 11 | var t = '
'; 12 | t += '' + result.name + ''; 13 | if (result.parent_name) { 14 | t += '' + result.parent_name + ''; 15 | } 16 | t += '
'; 17 | return t; 18 | } 19 | 20 | $typeahead.one('focus', function() { 21 | $form.addClass('loading'); 22 | 23 | $.getJSON(searchURL).then(function(searchData) { 24 | const searchIndex = lunr(function() { 25 | this.ref('url'); 26 | this.field('name'); 27 | this.field('abstract'); 28 | for (const [url, doc] of Object.entries(searchData)) { 29 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 30 | } 31 | }); 32 | 33 | $typeahead.typeahead( 34 | { 35 | highlight: true, 36 | minLength: 3, 37 | autoselect: true 38 | }, 39 | { 40 | limit: 10, 41 | display: displayTemplate, 42 | templates: { suggestion: suggestionTemplate }, 43 | source: function(query, sync) { 44 | const lcSearch = query.toLowerCase(); 45 | const results = searchIndex.query(function(q) { 46 | q.term(lcSearch, { boost: 100 }); 47 | q.term(lcSearch, { 48 | boost: 10, 49 | wildcard: lunr.Query.wildcard.TRAILING 50 | }); 51 | }).map(function(result) { 52 | var doc = searchData[result.ref]; 53 | doc.url = result.ref; 54 | return doc; 55 | }); 56 | sync(results); 57 | } 58 | } 59 | ); 60 | $form.removeClass('loading'); 61 | $typeahead.trigger('focus'); 62 | }); 63 | }); 64 | 65 | var baseURL = searchURL.slice(0, -"search.json".length); 66 | 67 | $typeahead.on('typeahead:select', function(e, result) { 68 | window.location = baseURL + result.url; 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Core/NavigationAnimation.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A data struct with information for a transition animation used by the `NavigationStackView`. 4 | public struct NavigationAnimation { 5 | /// The Z-Index (`-1`) to use by content which should be shown behind the other. 6 | public static let zIndexOfBehind = -1.0 7 | /// The Z-Index (`1`) to use by content which should be shown in front of the other. 8 | public static let zIndexOfInFront = 1.0 9 | 10 | /** 11 | - parameter animation: The animation curve to use when animating a transition. 12 | - parameter defaultViewTransition: The transition to apply to the origin view. 13 | Defaults to `static` to keep the view visible during the transition. 14 | - parameter alternativeViewTransition: The transition to apply to the destination view. 15 | Defaults to `static` to keep the view visible during the transition. 16 | - parameter defaultViewZIndex: The Z-index to apply to the origin view during the transition. 17 | Defaults to -1 to show the default view behind the alternative view during animations. 18 | - parameter alternativeViewZIndex: The Z-index to apply to the destination view during the transition. 19 | Defaults to 1 to show the alternative view in front of the default view during animations. 20 | */ 21 | public init( 22 | animation: Animation = .default, 23 | defaultViewTransition: AnyTransition = .static, 24 | alternativeViewTransition: AnyTransition = .static, 25 | defaultViewZIndex: Double = zIndexOfBehind, 26 | alternativeViewZIndex: Double = zIndexOfInFront 27 | ) { 28 | self.animation = animation 29 | self.defaultViewTransition = defaultViewTransition 30 | self.alternativeViewTransition = alternativeViewTransition 31 | self.defaultViewZIndex = defaultViewZIndex 32 | self.alternativeViewZIndex = alternativeViewZIndex 33 | } 34 | 35 | /// The animation curve to use when animating a transition. 36 | let animation: Animation 37 | /// The transition to apply to the origin view. 38 | let defaultViewTransition: AnyTransition 39 | /// The transition to apply to the destination view. 40 | let alternativeViewTransition: AnyTransition 41 | /// The Z-index to apply to the origin view during the transition. 42 | let defaultViewZIndex: Double 43 | /// The Z-index to apply to the destination view during the transition. 44 | let alternativeViewZIndex: Double 45 | } 46 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Core/NavigationStackModelConvenienceMethods.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension NavigationStackModel { 4 | /** 5 | A convenience method to navigate to a new view with a push transition animation. 6 | 7 | - parameter identifier: The navigation stack view's ID on which to push. 8 | - parameter alternativeView: The content view to push. 9 | */ 10 | func pushContent(_ identifier: IdentifierType, @ViewBuilder alternativeView: @escaping () -> Content) { 11 | showView(identifier, animation: .push, alternativeView: alternativeView) 12 | } 13 | 14 | /** 15 | A convenience method to navigate back to a previous view with a pop transition animation. 16 | 17 | - parameter identifier: The navigation stack view's ID on which to pop back. 18 | */ 19 | func popContent(_ identifier: IdentifierType) { 20 | hideView(identifier, animation: .pop) 21 | } 22 | 23 | /** 24 | A convenience method to navigate to a new view with a present transition animation. 25 | 26 | - parameter identifier: The navigation stack view's ID on which to present. 27 | - parameter alternativeView: The content view to present. 28 | */ 29 | func presentContent(_ identifier: IdentifierType, @ViewBuilder alternativeView: @escaping () -> Content) { 30 | showView(identifier, animation: .present, alternativeView: alternativeView) 31 | } 32 | 33 | /** 34 | A convenience method to navigate back to a previous view with a dismiss transition animation. 35 | 36 | - parameter identifier: The navigation stack view's ID on which to dismiss. 37 | */ 38 | func dismissContent(_ identifier: IdentifierType) { 39 | hideView(identifier, animation: .dismiss) 40 | } 41 | 42 | /** 43 | A convenience method to navigate to a new view with a fade-in transition animation. 44 | 45 | - parameter identifier: The navigation stack view's ID on which to fade-in. 46 | - parameter alternativeView: The content view to fade-in. 47 | */ 48 | func fadeInContent(_ identifier: IdentifierType, @ViewBuilder alternativeView: @escaping () -> Content) { 49 | showView(identifier, animation: .fade, alternativeView: alternativeView) 50 | } 51 | 52 | /** 53 | A convenience method to navigate back to a previous view with a fade-out transition animation. 54 | 55 | - parameter identifier: The navigation stack view's ID on which to fade-out. 56 | */ 57 | func fadeOutContent(_ identifier: IdentifierType) { 58 | hideView(identifier, animation: .fade) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack-acknowledgements.markdown: -------------------------------------------------------------------------------- 1 | # Acknowledgements 2 | This application makes use of the following third party libraries: 3 | 4 | ## SwiftFormat 5 | 6 | MIT License 7 | 8 | Copyright (c) 2016 Nick Lockwood 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | 28 | 29 | ## SwiftLint 30 | 31 | The MIT License (MIT) 32 | 33 | Copyright (c) 2020 Realm Inc. 34 | 35 | Permission is hereby granted, free of charge, to any person obtaining a copy 36 | of this software and associated documentation files (the "Software"), to deal 37 | in the Software without restriction, including without limitation the rights 38 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 39 | copies of the Software, and to permit persons to whom the Software is 40 | furnished to do so, subject to the following conditions: 41 | 42 | The above copyright notice and this permission notice shall be included in all 43 | copies or substantial portions of the Software. 44 | 45 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 46 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 47 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 48 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 49 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 50 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 51 | SOFTWARE. 52 | 53 | Generated by CocoaPods - https://cocoapods.org 54 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample-acknowledgements.markdown: -------------------------------------------------------------------------------- 1 | # Acknowledgements 2 | This application makes use of the following third party libraries: 3 | 4 | ## SwiftFormat 5 | 6 | MIT License 7 | 8 | Copyright (c) 2016 Nick Lockwood 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | 28 | 29 | ## SwiftLint 30 | 31 | The MIT License (MIT) 32 | 33 | Copyright (c) 2020 Realm Inc. 34 | 35 | Permission is hereby granted, free of charge, to any person obtaining a copy 36 | of this software and associated documentation files (the "Software"), to deal 37 | in the Software without restriction, including without limitation the rights 38 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 39 | copies of the Software, and to permit persons to whom the Software is 40 | furnished to do so, subject to the following conditions: 41 | 42 | The above copyright notice and this permission notice shall be included in all 43 | copies or substantial portions of the Software. 44 | 45 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 46 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 47 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 48 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 49 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 50 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 51 | SOFTWARE. 52 | 53 | Generated by CocoaPods - https://cocoapods.org 54 | -------------------------------------------------------------------------------- /NavigationStack.xcodeproj/xcshareddata/xcschemes/Format Code.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment9.swift: -------------------------------------------------------------------------------- 1 | // This experiment builds on top of Experiment8, but solves the ordering of the overlapping content views. 2 | import NavigationStack 3 | import SwiftUI 4 | 5 | struct Experiment9: View { 6 | @State var animationIndex = 0 7 | @State var transitionIndex = 0 8 | @State var optionIndex = 0 9 | 10 | @State var showAlternativeContentPreStep = false 11 | @State var showAlternativeContent = false 12 | 13 | @State var animation = Animation.linear 14 | @State var defaultEdge = Edge.leading 15 | @State var alternativeEdge = Edge.trailing 16 | @State var defaultTransition = AnyTransition.identity 17 | @State var alternativeTransition = AnyTransition.identity 18 | 19 | var body: some View { 20 | VStack(spacing: 20) { 21 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 22 | 23 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 24 | switch animationIndex { 25 | case 0: 26 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 27 | case 1: 28 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 29 | default: 30 | break 31 | } 32 | 33 | switch optionIndex { 34 | case 0: 35 | defaultEdge = .leading 36 | alternativeEdge = .trailing 37 | case 1: 38 | defaultEdge = .top 39 | alternativeEdge = .bottom 40 | default: 41 | break 42 | } 43 | 44 | switch transitionIndex { 45 | case 0: 46 | defaultTransition = .move(edge: defaultEdge) 47 | alternativeTransition = .move(edge: alternativeEdge) 48 | case 1: 49 | defaultTransition = .scale(scale: CGFloat(optionIndex * 2)) 50 | alternativeTransition = .scale(scale: CGFloat(optionIndex * 2)) 51 | default: 52 | break 53 | } 54 | 55 | showAlternativeContentPreStep.toggle() 56 | withAnimation(animation) { 57 | showAlternativeContent.toggle() 58 | } 59 | } 60 | 61 | if showAlternativeContentPreStep { 62 | if !showAlternativeContent { 63 | DefaultContent().transition(defaultTransition) 64 | } 65 | if showAlternativeContent { 66 | AlternativeContent().transition(alternativeTransition).zIndex(1) // Makes the alternative content always on top of the default one. 67 | } 68 | } else { 69 | if !showAlternativeContent { 70 | DefaultContent().transition(defaultTransition) 71 | } 72 | if showAlternativeContent { 73 | AlternativeContent().transition(alternativeTransition).zIndex(1) // Makes the alternative content always on top of the default one. 74 | } 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationStackNodeTests/NavigationStackNodePublishedTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | @testable import NavigationStack 3 | import SwiftUI 4 | import XCTest 5 | 6 | class NavigationStackNodePublishedTests: XCTestCase { 7 | var node: NavigationStackNode! 8 | var nodeDisposal: AnyCancellable! 9 | 10 | override func setUp() { 11 | node = NavigationStackNode(identifier: "Foo", alternativeView: { AnyView(EmptyView()) }) 12 | } 13 | 14 | private func setupNodePublishExpectation() { 15 | let nodeExpectation = expectation(description: "node") 16 | nodeDisposal = node.objectWillChange.sink( 17 | receiveValue: { 18 | nodeExpectation.fulfill() 19 | } 20 | ) 21 | } 22 | 23 | // MARK: - Tests 24 | 25 | // Test that changing the node's property will result in an update-notification to observers. 26 | func testIsAlternativeViewShowingPublished() throws { 27 | setupNodePublishExpectation() 28 | 29 | node.isAlternativeViewShowing = true 30 | 31 | waitForExpectations(timeout: 1) 32 | XCTAssertTrue(node.isAlternativeViewShowing) 33 | } 34 | 35 | // Test that changing the node's property will result in an update-notification to observers. 36 | func testIsAlternativeViewShowingPrecedePublished() throws { 37 | setupNodePublishExpectation() 38 | 39 | node.isAlternativeViewShowingPrecede = true 40 | 41 | waitForExpectations(timeout: 1) 42 | XCTAssertTrue(node.isAlternativeViewShowingPrecede) 43 | } 44 | 45 | // Test that changing the node's property will result in an update-notification to observers. 46 | func testTransitionAnimationPublished() throws { 47 | setupNodePublishExpectation() 48 | 49 | node.transitionAnimation = NavigationAnimation() 50 | 51 | waitForExpectations(timeout: 1) 52 | XCTAssertNotNil(node.transitionAnimation) 53 | } 54 | 55 | // Test that assigning a nextNode will result in an update-notification to observers. 56 | func testNextNodeAssignPublished() throws { 57 | setupNodePublishExpectation() 58 | 59 | node.nextNode = NavigationStackNode(identifier: "Bar", alternativeView: { AnyView(EmptyView()) }) 60 | 61 | waitForExpectations(timeout: 1) 62 | XCTAssertNotNil(node.nextNode) 63 | } 64 | 65 | // Test that changing a nextNode's property will result in an update-notification to observers of the node itself. 66 | func testNextNodeChangedPropertyPublished() throws { 67 | let nextNode = NavigationStackNode(identifier: "Bar", alternativeView: { AnyView(EmptyView()) }) 68 | node.nextNode = nextNode 69 | XCTAssertFalse(nextNode.isAlternativeViewShowing) 70 | 71 | setupNodePublishExpectation() 72 | 73 | nextNode.isAlternativeViewShowing = true 74 | 75 | waitForExpectations(timeout: 1) 76 | XCTAssertEqual(true, node.nextNode?.isAlternativeViewShowing) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment10.swift: -------------------------------------------------------------------------------- 1 | // Issue report: https://github.com/indieSoftware/NavigationStack/issues/1 2 | // With v1.0.2 the push is not visually executed sometimes. 3 | // Could be reproduced with iOS 14.4, but not anymore with iOS 14.5. 4 | // When starting the app with this text multiple times then sometimes the animation doesn't apply 5 | // and the pushed screen is not visible even when the nav stack is correclty set after the called push. 6 | // It seems this happens because of `isAlternativeViewShowingPrecede` is set to true in `NavigationStackModel` 7 | // and immediately after that `isAlternativeViewShowing` is also set to true within a `withAnimation` block. 8 | // When wrapping the last assignment inclusive the `withAnimation` block into a `DispatchQueue.main.asyncAfter(deadline: .now())` seems to solve this issue. 9 | // It seems with iOS 14.5 it's also solved, therefore, the applied changes for this were reverted. 10 | 11 | import NavigationStack 12 | import SwiftUI 13 | 14 | struct Experiment10: View { 15 | var body: some View { 16 | ContentView() 17 | .environmentObject(NavigationModel()) 18 | } 19 | } 20 | 21 | private struct SecondScreen: View { 22 | @EnvironmentObject var navigationModel: NavigationModel 23 | var body: some View { 24 | ZStack { 25 | Color.blue 26 | VStack { 27 | Spacer() 28 | HStack { 29 | Spacer() 30 | VStack { 31 | Text("Screen 2") 32 | .foregroundColor(.white) 33 | Button(action: { 34 | print("NavStack: \(navigationModel)") 35 | }, label: { 36 | Text("Print nav stack") 37 | .foregroundColor(.white) 38 | }) 39 | } 40 | Spacer() 41 | } 42 | Spacer() 43 | } 44 | } 45 | } 46 | } 47 | 48 | private struct ContentView: View { 49 | static let id = String(describing: Self.self) 50 | @EnvironmentObject var navigationModel: NavigationModel 51 | 52 | var body: some View { 53 | NavigationStackView(ContentView.id) { 54 | ZStack { 55 | Color.red 56 | VStack { 57 | Spacer() 58 | HStack { 59 | Spacer() 60 | VStack { 61 | Text("Screen 1") 62 | Button(action: { 63 | print("NavStack: \(navigationModel)") 64 | }, label: { 65 | Text("Print nav stack") 66 | }) 67 | } 68 | Spacer() 69 | } 70 | Spacer() 71 | } 72 | }.edgesIgnoringSafeArea(.all) 73 | } 74 | .onAppear { 75 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { // It seems with a higher delay the issue happens more often 76 | print("Push executed") 77 | self.navigationModel.showView(ContentView.id, animation: .push) { // When applying nil as animation this issue never happens 78 | SecondScreen() 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment8.swift: -------------------------------------------------------------------------------- 1 | // This experiment tries like Experiment3 to save the transition into a property rather than in a state. 2 | // This works when using a pre-update step for switching the content. 3 | import NavigationStack 4 | import SwiftUI 5 | 6 | struct Experiment8: View { 7 | @State var animationIndex = 0 8 | @State var transitionIndex = 0 9 | @State var optionIndex = 0 10 | 11 | @State var showAlternativeContentPreStep = false 12 | @State var showAlternativeContent = false 13 | 14 | @State var animation = Animation.linear 15 | @State var defaultEdge = Edge.leading 16 | @State var alternativeEdge = Edge.trailing 17 | @State var defaultTransition = AnyTransition.identity 18 | @State var alternativeTransition = AnyTransition.identity 19 | 20 | var body: some View { 21 | VStack(spacing: 20) { 22 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 23 | 24 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 25 | switch animationIndex { 26 | case 0: 27 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 28 | case 1: 29 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 30 | default: 31 | break 32 | } 33 | 34 | switch optionIndex { 35 | case 0: 36 | defaultEdge = .leading 37 | alternativeEdge = .trailing 38 | case 1: 39 | defaultEdge = .top 40 | alternativeEdge = .bottom 41 | default: 42 | break 43 | } 44 | 45 | switch transitionIndex { 46 | case 0: 47 | defaultTransition = .move(edge: defaultEdge) 48 | alternativeTransition = .move(edge: alternativeEdge) 49 | case 1: 50 | defaultTransition = .scale(scale: CGFloat(optionIndex * 2)) 51 | alternativeTransition = .scale(scale: CGFloat(optionIndex * 2)) 52 | default: 53 | break 54 | } 55 | 56 | showAlternativeContentPreStep.toggle() // First update the view with the new transition, but without animation. 57 | withAnimation(animation) { 58 | showAlternativeContent.toggle() // Then update the view with the previously applied transition and with animation. 59 | } 60 | } 61 | 62 | if showAlternativeContentPreStep { // This pre-step solves the animation glitch. 63 | if !showAlternativeContent { 64 | DefaultContent().transition(defaultTransition) 65 | } 66 | if showAlternativeContent { 67 | AlternativeContent().transition(alternativeTransition) 68 | } 69 | } else { 70 | if !showAlternativeContent { 71 | DefaultContent().transition(defaultTransition) 72 | } 73 | if showAlternativeContent { 74 | AlternativeContent().transition(alternativeTransition) 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import NavigationStack 2 | import SwiftUI 3 | import UIKit 4 | 5 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 6 | var window: UIWindow? 7 | 8 | let argumentViewMapping = [ 9 | "Experiment0": AnyView(Experiment0()), 10 | "Experiment1": AnyView(Experiment1()), 11 | "Experiment2": AnyView(Experiment2()), 12 | "Experiment3": AnyView(Experiment3()), 13 | "Experiment4": AnyView(Experiment4()), 14 | "Experiment5": AnyView(Experiment5()), 15 | "Experiment6": AnyView(Experiment6()), 16 | "Experiment7": AnyView(Experiment7()), 17 | "Experiment8": AnyView(Experiment8()), 18 | "Experiment9": AnyView(Experiment9()), 19 | "Experiment10": AnyView(Experiment10()), 20 | "Experiment11": AnyView(Experiment11()) 21 | ] 22 | 23 | func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { 24 | var contentView: AnyView? 25 | if CommandLine.arguments.count >= 2 { 26 | contentView = argumentViewMapping[CommandLine.arguments[1]] 27 | } 28 | if contentView == nil { 29 | contentView = AnyView( 30 | ContentView1() 31 | .environmentObject(NavigationModel(silenceErrors: true)) 32 | ) 33 | } 34 | 35 | if let windowScene = scene as? UIWindowScene { 36 | let window = UIWindow(windowScene: windowScene) 37 | window.rootViewController = UIHostingController(rootView: contentView) 38 | self.window = window 39 | window.makeKeyAndVisible() 40 | } 41 | } 42 | 43 | func sceneDidDisconnect(_: UIScene) { 44 | // Called as the scene is being released by the system. 45 | // This occurs shortly after the scene enters the background, or when its session is discarded. 46 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 47 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 48 | } 49 | 50 | func sceneDidBecomeActive(_: UIScene) { 51 | // Called when the scene has moved from an inactive state to an active state. 52 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 53 | } 54 | 55 | func sceneWillResignActive(_: UIScene) { 56 | // Called when the scene will move from an active state to an inactive state. 57 | // This may occur due to temporary interruptions (ex. an incoming phone call). 58 | } 59 | 60 | func sceneWillEnterForeground(_: UIScene) { 61 | // Called as the scene transitions from the background to the foreground. 62 | // Use this method to undo the changes made on entering the background. 63 | } 64 | 65 | func sceneDidEnterBackground(_: UIScene) { 66 | // Called as the scene transitions from the foreground to the background. 67 | // Use this method to save data, release shared resources, and store enough scene-specific state information 68 | // to restore the scene back to its current state. 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationModelTests/NavigationModelStackViewCallTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationModelStackViewCallTests: XCTestCase { 6 | var model: NavigationModel! 7 | 8 | override func setUp() { 9 | model = NavigationModel(silenceErrors: true) 10 | } 11 | 12 | // MARK: - Tests 13 | 14 | func testIsAlternativeViewShowingPrecedeFalse() throws { 15 | let result = model.isAlternativeViewShowingPrecede("Foo") 16 | 17 | XCTAssertFalse(result) 18 | } 19 | 20 | func testIsAlternativeViewShowingPrecede() throws { 21 | model.showView("Foo") { EmptyView() } 22 | 23 | let result = model.isAlternativeViewShowingPrecede("Foo") 24 | 25 | XCTAssertTrue(result) 26 | } 27 | 28 | func testAlternativeViewFalse() throws { 29 | let result = model.alternativeView("Foo") 30 | 31 | XCTAssertNil(result) 32 | } 33 | 34 | func testAlternativeView() throws { 35 | model.showView("Foo") { EmptyView() } 36 | 37 | let result = model.alternativeView("Foo") 38 | 39 | XCTAssertNotNil(result) 40 | } 41 | 42 | func testDefaultViewTransitionFalse() throws { 43 | let result = model.defaultViewTransition("Foo") 44 | 45 | XCTAssertNotNil(result) 46 | } 47 | 48 | func testDefaultViewTransition() throws { 49 | model.showView("Foo") { EmptyView() } 50 | 51 | let result = model.defaultViewTransition("Foo") 52 | 53 | XCTAssertNotNil(result) 54 | } 55 | 56 | func testAlternativeViewTransitionFalse() throws { 57 | let result = model.alternativeViewTransition("Foo") 58 | 59 | XCTAssertNotNil(result) 60 | } 61 | 62 | func testAlternativeViewTransition() throws { 63 | model.showView("Foo") { EmptyView() } 64 | 65 | let result = model.alternativeViewTransition("Foo") 66 | 67 | XCTAssertNotNil(result) 68 | } 69 | 70 | func testDefaultViewZIndexFalse() throws { 71 | let result = model.defaultViewZIndex("Foo") 72 | 73 | XCTAssertEqual(.zero, result) 74 | } 75 | 76 | func testDefaultViewZIndex() throws { 77 | model.showView( 78 | "Foo", 79 | animation: NavigationAnimation( 80 | animation: .default, 81 | defaultViewTransition: .slide, 82 | alternativeViewTransition: .opacity, 83 | defaultViewZIndex: 22, 84 | alternativeViewZIndex: 55 85 | ) 86 | ) { EmptyView() } 87 | 88 | let result = model.defaultViewZIndex("Foo") 89 | 90 | XCTAssertEqual(22, result) 91 | } 92 | 93 | func testAlternativeViewZIndexFalse() throws { 94 | let result = model.alternativeViewZIndex("Foo") 95 | 96 | XCTAssertEqual(.zero, result) 97 | } 98 | 99 | func testAlternativeViewZIndex() throws { 100 | model.showView( 101 | "Foo", 102 | animation: NavigationAnimation( 103 | animation: .default, 104 | defaultViewTransition: .slide, 105 | alternativeViewTransition: .opacity, 106 | defaultViewZIndex: 22, 107 | alternativeViewZIndex: 55 108 | ) 109 | ) { EmptyView() } 110 | 111 | let result = model.alternativeViewZIndex("Foo") 112 | 113 | XCTAssertEqual(55, result) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/NavigationStack/ViewLifecycle/OnAnimationCompleted.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension View { 4 | /** 5 | Calls the completion handler whenever an animation on the given value completes. 6 | For this the value has to be changed within a `withAnimation` block otherwise the completion block will not be called. 7 | 8 | - parameter value: The value to observe for animations. Must be a `VectorArithmetic` type, e.g. `Double` or `Float`. 9 | - parameter completion: The completion callback to call once the animation completes. 10 | - parameter value: The current value when the completion block is called. 11 | - returns: A modified `View` instance with the observer attached. 12 | */ 13 | func onAnimationCompleted(for value: Value, completion: @escaping (_ value: Value) -> Void) -> some View { 14 | modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion)) 15 | } 16 | } 17 | 18 | /** 19 | An animatable modifier that is used for observing animations for a given animatable value. 20 | 21 | Source: [https://www.avanderlee.com/swiftui/withanimation-completion-callback](https://www.avanderlee.com/swiftui/withanimation-completion-callback) 22 | */ 23 | private struct AnimationCompletionObserverModifier: AnimatableModifier where Value: VectorArithmetic { 24 | /// While animating, SwiftUI changes the old input value to the new target value using this property. 25 | /// This value is set to the old value until the animation completes. 26 | /// didSet is only called when the data is changed within a `withAnimation` block and then it is called multiple times throughout the animation. 27 | var animatableData: Value { 28 | didSet { 29 | notifyCompletionIfFinished() 30 | } 31 | } 32 | 33 | /// The target value for which we're observing. 34 | /// This value is directly set once the animation starts. 35 | /// During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes. 36 | private let targetValue: Value 37 | 38 | /// The completion callback which is called once the animation completes. 39 | private let completion: (_ value: Value) -> Void 40 | 41 | init(observedValue: Value, completion: @escaping (_ value: Value) -> Void) { 42 | self.completion = completion 43 | targetValue = observedValue 44 | animatableData = observedValue // Doesn't trigger didSet except the value is changed within an animation. 45 | } 46 | 47 | func body(content: Content) -> some View { 48 | // We're not really modifying the view so we can directly return the original input value. 49 | content 50 | } 51 | 52 | /// Verifies whether the current animation is finished and calls the completion callback if true. 53 | private func notifyCompletionIfFinished() { 54 | guard animatableData == targetValue else { return } 55 | 56 | // Dispatching is needed to take the next runloop for the completion callback. 57 | // This prevents errors like "Modifying state during view update, this will cause undefined behavior." 58 | DispatchQueue.main.async { 59 | self.completion(animatableData) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/NavigationStackExampleUITests/ExperimentTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class ExperimentTests: XCTestCase { 4 | private var app: XCUIApplication! 5 | 6 | override func setUpWithError() throws { 7 | continueAfterFailure = false 8 | app = XCUIApplication() 9 | } 10 | 11 | private func launchExperiment(_ number: Int) { 12 | app.launchArguments = ["Experiment\(number)"] 13 | app.launch() 14 | 15 | // Push alternative content 16 | tapToggleContent() 17 | // Pop back to default 18 | tapToggleContent() 19 | 20 | // Switch transition to Scale 21 | tapPicker(1, segment: 1) 22 | 23 | // Show alternative content with a scale transition 24 | tapToggleContent() 25 | 26 | // Switch transition back to Move 27 | tapPicker(1, segment: 0) 28 | 29 | // Transition back to the default content 30 | tapToggleContent() 31 | } 32 | 33 | private func tapToggleContent() { 34 | app.buttons["ToggleContentButton"].tap() 35 | sleep(1) 36 | } 37 | 38 | private func tapPicker(_ index: Int, segment: Int) { 39 | let picker = app.segmentedControls["Picker_\(index)"] 40 | let button = picker.buttons.element(boundBy: segment) 41 | button.tap() 42 | sleep(1) 43 | } 44 | 45 | // MARK: - Tests 46 | 47 | // This experiment includes knowledge from experiment 2 to 5 to get the transition animation working as expected. 48 | func testExperiment1() throws { 49 | launchExperiment(1) 50 | } 51 | 52 | // This experiment tries to use an if-else branch to switch the default and alternative content view, but that doesn't work. 53 | func testExperiment2() throws { 54 | launchExperiment(2) 55 | } 56 | 57 | // This experiment tries to save the transition into a state property, but that doesn't work. 58 | func testExperiment3() throws { 59 | launchExperiment(3) 60 | } 61 | 62 | // This experiment uses the ternary operator instead of an if-else branch to return different views with the different transitions applied, 63 | // but that doesn't work. 64 | func testExperiment4() throws { 65 | launchExperiment(4) 66 | } 67 | 68 | // This experiment uses the same set up as Experiment4, but instead of using the ternary operator to return back two different views 69 | // here we use it to just return the different transitions, but that doesn't work either. 70 | func testExperiment5() throws { 71 | launchExperiment(5) 72 | } 73 | 74 | // This experiment is the same as Experiment1, but uses a switch statement instead of if-else branches. 75 | func testExperiment6() throws { 76 | launchExperiment(6) 77 | } 78 | 79 | // This experiment works like Experiment1, but shows a glitch when accessing the showAlternativeContent state inside of a sub-view. 80 | func testExperiment7() throws { 81 | launchExperiment(7) 82 | } 83 | 84 | // This experiment tries like Experiment3 to save the transition into a property rather than in a state. 85 | // This works when using a pre-update step for switching the content. 86 | func testExperiment8() throws { 87 | launchExperiment(8) 88 | } 89 | 90 | // This experiment builds on top of Experiment8, but solves the ordering of the overlapping content views. 91 | func testExperiment9() throws { 92 | launchExperiment(9) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Core/NavigationStackNode.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | /** 5 | A node usable to construct a navigation hierarchy via a linked list. 6 | 7 | This is used by the `NavigationModel` to hold each navigation stack view's navigation state. 8 | The node holds the navigation state's data and propagates any changes to the model. 9 | Each node belongs to a navigation stack view, but a node only exists when that view also has a navigation applied. 10 | */ 11 | class NavigationStackNode: ObservableObject where IdentifierType: Equatable { 12 | /** 13 | Initializes the node. 14 | 15 | - parameter identifier: The representing navigation stack view's ID. 16 | - parameter alternativeView: The content to show when this node's navigation is active, meaning `isAlternativeViewShowing` is true. 17 | */ 18 | init(identifier: IdentifierType, alternativeView: @escaping AnyViewBuilder) { 19 | identifer = identifier 20 | self.alternativeView = alternativeView 21 | } 22 | 23 | /// The navigation stack view's ID which this node represents. 24 | let identifer: IdentifierType 25 | /// The content view which should be shown when the navigation is active. 26 | let alternativeView: AnyViewBuilder 27 | 28 | /// True whether the navigation has been applied and the alternative view is visible, otherwise false. 29 | @Published var isAlternativeViewShowing = false 30 | /// The same as `isAlternativeViewShowing`, but as a precede value. See Experiment8. 31 | @Published var isAlternativeViewShowingPrecede = false 32 | /// The transition animation to apply. 33 | @Published var transitionAnimation: NavigationAnimation? 34 | 35 | /// Keeps track of the current transition animation progress. 36 | /// Animated to determine when an animation has completed. 37 | @Published var transitionProgress: Float = .progressToDefaultView 38 | 39 | /// The next navigation stack view's node in the hierarchy. 40 | @Published var nextNode: NavigationStackNode? { 41 | didSet { 42 | // Propagates any published state changes from sub-nodes to observers of this node. 43 | nextNodeChangeCanceller = nextNode?.objectWillChange.sink(receiveValue: { [weak self] _ in 44 | self?.objectWillChange.send() 45 | }) 46 | } 47 | } 48 | 49 | /// Combine's sink bag for `nexNode`. 50 | private var nextNodeChangeCanceller: AnyCancellable? 51 | 52 | /** 53 | Retrieves recursively the node in the hiarachy with a given ID. 54 | 55 | - parameter identifier: The node's ID which to retrieve. 56 | - returns: The first node in the linked list with the given ID. 57 | */ 58 | func getNode(_ identifier: IdentifierType) -> NavigationStackNode? { 59 | identifer == identifier ? self : nextNode?.getNode(identifier) 60 | } 61 | 62 | /** 63 | Returns the last node of the linked list which is actively showing a navigation. 64 | 65 | - returns: The last active node. 66 | */ 67 | func getLeafNode() -> NavigationStackNode? { 68 | if !isAlternativeViewShowing { 69 | return nil 70 | } 71 | return nextNode?.getLeafNode() ?? self 72 | } 73 | } 74 | 75 | // MARK: - Debugging 76 | 77 | extension NavigationStackNode: CustomDebugStringConvertible { 78 | var debugDescription: String { 79 | var description = "'\(identifer)'" 80 | if let nextNode = nextNode { 81 | description += "|\(nextNode)" 82 | } 83 | return description 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/ExampleViews/ContentView3.swift: -------------------------------------------------------------------------------- 1 | import NavigationStack 2 | import SwiftUI 3 | 4 | struct ContentView3: View { 5 | static let id = String(describing: Self.self) 6 | 7 | @EnvironmentObject private var navigationModel: NavigationModel 8 | 9 | // Freezes the state of `navigationModel.isAlternativeViewShowing("ContentView2")` to prevent transition animation glitches. 10 | let isView2Showing: Bool 11 | 12 | var body: some View { 13 | NavigationStackView(ContentView3.id) { 14 | HStack { 15 | VStack(alignment: .leading, spacing: 20) { 16 | Text(ContentView3.id) 17 | 18 | // It's safe to query the `hasAlternativeViewShowing` state from the model, because it will be frozen by the button view. 19 | // However, to be safe we could also just pass `true` because View3 is not the root view. 20 | DismissTopContentButton(hasAlternativeViewShowing: navigationModel.hasAlternativeViewShowing) 21 | 22 | Group { 23 | Button(action: { 24 | // Example of the shortcut pop transition, which is a move transition. 25 | navigationModel.popContent(ContentView1.id) 26 | }, label: { 27 | Text("Pop to root (View 1)") 28 | }) 29 | .accessibility(identifier: "PopToRoot") 30 | 31 | // Using isAlternativeViewShowing from the model to show different sub-views will lead to animation glitches, 32 | // therefore use the frozen `isView2Showing` value. 33 | // if navigationModel.isAlternativeViewShowing("ContentView2") { 34 | if isView2Showing { 35 | Button(action: { 36 | navigationModel.popContent(ContentView2.id) 37 | }, label: { 38 | Text("Pop to View 2 (w/ animation)") 39 | }) 40 | .accessibility(identifier: "PopToView2Animated") 41 | } 42 | 43 | Button(action: { 44 | // Example of a simple hide transition without animation. 45 | navigationModel.hideView(ContentView2.id) 46 | // When no animation has to be played then `onDidAppear` will not be executed. 47 | // This is not necessary because with no animation the follow-up logic in `onDidAppear` can be instead executed right here. 48 | }, label: { 49 | // Using isAlternativeViewShowing from the model to show different sub-views will lead to animation glitches, 50 | // therefore use the frozen `isView2Showing` value. 51 | // if navigationModel.isAlternativeViewShowing("ContentView2") { 52 | if isView2Showing { 53 | Text("Pop to View 2 (w/o animation)") 54 | } else { 55 | Text("Pop to View 2 (not available)") 56 | } 57 | }) 58 | .accessibility(identifier: "PopToView2NoAnimation") 59 | 60 | Button(action: { 61 | navigationModel.presentContent(ContentView3.id) { 62 | ContentView4(isPresented: navigationModel.viewShowingBinding(ContentView3.id)) 63 | } 64 | }, label: { 65 | Text("Present View 4") 66 | }) 67 | .accessibility(identifier: "PresentView4") 68 | } 69 | 70 | Spacer() 71 | } 72 | Spacer() 73 | } 74 | .padding() 75 | .background(Color.orange.opacity(0.3)) 76 | .onDidAppear { 77 | print("\(ContentView3.id) did appear") 78 | } 79 | } 80 | } 81 | } 82 | 83 | struct ContentView3_Previews: PreviewProvider { 84 | static var previews: some View { 85 | ContentView3(isView2Showing: true) 86 | .environmentObject(NavigationModel()) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.3) 5 | activesupport (5.2.5) 6 | concurrent-ruby (~> 1.0, >= 1.0.2) 7 | i18n (>= 0.7, < 2) 8 | minitest (~> 5.1) 9 | tzinfo (~> 1.1) 10 | addressable (2.7.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | algoliasearch (1.27.5) 13 | httpclient (~> 2.8, >= 2.8.3) 14 | json (>= 1.5.1) 15 | atomos (0.1.3) 16 | claide (1.0.3) 17 | clamp (1.3.2) 18 | cocoapods (1.10.1) 19 | addressable (~> 2.6) 20 | claide (>= 1.0.2, < 2.0) 21 | cocoapods-core (= 1.10.1) 22 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 23 | cocoapods-downloader (>= 1.4.0, < 2.0) 24 | cocoapods-plugins (>= 1.0.0, < 2.0) 25 | cocoapods-search (>= 1.0.0, < 2.0) 26 | cocoapods-trunk (>= 1.4.0, < 2.0) 27 | cocoapods-try (>= 1.1.0, < 2.0) 28 | colored2 (~> 3.1) 29 | escape (~> 0.0.4) 30 | fourflusher (>= 2.3.0, < 3.0) 31 | gh_inspector (~> 1.0) 32 | molinillo (~> 0.6.6) 33 | nap (~> 1.0) 34 | ruby-macho (~> 1.4) 35 | xcodeproj (>= 1.19.0, < 2.0) 36 | cocoapods-core (1.10.1) 37 | activesupport (> 5.0, < 6) 38 | addressable (~> 2.6) 39 | algoliasearch (~> 1.0) 40 | concurrent-ruby (~> 1.1) 41 | fuzzy_match (~> 2.0.4) 42 | nap (~> 1.0) 43 | netrc (~> 0.11) 44 | public_suffix 45 | typhoeus (~> 1.0) 46 | cocoapods-deintegrate (1.0.4) 47 | cocoapods-downloader (1.4.0) 48 | cocoapods-plugins (1.0.0) 49 | nap 50 | cocoapods-search (1.0.0) 51 | cocoapods-trunk (1.5.0) 52 | nap (>= 0.8, < 2.0) 53 | netrc (~> 0.11) 54 | cocoapods-try (1.2.0) 55 | colored2 (3.1.2) 56 | concurrent-ruby (1.1.8) 57 | escape (0.0.4) 58 | ethon (0.14.0) 59 | ffi (>= 1.15.0) 60 | ffi (1.15.0) 61 | fourflusher (2.3.1) 62 | fuzzy_match (2.0.4) 63 | gh_inspector (1.1.3) 64 | httpclient (2.8.3) 65 | i18n (1.8.10) 66 | concurrent-ruby (~> 1.0) 67 | jazzy (0.13.6) 68 | cocoapods (~> 1.5) 69 | mustache (~> 1.1) 70 | open4 71 | redcarpet (~> 3.4) 72 | rouge (>= 2.0.6, < 4.0) 73 | sassc (~> 2.1) 74 | sqlite3 (~> 1.3) 75 | xcinvoke (~> 0.3.0) 76 | json (2.5.1) 77 | liferaft (0.0.6) 78 | mini_portile2 (2.5.1) 79 | minitest (5.14.4) 80 | molinillo (0.6.6) 81 | mustache (1.1.1) 82 | nanaimo (0.3.0) 83 | nap (1.1.0) 84 | netrc (0.11.0) 85 | nokogiri (1.11.3) 86 | mini_portile2 (~> 2.5.0) 87 | racc (~> 1.4) 88 | open4 (1.3.4) 89 | public_suffix (4.0.6) 90 | racc (1.5.2) 91 | redcarpet (3.5.1) 92 | rouge (3.26.0) 93 | ruby-macho (1.4.0) 94 | sassc (2.4.0) 95 | ffi (~> 1.9) 96 | slather (2.7.1) 97 | CFPropertyList (>= 2.2, < 4) 98 | activesupport 99 | clamp (~> 1.3) 100 | nokogiri (~> 1.11) 101 | xcodeproj (~> 1.7) 102 | sqlite3 (1.4.2) 103 | thread_safe (0.3.6) 104 | typhoeus (1.4.0) 105 | ethon (>= 0.9.0) 106 | tzinfo (1.2.9) 107 | thread_safe (~> 0.1) 108 | xcinvoke (0.3.0) 109 | liferaft (~> 0.0.6) 110 | xcodeproj (1.19.0) 111 | CFPropertyList (>= 2.3.3, < 4.0) 112 | atomos (~> 0.1.3) 113 | claide (>= 1.0.2, < 2.0) 114 | colored2 (~> 3.1) 115 | nanaimo (~> 0.3.0) 116 | 117 | PLATFORMS 118 | ruby 119 | 120 | DEPENDENCIES 121 | jazzy 122 | slather 123 | 124 | BUNDLED WITH 125 | 2.2.8 126 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Transitions/StripesShape.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension AnyTransition { 4 | /** 5 | A custom transition using horizontal or vertical stripes to blend over. 6 | 7 | - parameter stripes: The number of stripes the view should be sliced into. 8 | - parameter horizontal: Set to true to lay the stripes out horizontally, false for vertically. 9 | */ 10 | static func stripes(stripes: Int, horizontal: Bool, inverted: Bool = false) -> AnyTransition { 11 | AnyTransition.asymmetric( 12 | insertion: AnyTransition.modifier( 13 | active: ClipShapeModifier( 14 | shape: StripesShape(insertion: true, stripes: stripes, horizontal: horizontal, inverted: inverted, animatableData: 1), 15 | style: FillStyle() 16 | ), 17 | identity: ClipShapeModifier( 18 | shape: StripesShape(insertion: true, stripes: stripes, horizontal: horizontal, inverted: inverted, animatableData: 0), 19 | style: FillStyle() 20 | ) 21 | ), 22 | removal: AnyTransition.modifier( 23 | active: ClipShapeModifier( 24 | shape: StripesShape(insertion: false, stripes: stripes, horizontal: horizontal, inverted: inverted, animatableData: 1), 25 | style: FillStyle() 26 | ), 27 | identity: ClipShapeModifier( 28 | shape: StripesShape(insertion: false, stripes: stripes, horizontal: horizontal, inverted: inverted, animatableData: 0), 29 | style: FillStyle() 30 | ) 31 | ) 32 | ) 33 | } 34 | } 35 | 36 | /** 37 | A slicing pattern consisting of multiple rectangle shapes. 38 | 39 | Source inspired by [SwiftUI-Lab](https://swiftui-lab.com/advanced-transitions) 40 | */ 41 | public struct StripesShape: Shape { 42 | /// When true the animation will enlarge the view, when false the animation will shrink the view. 43 | public let insertion: Bool 44 | /// The number of stripes to use. 45 | public let stripes: Int 46 | /// When true then the stripes will be layed horizontally, otherwise vertically. 47 | public let horizontal: Bool 48 | /// When false then the horizontal animation is intended to the bottom, when true then to the top. 49 | /// When false then the vertical animation is intended to the right, when true then to the left. 50 | public let inverted: Bool 51 | 52 | public var animatableData: CGFloat 53 | 54 | public func path(in rect: CGRect) -> Path { 55 | var path = Path() 56 | let inversionModifier: CGFloat = inverted ? -1.0 : 1.0 57 | 58 | if horizontal { 59 | let stripeHeight = rect.height / CGFloat(stripes) 60 | 61 | for index in 0 ... stripes { 62 | let position = CGFloat(index) 63 | 64 | if insertion { 65 | path.addRect(CGRect(x: 0, y: position * stripeHeight, width: rect.width, height: inversionModifier * stripeHeight * (1 - animatableData))) 66 | } else { 67 | path.addRect(CGRect( 68 | x: 0, 69 | y: position * stripeHeight + (stripeHeight * animatableData), 70 | width: rect.width, 71 | height: inversionModifier * stripeHeight * (1 - animatableData) 72 | )) 73 | } 74 | } 75 | } else { 76 | let stripeWidth = rect.width / CGFloat(stripes) 77 | 78 | for index in 0 ... stripes { 79 | let position = CGFloat(index) 80 | 81 | if insertion { 82 | path.addRect(CGRect(x: position * stripeWidth, y: 0, width: inversionModifier * stripeWidth * (1 - animatableData), height: rect.height)) 83 | } else { 84 | path.addRect(CGRect( 85 | x: position * stripeWidth + (stripeWidth * animatableData), 86 | y: 0, 87 | width: inversionModifier * stripeWidth * (1 - animatableData), 88 | height: rect.height 89 | )) 90 | } 91 | } 92 | } 93 | 94 | return path 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment7.swift: -------------------------------------------------------------------------------- 1 | // This experiment works like Experiment1, but shows a glitch when accessing the showAlternativeContent state inside of a sub-view. 2 | import NavigationStack 3 | import SwiftUI 4 | 5 | struct Experiment7: View { 6 | @State var animationIndex = 0 7 | @State var transitionIndex = 0 8 | @State var optionIndex = 0 9 | 10 | @State var showAlternativeContent = false 11 | 12 | @State var animation = Animation.linear 13 | @State var defaultEdge = Edge.leading 14 | @State var alternativeEdge = Edge.trailing 15 | 16 | var body: some View { 17 | VStack(spacing: 20) { 18 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 19 | 20 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 21 | // Set the animation and the transition before the withAnimation block to update it before visualising it 22 | // and to decouple these states from the picker variables. 23 | switch animationIndex { 24 | case 0: 25 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 26 | case 1: 27 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 28 | default: 29 | break 30 | } 31 | 32 | switch optionIndex { 33 | case 0: 34 | defaultEdge = .leading 35 | alternativeEdge = .trailing 36 | case 1: 37 | defaultEdge = .top 38 | alternativeEdge = .bottom 39 | default: 40 | break 41 | } 42 | 43 | withAnimation(animation) { 44 | showAlternativeContent.toggle() 45 | } 46 | } 47 | 48 | if !showAlternativeContent { 49 | if transitionIndex == 0 { 50 | DefaultContent2(alternativeContentShowing: $showAlternativeContent).transition(.move(edge: defaultEdge)) 51 | } else { 52 | DefaultContent2(alternativeContentShowing: $showAlternativeContent).transition(.scale(scale: CGFloat(optionIndex * 2))) 53 | } 54 | } 55 | if showAlternativeContent { 56 | if transitionIndex == 0 { 57 | AlternativeContent2(alternativeContentShowing: $showAlternativeContent).transition(.move(edge: alternativeEdge)) 58 | } else { 59 | AlternativeContent2(alternativeContentShowing: $showAlternativeContent).transition(.scale(scale: CGFloat(optionIndex * 2))) 60 | } 61 | } 62 | } 63 | } 64 | 65 | struct DefaultContent2: View { 66 | // let alternativeContentShowing: Bool 67 | // This binding leads to animation glitches, use a non-binding instead (uncomment the line above) to solve this. 68 | @Binding var alternativeContentShowing: Bool 69 | var body: some View { 70 | ZStack { 71 | ContentText(alternativeContentShowing: alternativeContentShowing) 72 | Color(.green) 73 | .opacity(0.5) 74 | } 75 | } 76 | } 77 | 78 | struct AlternativeContent2: View { 79 | // let alternativeContentShowing: Bool 80 | // This binding leads to animation glitches, use a non-binding instead (uncomment the line above) to solve this. 81 | @Binding var alternativeContentShowing: Bool 82 | var body: some View { 83 | ZStack { 84 | ContentText(alternativeContentShowing: alternativeContentShowing) 85 | Color(.orange) 86 | .opacity(0.5) 87 | } 88 | } 89 | } 90 | 91 | struct ContentText: View { 92 | let alternativeContentShowing: Bool 93 | var body: some View { 94 | if !alternativeContentShowing { 95 | Text("Default Content (Green)") 96 | } else { 97 | Text("Alternative Content (Orange)") 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/ExampleViews/OnDidAppearExample.swift: -------------------------------------------------------------------------------- 1 | import NavigationStack 2 | import SwiftUI 3 | 4 | struct OnDidAppearExample: View { 5 | static let id = String(describing: Self.self) 6 | 7 | @EnvironmentObject private var navigationModel: NavigationModel 8 | 9 | @State private var view2State: ViewState = .none 10 | 11 | var body: some View { 12 | NavigationStackView(OnDidAppearExample.id) { 13 | HStack { 14 | VStack(alignment: .leading, spacing: 20) { 15 | HStack { 16 | // General back button. 17 | Button(action: { 18 | navigationModel.hideTopViewWithReverseAnimation() 19 | }, label: { 20 | Text("Back") 21 | }) 22 | .accessibility(identifier: "BackButton") 23 | Text(OnDidAppearExample.id) 24 | } 25 | 26 | // Button to push view2. 27 | Button(action: { 28 | // View2 should be showsn thus it is appearing. 29 | view2State = .appearing 30 | navigationModel.showView( 31 | OnDidAppearExample.id, 32 | animation: NavigationAnimation( 33 | animation: .easeInOut(duration: 3), 34 | defaultViewTransition: .move(edge: .leading), 35 | alternativeViewTransition: .move(edge: .trailing) 36 | ) 37 | ) { 38 | OnDidAppearDestinationView(view2State: $view2State) 39 | } 40 | }, label: { 41 | Text("Push Destination View") 42 | }) 43 | .accessibility(identifier: "PushView2") 44 | 45 | // Displays the current state of view2. 46 | Text("View2 state: \(String(describing: view2State))") 47 | 48 | Spacer() 49 | } 50 | Spacer() 51 | } 52 | .padding() 53 | .background(Color.green) 54 | .onDidAppear { 55 | // View1 has appeared which means the other view has disappeared. 56 | view2State = .none 57 | print("\(OnDidAppearExample.id) did appear") 58 | } 59 | } 60 | } 61 | } 62 | 63 | private struct OnDidAppearDestinationView: View { 64 | static let id = String(describing: Self.self) 65 | 66 | @EnvironmentObject private var navigationModel: NavigationModel 67 | 68 | @Binding var view2State: ViewState 69 | 70 | var body: some View { 71 | HStack { 72 | VStack(alignment: .leading, spacing: 20) { 73 | Text(OnDidAppearDestinationView.id) 74 | 75 | // Button to go back to view1. 76 | Button(action: { 77 | // Pop back which means view2 is about to disappear. 78 | view2State = .disappearing 79 | navigationModel.hideView( 80 | OnDidAppearExample.id, 81 | animation: NavigationAnimation( 82 | animation: .easeInOut(duration: 3), 83 | defaultViewTransition: .move(edge: .leading), 84 | alternativeViewTransition: .move(edge: .trailing) 85 | ) 86 | ) 87 | }, label: { 88 | Text("Pop back") 89 | }) 90 | .accessibility(identifier: "PopView2") 91 | 92 | // Displays the current state of view2. 93 | Text("View2 state: \(String(describing: view2State))") 94 | 95 | Spacer() 96 | } 97 | Spacer() 98 | } 99 | .padding() 100 | .background(Color.orange) 101 | .onDidAppear { 102 | // View2 has appeared and is now present. 103 | view2State = .present 104 | print("\(OnDidAppearDestinationView.id) did appear") 105 | } 106 | } 107 | } 108 | 109 | private enum ViewState { 110 | case none 111 | case appearing 112 | case present 113 | case disappearing 114 | } 115 | 116 | struct OnDidAppearExample_Previews: PreviewProvider { 117 | static var previews: some View { 118 | OnDidAppearExample() 119 | .environmentObject(NavigationModel()) 120 | OnDidAppearDestinationView(view2State: .constant(.none)) 121 | .environmentObject(NavigationModel()) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStack/Pods-NavigationStack-acknowledgements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | This application makes use of the following third party libraries: 10 | Title 11 | Acknowledgements 12 | Type 13 | PSGroupSpecifier 14 | 15 | 16 | FooterText 17 | MIT License 18 | 19 | Copyright (c) 2016 Nick Lockwood 20 | 21 | Permission is hereby granted, free of charge, to any person obtaining a copy 22 | of this software and associated documentation files (the "Software"), to deal 23 | in the Software without restriction, including without limitation the rights 24 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 25 | copies of the Software, and to permit persons to whom the Software is 26 | furnished to do so, subject to the following conditions: 27 | 28 | The above copyright notice and this permission notice shall be included in all 29 | copies or substantial portions of the Software. 30 | 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 32 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 33 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 34 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 35 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 36 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 37 | SOFTWARE. 38 | 39 | License 40 | MIT 41 | Title 42 | SwiftFormat 43 | Type 44 | PSGroupSpecifier 45 | 46 | 47 | FooterText 48 | The MIT License (MIT) 49 | 50 | Copyright (c) 2020 Realm Inc. 51 | 52 | Permission is hereby granted, free of charge, to any person obtaining a copy 53 | of this software and associated documentation files (the "Software"), to deal 54 | in the Software without restriction, including without limitation the rights 55 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 56 | copies of the Software, and to permit persons to whom the Software is 57 | furnished to do so, subject to the following conditions: 58 | 59 | The above copyright notice and this permission notice shall be included in all 60 | copies or substantial portions of the Software. 61 | 62 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 63 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 64 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 65 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 66 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 67 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 68 | SOFTWARE. 69 | 70 | License 71 | MIT 72 | Title 73 | SwiftLint 74 | Type 75 | PSGroupSpecifier 76 | 77 | 78 | FooterText 79 | Generated by CocoaPods - https://cocoapods.org 80 | Title 81 | 82 | Type 83 | PSGroupSpecifier 84 | 85 | 86 | StringsTable 87 | Acknowledgements 88 | Title 89 | Acknowledgements 90 | 91 | 92 | -------------------------------------------------------------------------------- /Pods/Target Support Files/Pods-NavigationStackExample/Pods-NavigationStackExample-acknowledgements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreferenceSpecifiers 6 | 7 | 8 | FooterText 9 | This application makes use of the following third party libraries: 10 | Title 11 | Acknowledgements 12 | Type 13 | PSGroupSpecifier 14 | 15 | 16 | FooterText 17 | MIT License 18 | 19 | Copyright (c) 2016 Nick Lockwood 20 | 21 | Permission is hereby granted, free of charge, to any person obtaining a copy 22 | of this software and associated documentation files (the "Software"), to deal 23 | in the Software without restriction, including without limitation the rights 24 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 25 | copies of the Software, and to permit persons to whom the Software is 26 | furnished to do so, subject to the following conditions: 27 | 28 | The above copyright notice and this permission notice shall be included in all 29 | copies or substantial portions of the Software. 30 | 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 32 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 33 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 34 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 35 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 36 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 37 | SOFTWARE. 38 | 39 | License 40 | MIT 41 | Title 42 | SwiftFormat 43 | Type 44 | PSGroupSpecifier 45 | 46 | 47 | FooterText 48 | The MIT License (MIT) 49 | 50 | Copyright (c) 2020 Realm Inc. 51 | 52 | Permission is hereby granted, free of charge, to any person obtaining a copy 53 | of this software and associated documentation files (the "Software"), to deal 54 | in the Software without restriction, including without limitation the rights 55 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 56 | copies of the Software, and to permit persons to whom the Software is 57 | furnished to do so, subject to the following conditions: 58 | 59 | The above copyright notice and this permission notice shall be included in all 60 | copies or substantial portions of the Software. 61 | 62 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 63 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 64 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 65 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 66 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 67 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 68 | SOFTWARE. 69 | 70 | License 71 | MIT 72 | Title 73 | SwiftLint 74 | Type 75 | PSGroupSpecifier 76 | 77 | 78 | FooterText 79 | Generated by CocoaPods - https://cocoapods.org 80 | Title 81 | 82 | Type 83 | PSGroupSpecifier 84 | 85 | 86 | StringsTable 87 | Acknowledgements 88 | Title 89 | Acknowledgements 90 | 91 | 92 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/ExampleViews/ContentView2.swift: -------------------------------------------------------------------------------- 1 | import NavigationStack 2 | import SwiftUI 3 | 4 | struct ContentView2: View { 5 | static let id = String(describing: Self.self) 6 | 7 | @EnvironmentObject private var navigationModel: NavigationModel 8 | 9 | var body: some View { 10 | NavigationStackView(ContentView2.id) { 11 | HStack { 12 | VStack(alignment: .leading, spacing: 20) { 13 | Text(ContentView2.id) 14 | 15 | // It's safe to query the `hasAlternativeViewShowing` state from the model, because it will be frozen by the button view. 16 | // However, to be safe we could also just pass `true` because View2 is not the root view. 17 | DismissTopContentButton(hasAlternativeViewShowing: navigationModel.hasAlternativeViewShowing) 18 | 19 | Button(action: { 20 | // Example of a reset transition via move, which is essentially a pop transition. 21 | navigationModel.hideView( 22 | ContentView1.id, 23 | animation: NavigationAnimation( 24 | animation: .easeOut, 25 | defaultViewTransition: .move(edge: .leading), 26 | alternativeViewTransition: .move(edge: .trailing) 27 | ) 28 | ) 29 | }, label: { 30 | Text("Pop to View 1") 31 | }) 32 | .accessibility(identifier: "PopToView1") 33 | 34 | Button(action: { 35 | // Example of a combined reset transition. 36 | navigationModel.hideView( 37 | ContentView1.id, 38 | animation: NavigationAnimation( 39 | animation: .easeOut, 40 | defaultViewTransition: AnyTransition.scale(scale: 2).combined(with: .opacity), 41 | alternativeViewTransition: AnyTransition.scale(scale: 0).combined(with: .opacity) 42 | ) 43 | ) 44 | }, label: { 45 | Text("Scale down to View 1") 46 | }) 47 | .accessibility(identifier: "ScaleDownToView1") 48 | 49 | Button(action: { 50 | // Example of a custom reset transition. 51 | navigationModel.hideView( 52 | ContentView1.id, 53 | animation: NavigationAnimation( 54 | animation: Animation.easeOut.speed(0.25), 55 | defaultViewTransition: .circleShape, 56 | alternativeViewTransition: .circleShape 57 | ) 58 | ) 59 | }, label: { 60 | Text("Double Iris to View 1") 61 | }) 62 | .accessibility(identifier: "DoubleIrisToView1") 63 | 64 | Button(action: { 65 | navigationModel.pushContent(ContentView2.id) { 66 | // It's safe to query the `isAlternativeViewShowing` state from the model, because it will be frozen by View3. 67 | // However, to be sage we could also just pass `true` because View2 is alreaydy showing when transitioning from View2 to View3. 68 | ContentView3(isView2Showing: navigationModel.isAlternativeViewShowing(ContentView2.id)) 69 | } 70 | }, label: { 71 | Text("Push View 3") 72 | }) 73 | .accessibility(identifier: "PushView3") 74 | 75 | Button(action: { 76 | navigationModel.presentContent(ContentView2.id) { 77 | ContentView4(isPresented: navigationModel.viewShowingBinding(ContentView2.id)) 78 | } 79 | }, label: { 80 | Text("Present View 4") 81 | }) 82 | .accessibility(identifier: "PresentView4") 83 | 84 | Spacer() 85 | } 86 | Spacer() 87 | } 88 | .padding() 89 | .background(Color.yellow.opacity(0.3)) 90 | .onAppear { 91 | // onAppear shouldn't be used for views in the navigation stack because it will be called too often! 92 | print("\(ContentView2.id) onAppear (negative example)") 93 | } 94 | .onDidAppear { 95 | print("\(ContentView2.id) did appear") 96 | } 97 | } 98 | } 99 | } 100 | 101 | struct ContentView2_Previews: PreviewProvider { 102 | static var previews: some View { 103 | ContentView2() 104 | .environmentObject(NavigationModel()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/ExampleViews/TransitionExamples.swift: -------------------------------------------------------------------------------- 1 | import NavigationStack 2 | import SwiftUI 3 | 4 | struct TransitionExamples: View { 5 | static let id = String(describing: Self.self) 6 | static let transitionSpeed = 0.75 7 | 8 | @EnvironmentObject private var navigationModel: NavigationModel 9 | 10 | var body: some View { 11 | NavigationStackView(TransitionExamples.id) { 12 | ScrollView { 13 | HStack { 14 | VStack(alignment: .leading, spacing: 20) { 15 | Button(action: { 16 | navigationModel.hideTopViewWithReverseAnimation() 17 | }, label: { 18 | Text("Dismiss Transition Examples") 19 | }) 20 | .accessibility(identifier: "BackButton") 21 | 22 | // SwiftUI transitions 23 | Group { 24 | transitionExample(name: "Move", transition: .move(edge: .trailing)) 25 | transitionExample(name: "Scale", transition: .scale(scale: 0.0, anchor: UnitPoint(x: 0.2, y: 0.2))) 26 | transitionExample(name: "Offset", transition: .offset(x: 100, y: 100)) 27 | transitionExample(name: "Opacity", transition: .opacity) 28 | transitionExample(name: "Slide", transition: .slide) 29 | transitionExample(name: "Identity", transition: .identity) 30 | } 31 | 32 | // Identity replacement 33 | transitionExample(name: "Static", transition: .static) 34 | 35 | // Transitions with SwiftUI effects 36 | Group { 37 | transitionExample(name: "Blur", transition: .blur(radius: 100)) 38 | transitionExample(name: "Brightness", transition: .brightness()) 39 | transitionExample(name: "Contrast", transition: .contrast(-1)) 40 | transitionExample(name: "HueRotation", transition: .hueRotation(.degrees(360))) 41 | transitionExample(name: "Saturation", transition: .saturation()) 42 | } 43 | 44 | // Custom transitions 45 | Group { 46 | transitionExample(name: "TiltAndFly", transition: .tiltAndFly) 47 | transitionExample(name: "CircleShape", transition: .circleShape) 48 | transitionExample(name: "RectangleShape", transition: .rectangleShape) 49 | transitionExample(name: "StripesHorizontalDown", transition: .stripes(stripes: 5, horizontal: true)) 50 | transitionExample(name: "StripesHorizontalUp", transition: .stripes(stripes: 5, horizontal: true, inverted: true)) 51 | transitionExample(name: "StripesVerticalRight", transition: .stripes(stripes: 5, horizontal: false)) 52 | transitionExample(name: "StripesVerticalLeft", transition: .stripes(stripes: 5, horizontal: false, inverted: true)) 53 | } 54 | 55 | Spacer() 56 | } 57 | Spacer() 58 | } 59 | } 60 | .padding() 61 | .background(Color(UIColor.green).opacity(1.0)) 62 | } 63 | } 64 | 65 | func transitionExample(name: String, transition: AnyTransition) -> some View { 66 | Button(name) { 67 | navigationModel.showView( 68 | TransitionExamples.id, 69 | animation: NavigationAnimation( 70 | animation: Animation.easeOut.speed(TransitionExamples.transitionSpeed), 71 | defaultViewTransition: .static, 72 | alternativeViewTransition: transition 73 | ) 74 | ) { 75 | TransitionDestinationView() 76 | } 77 | } 78 | .accessibility(identifier: "\(name)Button") 79 | } 80 | } 81 | 82 | private struct TransitionDestinationView: View { 83 | @EnvironmentObject private var navigationModel: NavigationModel 84 | 85 | var body: some View { 86 | Button(action: { 87 | navigationModel.hideTopViewWithReverseAnimation() 88 | }, label: { 89 | HStack(alignment: .center) { 90 | Spacer() 91 | VStack(alignment: .center, spacing: 20) { 92 | Spacer() 93 | Text("Dismiss") 94 | Spacer() 95 | } 96 | Spacer() 97 | } 98 | }) 99 | .accessibility(identifier: "BackButton") 100 | .padding() 101 | .background(Color(UIColor.yellow).opacity(1.0)) 102 | } 103 | } 104 | 105 | struct TransitionExamples_Previews: PreviewProvider { 106 | static var previews: some View { 107 | TransitionExamples() 108 | .environmentObject(NavigationModel()) 109 | TransitionDestinationView() 110 | .environmentObject(NavigationModel()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment6.swift: -------------------------------------------------------------------------------- 1 | // This experiment is the same as Experiment1, but tries to transform the if-else branch into something more generic. 2 | // It's possibile to use a switch statement and extracting it into a subview. 3 | import NavigationStack 4 | import SwiftUI 5 | 6 | struct Experiment6: View { 7 | @State var animationIndex = 0 8 | @State var transitionIndex = 0 9 | @State var optionIndex = 0 10 | 11 | @State var showAlternativeContent = false 12 | 13 | @State var animation = Animation.linear 14 | @State var defaultEdge = Edge.leading 15 | @State var alternativeEdge = Edge.trailing 16 | @State var defaultTransition = AnyTransition.identity 17 | @State var alternativeTransition = AnyTransition.identity 18 | 19 | var body: some View { 20 | VStack(spacing: 20) { 21 | Pickers(animationIndex: $animationIndex, transitionIndex: $transitionIndex, optionIndex: $optionIndex) 22 | 23 | ToggleContentButton(showAlternativeContent: $showAlternativeContent) { 24 | switch animationIndex { 25 | case 0: 26 | animation = Animation.linear.speed(experimentAnimationSpeedFactor) 27 | case 1: 28 | animation = Animation.spring(response: 0.8, dampingFraction: 0.5, blendDuration: 2.5).speed(experimentAnimationSpeedFactor) 29 | default: 30 | break 31 | } 32 | 33 | switch optionIndex { 34 | case 0: 35 | defaultEdge = .leading 36 | alternativeEdge = .trailing 37 | case 1: 38 | defaultEdge = .top 39 | alternativeEdge = .bottom 40 | default: 41 | break 42 | } 43 | 44 | switch transitionIndex { 45 | case 0: 46 | defaultTransition = .move(edge: defaultEdge) 47 | alternativeTransition = .move(edge: alternativeEdge) 48 | case 1: 49 | defaultTransition = .scale(scale: CGFloat(optionIndex * 2)) 50 | alternativeTransition = .scale(scale: CGFloat(optionIndex * 2)) 51 | default: 52 | break 53 | } 54 | 55 | withAnimation(animation) { 56 | showAlternativeContent.toggle() 57 | } 58 | } 59 | 60 | if !showAlternativeContent { 61 | DefaultContent4(transitionIndex: $transitionIndex, defaultEdge: $defaultEdge, optionIndex: $optionIndex) 62 | } 63 | if showAlternativeContent { 64 | switch transitionIndex { 65 | case 0: 66 | AlternativeContent().transition(.move(edge: alternativeEdge)) 67 | case 1: 68 | AlternativeContent().transition(.scale(scale: CGFloat(optionIndex * 2))) 69 | default: 70 | Text("Undefined alternative transition") 71 | } 72 | } 73 | } 74 | } 75 | 76 | /* 77 | private func defaultContent1() -> AnyView { 78 | switch defaultTransition { // This is just an opaque type of AnyTransition and not an enum! 79 | case .move(edge: _): // '_' can only appear in a pattern or on the left side of an assignment 80 | return AnyView(DefaultContent().transition(.move(edge: defaultEdge))) 81 | default: 82 | return AnyView(Text("Undefined default transition")) 83 | } 84 | } 85 | 86 | private func defaultContent2() -> AnyView { 87 | switch transitionIndex { 88 | case 0: 89 | return AnyView(DefaultContent().transition(.move(edge: defaultEdge))) // No transition because the transition view is inside of AnyView 90 | default: 91 | return AnyView(Text("Undefined default transition")) 92 | } 93 | } 94 | 95 | private func defaultContent3() -> AnyView { 96 | switch transitionIndex { 97 | case 0: 98 | return AnyView(DefaultContent()) 99 | .transition(.move(edge: defaultEdge)) // Cannot convert return expression of type 'some View' to return type 'AnyView' 100 | default: 101 | return AnyView(Text("Undefined default transition")) 102 | } 103 | } 104 | */ 105 | private struct DefaultContent4: View { 106 | @Binding var transitionIndex: Int 107 | @Binding var defaultEdge: Edge 108 | @Binding var optionIndex: Int 109 | var body: some View { 110 | switch transitionIndex { 111 | case 0: 112 | DefaultContent().transition(.move(edge: defaultEdge)) 113 | case 1: 114 | DefaultContent().transition(.scale(scale: CGFloat(optionIndex * 2))) 115 | default: 116 | Text("Undefined default transition") 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/NavigationStack/Core/NavigationStackView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// The navigation view used to switch content when applying a navigation transition. 4 | /// 5 | /// This view works similar to SwiftUI's `NavigationView`. 6 | /// Place it as the view's root and provide the default content to show when no navigation transition has been applied. 7 | /// Use the `NavigationModel` to provide a destination view and transition animation to navigate to. 8 | /// 9 | /// - Important: 10 | /// A single instance of the `NavigationModel` has to be injected into the view hierarchy as an environment object: 11 | /// `MyRootView().environmentObject(NavigationModel())` 12 | public struct NavigationStackView: View where IdentifierType: Equatable { 13 | /** 14 | Initializes the navigation stack view with a given ID and its default content. 15 | 16 | - parameter identifier: The navigation stack view's ID. 17 | This is the reference ID to use when applying a navigation via the model and targeting this layer of stack. 18 | - parameter defaultView: The content view to show when no navigation has been applied. 19 | */ 20 | public init(_ identifier: IdentifierType, @ViewBuilder defaultView: @escaping () -> Content) where Content: View { 21 | self.identifier = identifier 22 | self.defaultView = { AnyView(defaultView()) } 23 | } 24 | 25 | @EnvironmentObject private var model: NavigationStackModel 26 | 27 | /// This navigation stack view's ID. 28 | let identifier: IdentifierType 29 | /// The navigation stack view's default content to show when no navigation has been applied. 30 | private let defaultView: AnyViewBuilder 31 | 32 | @State private var defaultViewAppearedActionWrapper: NavigationViewLifecycleActionWrapper? 33 | @State private var alternativeViewAppearedActionWrapper: NavigationViewLifecycleActionWrapper? 34 | 35 | public var body: some View { 36 | ZStack { 37 | if model.isAlternativeViewShowingPrecede(identifier) { // `if-else` and the precede-call are necessary, see Experiment8 38 | ContentViews( 39 | identifier: identifier, 40 | defaultView: defaultView, 41 | defaultViewAppearedActionWrapper: $defaultViewAppearedActionWrapper, 42 | alternativeViewAppearedActionWrapper: $alternativeViewAppearedActionWrapper 43 | ) 44 | } else { 45 | ContentViews( 46 | identifier: identifier, 47 | defaultView: defaultView, 48 | defaultViewAppearedActionWrapper: $defaultViewAppearedActionWrapper, 49 | alternativeViewAppearedActionWrapper: $alternativeViewAppearedActionWrapper 50 | ) 51 | } 52 | } 53 | .onAnimationCompleted(for: model.transitionProgress(identifier)) { progress in 54 | switch progress { 55 | case .progressToDefaultView: 56 | defaultViewAppearedActionWrapper?.action() 57 | case .progressToAlternativeView: 58 | alternativeViewAppearedActionWrapper?.action() 59 | default: 60 | fatalError("Progress \(progress) should never trigger") 61 | } 62 | } 63 | } 64 | } 65 | 66 | private struct ContentViews: View where IdentifierType: Equatable { 67 | @EnvironmentObject private var model: NavigationStackModel 68 | 69 | /// This navigation stack view's ID. 70 | let identifier: IdentifierType 71 | /// The navigation stack view's default content. 72 | let defaultView: AnyViewBuilder 73 | 74 | @Binding var defaultViewAppearedActionWrapper: NavigationViewLifecycleActionWrapper? 75 | @Binding var alternativeViewAppearedActionWrapper: NavigationViewLifecycleActionWrapper? 76 | 77 | var body: some View { 78 | ZStack { 79 | if !model.isAlternativeViewShowing(identifier) { 80 | defaultView() // The view shown when the navigation is not applied 81 | .transition(model.defaultViewTransition(identifier)) 82 | .zIndex(model.defaultViewZIndex(identifier)) 83 | .onPreferenceChange(OnDidAppearPreferenceKey.self) { 84 | defaultViewAppearedActionWrapper = $0 85 | } 86 | } // No `else`, see Experiment2 87 | if model.isAlternativeViewShowing(identifier), let alternativeView = model.alternativeView(identifier) { 88 | alternativeView() // The alternative view shown when the navigation is applied 89 | .transition(model.alternativeViewTransition(identifier)) 90 | .zIndex(model.alternativeViewZIndex(identifier)) 91 | .onPreferenceChange(OnDidAppearPreferenceKey.self) { 92 | alternativeViewAppearedActionWrapper = $0 93 | } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/NavigationStackExampleUITests/ContentViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class ContentViewTests: XCTestCase { 4 | private var app: XCUIApplication! 5 | 6 | override func setUpWithError() throws { 7 | continueAfterFailure = false 8 | app = XCUIApplication() 9 | app.launch() 10 | assureViewIsShowing("ContentView1") 11 | } 12 | 13 | func pressButton(_ name: String) { 14 | app.buttons[name].tap() 15 | } 16 | 17 | private func assureViewIsShowing(_ name: String, file _: StaticString = #filePath, line _: UInt = #line) { 18 | wait(forElement: app.staticTexts[name], timeout: 5) 19 | } 20 | 21 | private func assureViewIsNotShowing(_ name: String, file _: StaticString = #filePath, line: UInt = #line) { 22 | XCTAssertFalse(app.staticTexts[name].exists, line: line) 23 | } 24 | 25 | // MARK: - Tests 26 | 27 | func testBackOnRoot() throws { 28 | assureViewIsShowing("NotPossibleLabel") 29 | pressButton("Back") 30 | assureViewIsShowing("ContentView1") 31 | } 32 | 33 | func testShowViewTransition() throws { 34 | pressButton("PushView2") 35 | assureViewIsShowing("ContentView2") 36 | pressButton("PopToView1") 37 | assureViewIsShowing("ContentView1") 38 | } 39 | 40 | func testCombinedTransition() throws { 41 | pressButton("ScaleUpToView2") 42 | assureViewIsShowing("ContentView2") 43 | pressButton("ScaleDownToView1") 44 | assureViewIsShowing("ContentView1") 45 | } 46 | 47 | func testCustomTransition() throws { 48 | pressButton("SingleIrisToView2") 49 | assureViewIsShowing("ContentView2") 50 | pressButton("DoubleIrisToView1") 51 | assureViewIsShowing("ContentView1") 52 | } 53 | 54 | func testPushAndPopShortcut() throws { 55 | pressButton("PushView3") 56 | assureViewIsShowing("ContentView3") 57 | assureViewIsNotShowing("PopToView2Animated") 58 | pressButton("PopToView2NoAnimation") 59 | assureViewIsShowing("ContentView3") 60 | pressButton("Back") 61 | assureViewIsShowing("ContentView1") 62 | } 63 | 64 | func testPresentVerticalNoAnimationBack() throws { 65 | pressButton("PresentView4InFront") 66 | assureViewIsShowing("ContentView4") 67 | pressButton("DismissView4NoAnimation") 68 | assureViewIsShowing("ContentView1") 69 | } 70 | 71 | func testPresentVerticalInFront() throws { 72 | pressButton("PresentView4InFront") 73 | assureViewIsShowing("ContentView4") 74 | pressButton("DismissView4Animated") 75 | assureViewIsShowing("ContentView1") 76 | } 77 | 78 | func testPresentVerticalBehind() throws { 79 | pressButton("PresentView4Behind") 80 | assureViewIsShowing("ContentView4") 81 | pressButton("DismissView4Animated") 82 | assureViewIsShowing("ContentView1") 83 | } 84 | 85 | func testPresentVerticalFade() throws { 86 | pressButton("PresentView4Fading") 87 | assureViewIsShowing("ContentView4") 88 | pressButton("DismissView4Animated") 89 | assureViewIsShowing("ContentView1") 90 | } 91 | 92 | func testView4OnView2WithBack() throws { 93 | pressButton("PushView2") 94 | assureViewIsShowing("ContentView2") 95 | pressButton("PresentView4") 96 | assureViewIsShowing("ContentView4") 97 | pressButton("DismissView4Animated") 98 | assureViewIsShowing("ContentView2") 99 | pressButton("Back") 100 | assureViewIsShowing("ContentView1") 101 | } 102 | 103 | func testView4OnView3WithBack() throws { 104 | pressButton("PushView2") 105 | assureViewIsShowing("ContentView2") 106 | pressButton("PushView3") 107 | assureViewIsShowing("ContentView3") 108 | pressButton("PresentView4") 109 | assureViewIsShowing("ContentView4") 110 | pressButton("DismissView4Animated") 111 | assureViewIsShowing("ContentView3") 112 | pressButton("Back") 113 | assureViewIsShowing("ContentView2") 114 | pressButton("Back") 115 | assureViewIsShowing("ContentView1") 116 | } 117 | 118 | func testBackToRoot() throws { 119 | pressButton("PushView2") 120 | assureViewIsShowing("ContentView2") 121 | pressButton("PushView3") 122 | assureViewIsShowing("ContentView3") 123 | pressButton("PopToRoot") 124 | assureViewIsShowing("ContentView1") 125 | } 126 | 127 | func testMultipleBack() throws { 128 | pressButton("PushView2") 129 | assureViewIsShowing("ContentView2") 130 | pressButton("PushView3") 131 | assureViewIsShowing("ContentView3") 132 | pressButton("Back") 133 | assureViewIsShowing("ContentView2") 134 | pressButton("Back") 135 | assureViewIsShowing("ContentView1") 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /NavigationStack.xcodeproj/xcshareddata/xcschemes/NavigationStack.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 46 | 47 | 53 | 54 | 55 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 78 | 79 | 85 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /Tests/NavigationStackTests/NavigationModelTests/NavigationModelStateTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NavigationStack 2 | import SwiftUI 3 | import XCTest 4 | 5 | class NavigationModelStateTests: XCTestCase { 6 | var model: NavigationModel! 7 | 8 | override func setUp() { 9 | model = NavigationModel(silenceErrors: true) 10 | } 11 | 12 | override func tearDownWithError() throws { 13 | weak var weakModel = model 14 | model = nil 15 | XCTAssertNil(weakModel) 16 | } 17 | 18 | // MARK: - Tests 19 | 20 | func testHasAlternativeViewShowingFalse() throws { 21 | let result = model.hasAlternativeViewShowing 22 | 23 | XCTAssertFalse(result) 24 | } 25 | 26 | func testHasAlternativeViewShowing() throws { 27 | model.showView("Foo") { EmptyView() } 28 | 29 | let result = model.hasAlternativeViewShowing 30 | 31 | XCTAssertTrue(result) 32 | } 33 | 34 | func testIsAlternativeViewShowingFalse() throws { 35 | let result = model.isAlternativeViewShowing("Foo") 36 | 37 | XCTAssertFalse(result) 38 | } 39 | 40 | func testIsAlternativeViewShowing() throws { 41 | model.showView("Foo") { EmptyView() } 42 | 43 | let result = model.isAlternativeViewShowing("Foo") 44 | 45 | XCTAssertTrue(result) 46 | } 47 | 48 | func testTopViewShowingBindingFalse() throws { 49 | let binding = model.topViewShowingBinding() 50 | 51 | XCTAssertNotNil(binding) 52 | XCTAssertFalse(binding.wrappedValue) 53 | 54 | binding.wrappedValue.toggle() 55 | 56 | XCTAssertFalse(binding.wrappedValue) 57 | } 58 | 59 | func testTopViewShowingBinding() throws { 60 | model.showView("Foo") { EmptyView() } 61 | 62 | let binding = model.topViewShowingBinding() 63 | 64 | XCTAssertNotNil(binding) 65 | XCTAssertTrue(binding.wrappedValue) 66 | XCTAssertTrue(model.isAlternativeViewShowing("Foo")) 67 | 68 | binding.wrappedValue.toggle() 69 | 70 | XCTAssertFalse(binding.wrappedValue) 71 | XCTAssertFalse(model.isAlternativeViewShowing("Foo")) 72 | } 73 | 74 | func testTopViewShowingBindingReflectsModelChange() throws { 75 | model.showView("Foo") { EmptyView() } 76 | 77 | let binding = model.topViewShowingBinding() 78 | 79 | XCTAssertNotNil(binding) 80 | XCTAssertTrue(binding.wrappedValue) 81 | XCTAssertTrue(model.isAlternativeViewShowing("Foo")) 82 | 83 | model.hideTopView() 84 | 85 | XCTAssertFalse(binding.wrappedValue) 86 | XCTAssertFalse(model.isAlternativeViewShowing("Foo")) 87 | } 88 | 89 | func testTopViewShowingBindingDoesNotRetainNode() throws { 90 | model.showView("Foo") { EmptyView() } 91 | 92 | let binding = model.topViewShowingBinding() 93 | XCTAssertNotNil(binding) 94 | weak var node = model.navigationStackNode 95 | XCTAssertNotNil(node) 96 | XCTAssertEqual("Foo", node?.identifer) 97 | 98 | model.hideTopView() 99 | model.cleanupNodeList() 100 | 101 | XCTAssertNil(node) 102 | } 103 | 104 | func testViewShowingBindingFalse() throws { 105 | model.showView("Foo") { EmptyView() } 106 | 107 | let binding = model.viewShowingBinding("Bar") 108 | 109 | XCTAssertNotNil(binding) 110 | XCTAssertFalse(binding.wrappedValue) 111 | 112 | binding.wrappedValue.toggle() 113 | 114 | XCTAssertFalse(binding.wrappedValue) 115 | } 116 | 117 | func testViewShowingBinding() throws { 118 | model.showView("Foo") { EmptyView() } 119 | 120 | let binding = model.viewShowingBinding("Foo") 121 | 122 | XCTAssertNotNil(binding) 123 | XCTAssertTrue(binding.wrappedValue) 124 | XCTAssertTrue(model.isAlternativeViewShowing("Foo")) 125 | 126 | binding.wrappedValue.toggle() 127 | 128 | XCTAssertFalse(binding.wrappedValue) 129 | XCTAssertFalse(model.isAlternativeViewShowing("Foo")) 130 | } 131 | 132 | func testViewShowingBindingReflectsModelChange() throws { 133 | model.showView("Foo") { EmptyView() } 134 | 135 | let binding = model.viewShowingBinding("Foo") 136 | 137 | XCTAssertNotNil(binding) 138 | XCTAssertTrue(binding.wrappedValue) 139 | XCTAssertTrue(model.isAlternativeViewShowing("Foo")) 140 | 141 | model.hideTopView() 142 | 143 | XCTAssertFalse(binding.wrappedValue) 144 | XCTAssertFalse(model.isAlternativeViewShowing("Foo")) 145 | } 146 | 147 | func testViewShowingBindingDoesNotRetainNode() throws { 148 | model.showView("Foo") { EmptyView() } 149 | 150 | let binding = model.viewShowingBinding("Foo") 151 | XCTAssertNotNil(binding) 152 | weak var node = model.navigationStackNode 153 | XCTAssertNotNil(node) 154 | XCTAssertEqual("Foo", node?.identifer) 155 | 156 | model.hideTopView() 157 | model.cleanupNodeList() 158 | 159 | XCTAssertNil(node) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Sources/NavigationStackExample/Experiments/Experiment0.swift: -------------------------------------------------------------------------------- 1 | // A simple example how to use SwiftUI navigation with Apple's out-of-the-box solutions. 2 | import SwiftUI 3 | 4 | struct Experiment0: View { 5 | @State var isShowingView1 = false 6 | @State var isShowingView3 = false 7 | @State var isShowingView3fullscreen = false 8 | 9 | var body: some View { 10 | NavigationView { 11 | HStack { 12 | VStack(alignment: .leading, spacing: 20) { 13 | NavigationLink( 14 | destination: AlternativeContentView1(isShowingView1: $isShowingView1), 15 | isActive: $isShowingView1 16 | ) { 17 | Text("NavigationLink to View1") 18 | } 19 | 20 | Button(action: { 21 | isShowingView3.toggle() 22 | }, label: { 23 | Text("Present View 3 as sheet") 24 | }) 25 | .sheet(isPresented: $isShowingView3) { 26 | AlternativeContentView3(isShowingView3: $isShowingView3) 27 | } 28 | 29 | if #available(iOS 14.0, *) { 30 | Button(action: { 31 | isShowingView3fullscreen.toggle() 32 | }, label: { 33 | Text("Present View 3 fullscreen (iOS 14 only)") 34 | }) 35 | .fullScreenCover(isPresented: $isShowingView3fullscreen) { // fullscreen only with iOS 14 36 | AlternativeContentView3(isShowingView3: $isShowingView3fullscreen) 37 | } 38 | } else { 39 | Text("Present View 3 fullscreen (iOS 14 only)") 40 | } 41 | 42 | Spacer() 43 | } 44 | Spacer() 45 | } 46 | .padding() 47 | .navigationBarTitle("Home View") 48 | .navigationBarHidden(false) // has some glitches with iOS 13 49 | } 50 | } 51 | } 52 | 53 | struct AlternativeContentView1: View { 54 | @Binding var isShowingView1: Bool 55 | @State var isShowingView2 = false 56 | 57 | var body: some View { 58 | HStack { 59 | VStack(alignment: .leading, spacing: 20) { 60 | Text("Alternative Content 1") 61 | 62 | NavigationLink( 63 | destination: AlternativeContentView2(isShowingView1: $isShowingView1, isShowingView2: $isShowingView2), 64 | isActive: $isShowingView2 65 | ) { 66 | Text("NavigationLink to View2") 67 | } 68 | 69 | Button(action: { 70 | isShowingView1.toggle() 71 | }, label: { 72 | Text("Back to Home") 73 | }) 74 | 75 | Spacer() 76 | } 77 | Spacer() 78 | } 79 | .padding() 80 | .navigationBarTitle("Alternative Content 1") 81 | .navigationBarHidden(true) 82 | } 83 | } 84 | 85 | struct AlternativeContentView2: View { 86 | @Binding var isShowingView1: Bool // passing states of previous views is awkward 87 | @Binding var isShowingView2: Bool 88 | 89 | var body: some View { 90 | HStack { 91 | VStack(alignment: .leading, spacing: 20) { 92 | Text("Alternative Content 2") 93 | 94 | Button(action: { 95 | isShowingView2.toggle() 96 | }, label: { 97 | Text("Back to View2") 98 | }) 99 | 100 | Button(action: { 101 | isShowingView1.toggle() 102 | }, label: { 103 | Text("Back to Home") 104 | }) 105 | 106 | Spacer() 107 | } 108 | Spacer() 109 | } 110 | .padding() 111 | .navigationBarTitle("Alternative Content 2") 112 | } 113 | } 114 | 115 | struct AlternativeContentView3: View { 116 | @Binding var isShowingView3: Bool 117 | @State var isShowingView4 = false 118 | 119 | var body: some View { 120 | HStack { 121 | VStack(alignment: .leading, spacing: 20) { 122 | Text("Alternative Content 3") 123 | 124 | Button(action: { 125 | isShowingView3.toggle() 126 | }, label: { 127 | Text("Dismiss View 3") 128 | }) 129 | 130 | Button(action: { 131 | isShowingView4.toggle() 132 | }, label: { 133 | Text("Present View 4 as sheet") 134 | }) 135 | .sheet(isPresented: $isShowingView4) { 136 | AlternativeContentView4(isShowingView3: $isShowingView3, isShowingView4: $isShowingView4) 137 | } 138 | 139 | Spacer() 140 | } 141 | Spacer() 142 | } 143 | .padding() 144 | .navigationBarTitle("Alternative Content 3") 145 | } 146 | } 147 | 148 | struct AlternativeContentView4: View { 149 | @Binding var isShowingView3: Bool 150 | @Binding var isShowingView4: Bool 151 | 152 | var body: some View { 153 | HStack { 154 | VStack(alignment: .leading, spacing: 20) { 155 | Text("Alternative Content 4") 156 | 157 | Button(action: { 158 | isShowingView4.toggle() 159 | }, label: { 160 | Text("Dismiss View 4") 161 | }) 162 | 163 | Button(action: { 164 | isShowingView3.toggle() // doesn't work correctly 165 | }, label: { 166 | Text("Dismiss to Home") 167 | }) 168 | 169 | Spacer() 170 | } 171 | Spacer() 172 | } 173 | .padding() 174 | .navigationBarTitle("Alternative Content 4") 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /NavigationStack.xcodeproj/xcshareddata/xcschemes/NavigationStackExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 69 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 90 | 92 | 98 | 99 | 100 | 101 | 103 | 104 | 107 | 108 | 109 | --------------------------------------------------------------------------------