From 34f70490b2317438e6e7c6050df0b602ec38ae63 Mon Sep 17 00:00:00 2001 From: Brody Roberts Date: Mon, 5 Oct 2026 17:52:14 -0700 Subject: [PATCH 1/2] dismissAction FlowLink param --- Sources/FlowStack/FlowStack.swift | 44 ++++++--- .../FlowDismissActionTests.swift | 95 +++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 Tests/FlowStackTests/FlowDismissActionTests.swift diff --git a/Sources/FlowStack/FlowStack.swift b/Sources/FlowStack/FlowStack.swift index c53a814..361d014 100644 --- a/Sources/FlowStack/FlowStack.swift +++ b/Sources/FlowStack/FlowStack.swift @@ -227,6 +227,7 @@ public struct FlowStack: View { private var overlay: () -> Overlay private var usesInternalPath: Bool = false + private var dismissAction: (() async -> Void)? @StateObject private var destinationLookup: DestinationLookup = .init() @StateObject var accessibilityManager: AccessibilityManager = .init() @@ -236,13 +237,15 @@ public struct FlowStack: View { /// - Parameters: /// - overlayAlignment: The alignment applied to the overlay. /// - customSmoothAnimation: The animation to use during flow transitions. + /// - dismissAction: An optional asynchronous action to finish before dismissing a destination. /// - root: The view to display when the stack is empty. /// - overlay: The view to overlay on the FlowStack. This view is always visible in front of any view presented by the flow stack. - public init(overlayAlignment: Alignment = .center, customSmoothAnimation: CustomSmoothAnimation? = nil, @ViewBuilder root: @escaping () -> Root, @ViewBuilder overlay: @escaping () -> Overlay) { + public init(overlayAlignment: Alignment = .center, customSmoothAnimation: CustomSmoothAnimation? = nil, dismissAction: (() async -> Void)? = nil, @ViewBuilder root: @escaping () -> Root, @ViewBuilder overlay: @escaping () -> Overlay) { self.root = root self.overlay = overlay self.overlayAlignment = overlayAlignment self.customSmoothAnimation = customSmoothAnimation ?? CustomSmoothAnimation.default + self.dismissAction = dismissAction self.usesInternalPath = true self._path = Binding(get: { FlowPath() }, set: { _ in }) @@ -253,14 +256,16 @@ public struct FlowStack: View { /// - path: A Binding to the flow path for this stack. /// - overlayAlignment: The alignment applied to the overlay. /// - customSmoothAnimation: The animation to use during flow transitions. + /// - dismissAction: An optional asynchronous action to finish before dismissing a destination. /// - root: The view to display when the stack is empty. /// - overlay: The view to overlay on the FlowStack. This view is always visible in front of any view presented by the flow stack. - public init(path: Binding, overlayAlignment: Alignment = .center, customSmoothAnimation: CustomSmoothAnimation? = nil, @ViewBuilder root: @escaping () -> Root, @ViewBuilder overlay: @escaping () -> Overlay) { + public init(path: Binding, overlayAlignment: Alignment = .center, customSmoothAnimation: CustomSmoothAnimation? = nil, dismissAction: (() async -> Void)? = nil, @ViewBuilder root: @escaping () -> Root, @ViewBuilder overlay: @escaping () -> Overlay) { self.root = root self.overlay = overlay self.overlayAlignment = overlayAlignment self._path = path self.customSmoothAnimation = customSmoothAnimation ?? CustomSmoothAnimation.default + self.dismissAction = dismissAction } private func destination(for instance: any (Hashable & Equatable)) -> AnyDestination? { @@ -298,18 +303,31 @@ public struct FlowStack: View { onDismiss: { guard let element = pathToUse.wrappedValue.elements.last else { return } - // The link comes into view first, so that it is already standing in for - // the destination when the destination starts zooming back into it. - linkContexts.revealLink(for: AnyHashable(element.value), atLevel: element.index, source: element.source) { - guard pathToUse.wrappedValue.elements.last == element else { return } - withTransaction(transaction) { - accessibilityManager.decrementIndex() - pathToUse.wrappedValue.removeLast() + if let dismissAction = dismissAction { + Task { @MainActor in + await dismissAction() + dismiss(element) } + } else { + dismiss(element) } }) } + private func dismiss(_ element: FlowElement) { + // The action may have changed the path while it was suspended. + guard pathToUse.wrappedValue.elements.last == element else { return } + + // Reveal after the action finishes, using the link's current layout. + linkContexts.revealLink(for: AnyHashable(element.value), atLevel: element.index, source: element.source) { + guard pathToUse.wrappedValue.elements.last == element else { return } + withTransaction(transaction) { + accessibilityManager.decrementIndex() + pathToUse.wrappedValue.removeLast() + } + } + } + private var transaction: Transaction { var transaction = Transaction(animation: customSmoothAnimation.animation) transaction.disablesAnimations = true @@ -373,8 +391,9 @@ public extension FlowStack where Overlay == EmptyView { /// Creates a flow stack that manages its own navigation state. /// - Parameters: /// - customSmoothAnimation: The animation to use during flow transitions. + /// - dismissAction: An optional asynchronous action to finish before dismissing a destination. /// - root: The view to display when the stack is empty. - init(customSmoothAnimation: CustomSmoothAnimation? = nil, @ViewBuilder root: @escaping () -> Root) { + init(customSmoothAnimation: CustomSmoothAnimation? = nil, dismissAction: (() async -> Void)? = nil, @ViewBuilder root: @escaping () -> Root) { self.root = root self.overlay = { EmptyView() } self.overlayAlignment = .center @@ -382,19 +401,22 @@ public extension FlowStack where Overlay == EmptyView { self.usesInternalPath = true self._path = Binding(get: { FlowPath() }, set: { _ in }) self.customSmoothAnimation = customSmoothAnimation ?? CustomSmoothAnimation.default + self.dismissAction = dismissAction } /// Creates a flow stack with heterogeneous navigation state that you can control. /// - Parameters: /// - path: A Binding to the flow path for this stack. /// - customSmoothAnimation: The animation to use during flow transitions. + /// - dismissAction: An optional asynchronous action to finish before dismissing a destination. /// - root: The view to display when the stack is empty. - init(path: Binding, customSmoothAnimation: CustomSmoothAnimation? = nil, @ViewBuilder root: @escaping () -> Root) { + init(path: Binding, customSmoothAnimation: CustomSmoothAnimation? = nil, dismissAction: (() async -> Void)? = nil, @ViewBuilder root: @escaping () -> Root) { self.root = root self.overlay = { EmptyView() } self.overlayAlignment = .center self._path = path self.customSmoothAnimation = customSmoothAnimation ?? CustomSmoothAnimation.default + self.dismissAction = dismissAction } } diff --git a/Tests/FlowStackTests/FlowDismissActionTests.swift b/Tests/FlowStackTests/FlowDismissActionTests.swift new file mode 100644 index 0000000..3188311 --- /dev/null +++ b/Tests/FlowStackTests/FlowDismissActionTests.swift @@ -0,0 +1,95 @@ +import SwiftUI +import XCTest +@testable import FlowStack + +@MainActor +final class FlowDismissActionTests: XCTestCase { + func testDismissWaitsForActionBeforeRemovingDestination() async throws { + let model = DismissInputs() + let host = HostedView(DismissFixture(model: model, action: { await model.pauseAction() })) + defer { host.close(); model.finishAction() } + + try await waitFor("dismiss environment") { model.dismiss != nil } + model.dismiss?() + try await waitFor("action to start") { model.continuation != nil } + XCTAssertEqual(model.path.count, 1) + + model.finishAction() + try await waitFor("dismissal after action") { model.path.isEmpty } + XCTAssertEqual(model.actionCalls, 1) + } + + func testActionChangingPathDoesNotDismissReplacement() async throws { + let model = DismissInputs() + let host = HostedView(DismissFixture(model: model, action: { await model.pauseAction() })) + defer { host.close(); model.finishAction() } + + try await waitFor("dismiss environment") { model.dismiss != nil } + model.dismiss?() + try await waitFor("action to start") { model.continuation != nil } + model.path.removeLast() + model.path.append("replacement") + model.finishAction() + try await waitFor("action to finish") { model.actionFinished } + // Allow the task that awaited the callback to resume as well. + await Task.yield() + XCTAssertTrue(model.path.contains("replacement", atLevel: 0)) + XCTAssertEqual(model.path.count, 1) + } + + func testDismissWithoutActionKeepsExistingBehavior() async throws { + let model = DismissInputs() + let host = HostedView(DismissFixture(model: model, action: nil)) + defer { host.close() } + + try await waitFor("dismiss environment") { model.dismiss != nil } + model.dismiss?() + try await waitFor("dismissal without an action") { model.path.isEmpty } + XCTAssertEqual(model.actionCalls, 0) + } +} + +private final class DismissInputs: ObservableObject { + @Published var path: FlowPath = { + var path = FlowPath() + path.append("initial") + return path + }() + var dismiss: FlowDismissAction? + var continuation: CheckedContinuation? + var actionCalls = 0 + var actionFinished = false + + @MainActor + func pauseAction() async { + actionCalls += 1 + await withCheckedContinuation { continuation = $0 } + actionFinished = true + } + + func finishAction() { + continuation?.resume() + continuation = nil + } +} + +private struct DismissFixture: View { + @ObservedObject var model: DismissInputs + let action: (() async -> Void)? + + var body: some View { + FlowStack(path: $model.path, dismissAction: action) { + DismissProbe(model: model) + .flowDestination(for: String.self) { Text($0) } + } + } +} + +private struct DismissProbe: View { + @Environment(\.flowDismiss) private var dismiss + let model: DismissInputs + + var body: some View { + Color.clear.onAppear { model.dismiss = dismiss } + } +} From a55d310a78ce671df8808b68f8f379bcfbf9f8fe Mon Sep 17 00:00:00 2001 From: Brody Roberts Date: Mon, 5 Oct 2026 18:04:33 -0700 Subject: [PATCH 2/2] Way simplier... --- Sources/FlowStack/FlowStack.swift | 33 +++---- .../FlowDismissActionTests.swift | 95 ------------------- 2 files changed, 11 insertions(+), 117 deletions(-) delete mode 100644 Tests/FlowStackTests/FlowDismissActionTests.swift diff --git a/Sources/FlowStack/FlowStack.swift b/Sources/FlowStack/FlowStack.swift index 361d014..4900822 100644 --- a/Sources/FlowStack/FlowStack.swift +++ b/Sources/FlowStack/FlowStack.swift @@ -301,33 +301,22 @@ public struct FlowStack: View { private var flowDismissAction: FlowDismissAction { FlowDismissAction( onDismiss: { - guard let element = pathToUse.wrappedValue.elements.last else { return } - - if let dismissAction = dismissAction { - Task { @MainActor in - await dismissAction() - dismiss(element) + Task { + guard let element = pathToUse.wrappedValue.elements.last else { return } + // The link comes into view first, so that it is already standing in for + // the destination when the destination starts zooming back into it. + await dismissAction?() + linkContexts.revealLink(for: AnyHashable(element.value), atLevel: element.index, source: element.source) { + guard pathToUse.wrappedValue.elements.last == element else { return } + withTransaction(transaction) { + accessibilityManager.decrementIndex() + pathToUse.wrappedValue.removeLast() + } } - } else { - dismiss(element) } }) } - private func dismiss(_ element: FlowElement) { - // The action may have changed the path while it was suspended. - guard pathToUse.wrappedValue.elements.last == element else { return } - - // Reveal after the action finishes, using the link's current layout. - linkContexts.revealLink(for: AnyHashable(element.value), atLevel: element.index, source: element.source) { - guard pathToUse.wrappedValue.elements.last == element else { return } - withTransaction(transaction) { - accessibilityManager.decrementIndex() - pathToUse.wrappedValue.removeLast() - } - } - } - private var transaction: Transaction { var transaction = Transaction(animation: customSmoothAnimation.animation) transaction.disablesAnimations = true diff --git a/Tests/FlowStackTests/FlowDismissActionTests.swift b/Tests/FlowStackTests/FlowDismissActionTests.swift deleted file mode 100644 index 3188311..0000000 --- a/Tests/FlowStackTests/FlowDismissActionTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -import SwiftUI -import XCTest -@testable import FlowStack - -@MainActor -final class FlowDismissActionTests: XCTestCase { - func testDismissWaitsForActionBeforeRemovingDestination() async throws { - let model = DismissInputs() - let host = HostedView(DismissFixture(model: model, action: { await model.pauseAction() })) - defer { host.close(); model.finishAction() } - - try await waitFor("dismiss environment") { model.dismiss != nil } - model.dismiss?() - try await waitFor("action to start") { model.continuation != nil } - XCTAssertEqual(model.path.count, 1) - - model.finishAction() - try await waitFor("dismissal after action") { model.path.isEmpty } - XCTAssertEqual(model.actionCalls, 1) - } - - func testActionChangingPathDoesNotDismissReplacement() async throws { - let model = DismissInputs() - let host = HostedView(DismissFixture(model: model, action: { await model.pauseAction() })) - defer { host.close(); model.finishAction() } - - try await waitFor("dismiss environment") { model.dismiss != nil } - model.dismiss?() - try await waitFor("action to start") { model.continuation != nil } - model.path.removeLast() - model.path.append("replacement") - model.finishAction() - try await waitFor("action to finish") { model.actionFinished } - // Allow the task that awaited the callback to resume as well. - await Task.yield() - XCTAssertTrue(model.path.contains("replacement", atLevel: 0)) - XCTAssertEqual(model.path.count, 1) - } - - func testDismissWithoutActionKeepsExistingBehavior() async throws { - let model = DismissInputs() - let host = HostedView(DismissFixture(model: model, action: nil)) - defer { host.close() } - - try await waitFor("dismiss environment") { model.dismiss != nil } - model.dismiss?() - try await waitFor("dismissal without an action") { model.path.isEmpty } - XCTAssertEqual(model.actionCalls, 0) - } -} - -private final class DismissInputs: ObservableObject { - @Published var path: FlowPath = { - var path = FlowPath() - path.append("initial") - return path - }() - var dismiss: FlowDismissAction? - var continuation: CheckedContinuation? - var actionCalls = 0 - var actionFinished = false - - @MainActor - func pauseAction() async { - actionCalls += 1 - await withCheckedContinuation { continuation = $0 } - actionFinished = true - } - - func finishAction() { - continuation?.resume() - continuation = nil - } -} - -private struct DismissFixture: View { - @ObservedObject var model: DismissInputs - let action: (() async -> Void)? - - var body: some View { - FlowStack(path: $model.path, dismissAction: action) { - DismissProbe(model: model) - .flowDestination(for: String.self) { Text($0) } - } - } -} - -private struct DismissProbe: View { - @Environment(\.flowDismiss) private var dismiss - let model: DismissInputs - - var body: some View { - Color.clear.onAppear { model.dismiss = dismiss } - } -}