diff --git a/FlowStacks.podspec b/FlowStacks.podspec index 02bdda1..2bd9a69 100644 --- a/FlowStacks.podspec +++ b/FlowStacks.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'FlowStacks' - s.version = '0.3.8' + s.version = '0.4.0' s.summary = 'Hoist navigation state into a coordinator in SwiftUI.' s.description = <<-DESC diff --git a/FlowStacksApp.xcodeproj/project.pbxproj b/FlowStacksApp.xcodeproj/project.pbxproj index 8bad953..9223177 100644 --- a/FlowStacksApp.xcodeproj/project.pbxproj +++ b/FlowStacksApp.xcodeproj/project.pbxproj @@ -23,12 +23,23 @@ 526D9F2F26AF661F00B6B882 /* VMCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526D9F2226AF661F00B6B882 /* VMCoordinator.swift */; }; 526D9F3326AF667000B6B882 /* FlowStacksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526D9F3226AF667000B6B882 /* FlowStacksApp.swift */; }; 526D9F3426AF667000B6B882 /* FlowStacksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526D9F3226AF667000B6B882 /* FlowStacksApp.swift */; }; + 5285BBAF2B6408F500197CE7 /* FlowStacksAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5285BBAE2B6408F500197CE7 /* FlowStacksAppUITests.swift */; }; 52C5CCDE286EE92B0075ABA7 /* Deeplink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C5CCDD286EE92B0075ABA7 /* Deeplink.swift */; }; 52C5CCDF286EE92B0075ABA7 /* Deeplink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C5CCDD286EE92B0075ABA7 /* Deeplink.swift */; }; 77F136ED282E45E0008A6A02 /* ParentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F136EC282E45DF008A6A02 /* ParentCoordinator.swift */; }; 77F136EE282E45E0008A6A02 /* ParentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F136EC282E45DF008A6A02 /* ParentCoordinator.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 5285BBB22B6408F500197CE7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5241BF2726AA1D3A002D6892 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5241BF3226AA1D3B002D6892; + remoteInfo = "FlowStacksApp (iOS)"; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 5241BF3326AA1D3B002D6892 /* FlowStacksApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FlowStacksApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5241BF3926AA1D3B002D6892 /* FlowStacksApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FlowStacksApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -38,6 +49,8 @@ 526D9F1C26AF661F00B6B882 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 526D9F2226AF661F00B6B882 /* VMCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMCoordinator.swift; sourceTree = ""; }; 526D9F3226AF667000B6B882 /* FlowStacksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowStacksApp.swift; sourceTree = ""; }; + 5285BBAC2B6408F500197CE7 /* FlowStacksAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FlowStacksAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5285BBAE2B6408F500197CE7 /* FlowStacksAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowStacksAppUITests.swift; sourceTree = ""; }; 52C5CCDB286E5D780075ABA7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 52C5CCDD286EE92B0075ABA7 /* Deeplink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deeplink.swift; sourceTree = ""; }; 52E2C8F02A7265A00042C495 /* FlowStacks */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlowStacks; path = .; sourceTree = ""; }; @@ -63,6 +76,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5285BBA92B6408F500197CE7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -79,6 +99,7 @@ children = ( 521678E12A2FD6AE00954579 /* Packages */, 526D9F1B26AF661F00B6B882 /* FlowStacksApp */, + 5285BBAD2B6408F500197CE7 /* FlowStacksAppUITests */, 5241BF3426AA1D3B002D6892 /* Products */, 5241BF6D26AA1E8A002D6892 /* Frameworks */, ); @@ -89,6 +110,7 @@ children = ( 5241BF3326AA1D3B002D6892 /* FlowStacksApp.app */, 5241BF3926AA1D3B002D6892 /* FlowStacksApp.app */, + 5285BBAC2B6408F500197CE7 /* FlowStacksAppUITests.xctest */, ); name = Products; sourceTree = ""; @@ -124,6 +146,14 @@ path = Shared; sourceTree = ""; }; + 5285BBAD2B6408F500197CE7 /* FlowStacksAppUITests */ = { + isa = PBXGroup; + children = ( + 5285BBAE2B6408F500197CE7 /* FlowStacksAppUITests.swift */, + ); + path = FlowStacksAppUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -169,6 +199,24 @@ productReference = 5241BF3926AA1D3B002D6892 /* FlowStacksApp.app */; productType = "com.apple.product-type.application"; }; + 5285BBAB2B6408F500197CE7 /* FlowStacksAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5285BBB62B6408F500197CE7 /* Build configuration list for PBXNativeTarget "FlowStacksAppUITests" */; + buildPhases = ( + 5285BBA82B6408F500197CE7 /* Sources */, + 5285BBA92B6408F500197CE7 /* Frameworks */, + 5285BBAA2B6408F500197CE7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5285BBB32B6408F500197CE7 /* PBXTargetDependency */, + ); + name = FlowStacksAppUITests; + productName = FlowStacksAppUITests; + productReference = 5285BBAC2B6408F500197CE7 /* FlowStacksAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -176,7 +224,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1300; + LastSwiftUpdateCheck = 1510; LastUpgradeCheck = 1500; TargetAttributes = { 5241BF3226AA1D3B002D6892 = { @@ -185,6 +233,10 @@ 5241BF3826AA1D3B002D6892 = { CreatedOnToolsVersion = 13.0; }; + 5285BBAB2B6408F500197CE7 = { + CreatedOnToolsVersion = 15.1; + TestTargetID = 5241BF3226AA1D3B002D6892; + }; }; }; buildConfigurationList = 5241BF2A26AA1D3A002D6892 /* Build configuration list for PBXProject "FlowStacksApp" */; @@ -205,6 +257,7 @@ targets = ( 5241BF3226AA1D3B002D6892 /* FlowStacksApp (iOS) */, 5241BF3826AA1D3B002D6892 /* FlowStacksApp (macOS) */, + 5285BBAB2B6408F500197CE7 /* FlowStacksAppUITests */, ); }; /* End PBXProject section */ @@ -226,6 +279,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5285BBAA2B6408F500197CE7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -257,8 +317,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5285BBA82B6408F500197CE7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5285BBAF2B6408F500197CE7 /* FlowStacksAppUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 5285BBB32B6408F500197CE7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5241BF3226AA1D3B002D6892 /* FlowStacksApp (iOS) */; + targetProxy = 5285BBB22B6408F500197CE7 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 5241BF4026AA1D3B002D6892 /* Debug */ = { isa = XCBuildConfiguration; @@ -502,6 +578,52 @@ }; name = Release; }; + 5285BBB42B6408F500197CE7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.uk.johnpatrickmorgan.FlowStacksAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "FlowStacksApp (iOS)"; + }; + name = Debug; + }; + 5285BBB52B6408F500197CE7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.uk.johnpatrickmorgan.FlowStacksAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "FlowStacksApp (iOS)"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -532,6 +654,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5285BBB62B6408F500197CE7 /* Build configuration list for PBXNativeTarget "FlowStacksAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5285BBB42B6408F500197CE7 /* Debug */, + 5285BBB52B6408F500197CE7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/FlowStacksApp.xcodeproj/xcshareddata/xcschemes/FlowStacksApp (iOS).xcscheme b/FlowStacksApp.xcodeproj/xcshareddata/xcschemes/FlowStacksApp (iOS).xcscheme index 24440ac..539250f 100644 --- a/FlowStacksApp.xcodeproj/xcshareddata/xcschemes/FlowStacksApp (iOS).xcscheme +++ b/FlowStacksApp.xcodeproj/xcshareddata/xcschemes/FlowStacksApp (iOS).xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:"> + + + + 1 { Button("Go back") { navigator.goBack() } + .accessibilityIdentifier("Go back from \(number)") Button("Go back to root") { navigator.goBackToRoot() } + .accessibilityIdentifier("Go back to root from \(number)") } } .padding() diff --git a/FlowStacksApp/Shared/VMCoordinator.swift b/FlowStacksApp/Shared/VMCoordinator.swift index 3da3b03..068813c 100644 --- a/FlowStacksApp/Shared/VMCoordinator.swift +++ b/FlowStacksApp/Shared/VMCoordinator.swift @@ -11,15 +11,15 @@ class VMCoordinatorViewModel: ObservableObject { @Published var routes: Routes = [] init() { - routes.presentSheet(.home(.init(pickANumberSelected: showNumberList))) + routes.presentSheet(.home(.init(pickANumberSelected: showNumberList)), embedInNavigationView: true) } func showNumberList() { - routes.presentSheet(.numberList(.init(numberSelected: showNumber, cancel: dismiss))) + routes.push(.numberList(.init(numberSelected: showNumber, cancel: dismiss))) } func showNumber(_ number: Int) { - routes.presentSheet(.numberDetail(.init(number: number, cancel: goBackToRoot))) + routes.presentSheet(.numberDetail(.init(number: number, cancel: goBackToRoot)), embedInNavigationView: true) } func dismiss() { @@ -111,6 +111,8 @@ struct NumberDetailView: View { @Environment(\.presentationMode) var presentationMode + @EnvironmentObject var navigator: FlowNavigator + var body: some View { VStack { Text("\(viewModel.number)") @@ -118,6 +120,9 @@ struct NumberDetailView: View { Button("PresentationMode Dismiss") { presentationMode.wrappedValue.dismiss() } + Button("Navigator Dismiss") { + navigator.goBack() + } } .navigationTitle("Number \(viewModel.number)") } diff --git a/FlowStacksAppUITests/FlowStacksAppUITests.swift b/FlowStacksAppUITests/FlowStacksAppUITests.swift new file mode 100644 index 0000000..9f59ae4 --- /dev/null +++ b/FlowStacksAppUITests/FlowStacksAppUITests.swift @@ -0,0 +1,119 @@ +import XCTest + +let navigationTimeout = 0.8 + +final class FlowStacksAppUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testVMsTab() { + XCUIDevice.shared.orientation = .portrait + let app = XCUIApplication() + app.launch() + + XCTAssertTrue(app.tabBars.buttons["VMs"].waitForExistence(timeout: 3)) + app.tabBars.buttons["VMs"].tap() + XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Pick a number"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Numbers"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Go back"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Pick a number"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Numbers"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["1"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Number 1"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["PresentationMode Dismiss"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Numbers"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["2"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Number 2"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Navigator Dismiss"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Numbers"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["3"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Number 3"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Go back to root"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: navigationTimeout)) + } + + func testNumbersTab() { + XCUIDevice.shared.orientation = .portrait + let app = XCUIApplication() + app.launch() + + XCTAssertTrue(app.tabBars.buttons["Numbers"].waitForExistence(timeout: 3)) + app.tabBars.buttons["Numbers"].tap() + XCTAssertTrue(app.navigationBars["0"].waitForExistence(timeout: navigationTimeout)) + + app.steppers.firstMatch.buttons["Increment"].tap() + XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Present Double (cover) from 1"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["2"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Present Double (cover) from 2"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["4"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Present Double (sheet) from 4"].tap() + XCTAssertTrue(app.navigationBars["8"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Present Double (sheet) from 8"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["16"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Push next from 16"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Push next from 17"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["18"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Present Double (sheet) from 18"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["36"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Push next from 36"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["37"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Push next from 37"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["38"].waitForExistence(timeout: navigationTimeout)) + + app.navigationBars.buttons["37"].tap() + + app.buttons["Go back from 37"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["36"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Go back from 36"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["18"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Go back from 18"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Present Double (sheet) from 17"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["34"].waitForExistence(timeout: navigationTimeout)) + + app.navigationBars["34"].swipeSheetDown() + XCTAssertTrue(app.navigationBars["17"].waitForExistence(timeout: navigationTimeout)) + + app.buttons["Go back to root from 17"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["1"].waitForExistence(timeout: navigationTimeout * 3)) + } +} + +extension XCUIElement { + func swipeSheetDown() { + if #available(iOS 17.0, *) { + // This doesn't work in iOS 16 + self.swipeDown(velocity: .fast) + } else { + let start = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + let end = coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 5)) + start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0.0) + } + } +} diff --git a/Sources/FlowStacks/Node.swift b/Sources/FlowStacks/Node.swift index 13f1549..89fa019 100644 --- a/Sources/FlowStacks/Node.swift +++ b/Sources/FlowStacks/Node.swift @@ -1,147 +1,91 @@ import Foundation import SwiftUI -/// A view that represents a linked list of routes, each pushing or presenting the next in -/// the list. -indirect enum Node: View { - case route(Route, next: Node, allRoutes: Binding<[Route]>, index: Int, buildView: (Screen) -> V) - case end +struct Node: View { + @Binding var allScreens: [Route] + let buildView: (Binding, Int) -> V + let truncateToIndex: (Int) -> Void + let index: Int + let screen: Screen? + + // NOTE: even though this object is unused, its inclusion avoids a glitch when swiping to dismiss + // a sheet that's been presented from a pushed screen with a view model. + @EnvironmentObject var navigator: FlowNavigator - private var isActiveBinding: Binding { - switch self { - case .end, .route(_, next: .end, _, _, _): - return .constant(false) - case .route(_, .route, let allRoutes, let index, _): - return Binding( - get: { - if #available(iOS 17.0, *) { - return allRoutes.wrappedValue.count != index + 1 - } else { - return allRoutes.wrappedValue.count > index + 1 - } - }, - set: { isShowing in - guard !isShowing else { return } - guard allRoutes.wrappedValue.count > index + 1 else { return } - allRoutes.wrappedValue = Array(allRoutes.wrappedValue.prefix(index + 1)) - } - ) - } - } - - private var pushBinding: Binding { - switch next { - case .route(.push, _, _, _, _): - return isActiveBinding - default: - return .constant(false) - } - } - - private var sheetBinding: Binding { - switch next { - case .route(.sheet, _, _, _, _): - return isActiveBinding - default: - return .constant(false) - } - } - - private var onDismiss: (() -> Void)? { - switch next { - case .route(.sheet(_, _, let onDismiss), _, _, _, _), .route(.cover(_, _, let onDismiss), _, _, _, _): - return onDismiss - default: - return nil - } - } + @State var isAppeared = false - private var coverBinding: Binding { - switch next { - case .route(.cover, _, _, _, _): - return isActiveBinding - default: - return .constant(false) - } + init(allScreens: Binding<[Route]>, truncateToIndex: @escaping (Int) -> Void, index: Int, buildView: @escaping (Binding, Int) -> V) { + _allScreens = allScreens + self.truncateToIndex = truncateToIndex + self.index = index + self.buildView = buildView + screen = allScreens.wrappedValue[safe: index]?.screen } - private var route: Route? { - switch self { - case .end: - return nil - case .route(let route, _, _, _, _): - return route - } + private var isActiveBinding: Binding { + return Binding( + get: { allScreens.count > index + 1 }, + set: { isShowing in + guard !isShowing else { return } + guard allScreens.count > index + 1 else { return } + guard isAppeared else { return } + truncateToIndex(index + 1) + } + ) } - private var next: Node? { - switch self { - case .end: - return nil - case .route(_, let next, _, _, _): - return next - } + var next: some View { + Node(allScreens: $allScreens, truncateToIndex: truncateToIndex, index: index + 1, buildView: buildView) } - - @ViewBuilder - private var screenView: some View { - switch self { - case .end: - EmptyView() - case .route(let route, _, _, _, let buildView): - buildView(route.screen) - } + + var nextRoute: Route? { + allScreens[safe: index + 1] } @ViewBuilder - private var unwrappedBody: some View { - /// NOTE: On iOS 14.4 and below, a bug prevented multiple sheet/fullScreenCover modifiers being chained - /// on the same view, so we conditionally add the sheet/cover modifiers as a workaround. See - /// https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14_5-release-notes - if #available(iOS 14.5, *) { - screenView - .background( - NavigationLink(destination: next, isActive: pushBinding, label: EmptyView.init) - .hidden() - ) - .sheet( - isPresented: sheetBinding, - onDismiss: onDismiss, - content: { next } - ) - .cover( - isPresented: coverBinding, - onDismiss: onDismiss, - content: { next } - ) - } else { - let asSheet = next?.route?.style.isSheet ?? false - screenView - .background( - NavigationLink(destination: next, isActive: pushBinding, label: EmptyView.init) - .hidden() + var content: some View { + if let screen = allScreens[safe: index]?.screen ?? screen { + let screenBinding = Binding( + get: { allScreens[safe: index]?.screen ?? screen }, + set: { allScreens[index].screen = $0 } + ) + buildView(screenBinding, index) + .pushing( + isActive: nextRoute?.style == .push ? isActiveBinding : .constant(false), + destination: next ) - .present( - asSheet: asSheet, - isPresented: asSheet ? sheetBinding : coverBinding, - onDismiss: onDismiss, - content: { next } + .presenting( + sheetBinding: (nextRoute?.style.isSheet ?? false) ? isActiveBinding : .constant(false), + coverBinding: (nextRoute?.style.isCover ?? false) ? isActiveBinding : .constant(false), + destination: next, + onDismiss: nextRoute?.onDismiss ) + .onAppear { isAppeared = true } + .onDisappear { isAppeared = false } } } - + var body: some View { + let route = allScreens[safe: index] if route?.embedInNavigationView ?? false { NavigationView { - unwrappedBody + content } .navigationViewStyle(supportedNavigationViewStyle) } else { - unwrappedBody + content } } } + +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + /// There are spurious state updates when using the `column` navigation view style, so /// the navigation view style is forced to `stack` where possible. private var supportedNavigationViewStyle: some NavigationViewStyle { diff --git a/Sources/FlowStacks/Route.swift b/Sources/FlowStacks/Route.swift index b4460c1..0cc7ec5 100644 --- a/Sources/FlowStacks/Route.swift +++ b/Sources/FlowStacks/Route.swift @@ -59,6 +59,16 @@ public enum Route { } } + /// The onDIsmiss closure to be called when a sheet or full-screen cover is dismissed. + public var onDismiss: (() -> Void)? { + switch self { + case .push: + return nil + case .sheet(_, _, let onDismiss), .cover(_, _, let onDismiss): + return onDismiss + } + } + /// Whether the route is presented (via a sheet or cover presentation). public var isPresented: Bool { switch self { diff --git a/Sources/FlowStacks/Router.swift b/Sources/FlowStacks/Router.swift index 5dc7686..0d69467 100644 --- a/Sources/FlowStacks/Router.swift +++ b/Sources/FlowStacks/Router.swift @@ -7,31 +7,20 @@ public struct Router: View { @Binding var routes: [Route] /// A closure that builds a `ScreenView` from a `Screen`and its index. - @ViewBuilder var buildView: (Screen, Int) -> ScreenView + @ViewBuilder var buildView: (Binding, Int) -> ScreenView + /// Initializer for creating a Router using a binding to an array of screens. /// - Parameters: /// - stack: A binding to an array of screens. - /// - buildView: A closure that builds a `ScreenView` from a `Screen` and its index. - public init(_ routes: Binding<[Route]>, @ViewBuilder buildView: @escaping (Screen, Int) -> ScreenView) { + /// - buildView: A closure that builds a `ScreenView` from a binding to a `Screen` and its index. + public init(_ routes: Binding<[Route]>, @ViewBuilder buildView: @escaping (Binding, Int) -> ScreenView) { self._routes = routes self.buildView = buildView } - + public var body: some View { - routes - .enumerated() - .reversed() - .reduce(Node.end) { nextNode, new in - let (index, route) = new - return Node.route( - route, - next: nextNode, - allRoutes: $routes, - index: index, - buildView: { buildView($0, index) } - ) - } + Node(allScreens: $routes, truncateToIndex: { index in routes = Array(routes.prefix(index)) }, index: 0, buildView: buildView) .environmentObject(FlowNavigator($routes)) } } @@ -40,15 +29,19 @@ public extension Router { /// Initializer for creating a Router using a binding to an array of screens. /// - Parameters: /// - stack: A binding to an array of screens. - /// - buildView: A closure that builds a `ScreenView` from a binding to a `Screen` and its index. - init(_ routes: Binding<[Route]>, @ViewBuilder buildView: @escaping (Binding, Int) -> ScreenView) { + /// - buildView: A closure that builds a `ScreenView` from a `Screen` and its index. + init(_ routes: Binding<[Route]>, @ViewBuilder buildView: @escaping (Screen, Int) -> ScreenView) { + self._routes = routes + self.buildView = { buildView($0.wrappedValue, $1) } + } + + init(_ routes: Binding<[Route]>, @ViewBuilder buildView: @escaping (Binding) -> ScreenView) { + self._routes = routes + self.buildView = { screen, _ in buildView(screen) } + } + + init(_ routes: Binding<[Route]>, @ViewBuilder buildView: @escaping (Screen) -> ScreenView) { self._routes = routes - self.buildView = { _, index in - let binding = Binding( - get: { routes.wrappedValue[index].screen }, - set: { routes.wrappedValue[index].screen = $0 } - ) - return buildView(binding, index) - } + self.buildView = { $screen, _ in buildView(screen) } } } diff --git a/Sources/FlowStacks/View+cover.swift b/Sources/FlowStacks/View+cover.swift index c781a24..efc4cc2 100644 --- a/Sources/FlowStacks/View+cover.swift +++ b/Sources/FlowStacks/View+cover.swift @@ -31,24 +31,4 @@ extension View { } #endif } - - /// NOTE: On iOS 14.4 and below, a bug prevented multiple sheet/fullScreenCover modifiers being chained - /// on the same view, so we conditionally add the sheet/cover modifiers as a workaround. See - /// https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14_5-release-notes - @ViewBuilder - func present(asSheet: Bool, isPresented: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) -> some View { - if asSheet { - self.sheet( - isPresented: isPresented, - onDismiss: onDismiss, - content: content - ) - } else { - self.cover( - isPresented: isPresented, - onDismiss: onDismiss, - content: content - ) - } - } } diff --git a/Sources/FlowStacks/View+presenting.swift b/Sources/FlowStacks/View+presenting.swift new file mode 100644 index 0000000..82940aa --- /dev/null +++ b/Sources/FlowStacks/View+presenting.swift @@ -0,0 +1,58 @@ +// +// File.swift +// +// +// Created by John Morgan on 16/01/2024. +// + +import Foundation +import SwiftUI + +struct PresentingModifier: ViewModifier { + @Binding var sheetBinding: Bool + @Binding var coverBinding: Bool + var destination: Destination + var onDismiss: (() -> Void)? + + func body(content: Content) -> some View { + /// NOTE: On iOS 14.4 and below, a bug prevented multiple sheet/fullScreenCover modifiers being chained + /// on the same view, so we conditionally add the sheet/cover modifiers as a workaround. See + /// https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-14_5-release-notes + if #available(iOS 14.5, *) { + content + .sheet( + isPresented: $sheetBinding, + onDismiss: onDismiss, + content: { destination } + ) + .cover( + isPresented: $coverBinding, + onDismiss: onDismiss, + content: { destination } + ) + + } else { + if sheetBinding { + content + .sheet( + isPresented: $sheetBinding, + onDismiss: onDismiss, + content: { destination } + ) + } else { + content + .cover( + isPresented: $coverBinding, + onDismiss: onDismiss, + content: { destination } + ) + } + } + } +} + +extension View { + func presenting(sheetBinding: Binding, coverBinding: Binding, destination: Destination, onDismiss: (() -> Void)?) -> some View { + return modifier(PresentingModifier(sheetBinding: sheetBinding, coverBinding: coverBinding, destination: destination, onDismiss: onDismiss)) + } +} diff --git a/Sources/FlowStacks/View+pushing.swift b/Sources/FlowStacks/View+pushing.swift new file mode 100644 index 0000000..56cd740 --- /dev/null +++ b/Sources/FlowStacks/View+pushing.swift @@ -0,0 +1,32 @@ +// +// File.swift +// +// +// Created by John Morgan on 16/01/2024. +// + +import Foundation +import SwiftUI + +struct PushingModifier: ViewModifier { + @Binding var isActiveBinding: Bool + var destination: Destination + + func body(content: Content) -> some View { + content + .background( + NavigationLink( + destination: destination, + isActive: $isActiveBinding, + label: EmptyView.init + ) + .hidden() + ) + } +} + +extension View { + func pushing(isActive: Binding, destination: Destination) -> some View { + return modifier(PushingModifier(isActiveBinding: isActive, destination: destination)) + } +}