!
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 |
--------------------------------------------------------------------------------