From b01092bf1a1f0865e261241a2f3534f54c707507 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:22:59 -0700 Subject: [PATCH 01/50] Check in shared FlowStackExample scheme CI builds the FlowStackExample scheme; sharing the scheme makes the build deterministic instead of relying on Xcode's auto-generated schemes. --- .../xcschemes/FlowStackExample.xcscheme | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 FlowStackExample/FlowStackExample.xcodeproj/xcshareddata/xcschemes/FlowStackExample.xcscheme diff --git a/FlowStackExample/FlowStackExample.xcodeproj/xcshareddata/xcschemes/FlowStackExample.xcscheme b/FlowStackExample/FlowStackExample.xcodeproj/xcshareddata/xcschemes/FlowStackExample.xcscheme new file mode 100644 index 0000000..8ca2333 --- /dev/null +++ b/FlowStackExample/FlowStackExample.xcodeproj/xcshareddata/xcschemes/FlowStackExample.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 09e53e4bba7e063b74dad20e1f39ac7eaa7836b9 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:27:57 -0700 Subject: [PATCH 02/50] Remove unused CachedAsyncImage dependency from library target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in Sources/ imports CachedAsyncImage — it is only a recommendation for consumers (documented in the README) and a convenience used by the example app. Declaring it as a target dependency forced every adopter to pull it transitively. - Drop the dependency from Package.swift, making FlowStack a zero-dependency package; the example app now declares swiftui-cached-async-image directly. - Delete the root Package.resolved (no dependencies remain). - Bump swift-tools-version 5.7 -> 5.9. - Add a PrivacyInfo.xcprivacy resource (no tracking, no collected data, no required-reason APIs), now expected of third-party SDKs. --- .../project.pbxproj | 22 +++++++++++++++++++ Package.resolved | 14 ------------ Package.swift | 11 ++-------- Sources/FlowStack/PrivacyInfo.xcprivacy | 14 ++++++++++++ 4 files changed, 38 insertions(+), 23 deletions(-) delete mode 100644 Package.resolved create mode 100644 Sources/FlowStack/PrivacyInfo.xcprivacy diff --git a/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj b/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj index 7ba7ce0..9f7e56c 100644 --- a/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj +++ b/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ ED8DE1652A6765F600215165 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ED8DE1642A6765F600215165 /* Assets.xcassets */; }; ED8DE1682A6765F600215165 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ED8DE1672A6765F600215165 /* Preview Assets.xcassets */; }; ED8DE1742A6767B900215165 /* FlowStack in Frameworks */ = {isa = PBXBuildFile; productRef = ED8DE1732A6767B900215165 /* FlowStack */; }; + EDCA5E012E3000000000A001 /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = EDCA5E022E3000000000A002 /* CachedAsyncImage */; }; EDA1DD382A67693A00D82A9D /* URLCache+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1DD362A67693A00D82A9D /* URLCache+Extensions.swift */; }; EDA1DD392A67693A00D82A9D /* ProductDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1DD352A67693A00D82A9D /* ProductDetails.swift */; }; EDA1DD3A2A67693A00D82A9D /* FlowStackExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1DD372A67693A00D82A9D /* FlowStackExampleApp.swift */; }; @@ -38,6 +39,7 @@ buildActionMask = 2147483647; files = ( ED8DE1742A6767B900215165 /* FlowStack in Frameworks */, + EDCA5E012E3000000000A001 /* CachedAsyncImage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,6 +121,7 @@ name = FlowStackExample; packageProductDependencies = ( ED8DE1732A6767B900215165 /* FlowStack */, + EDCA5E022E3000000000A002 /* CachedAsyncImage */, ); productName = FlowStackExample; productReference = ED8DE15D2A6765F500215165 /* FlowStackExample.app */; @@ -148,6 +151,9 @@ Base, ); mainGroup = ED8DE1542A6765F500215165; + packageReferences = ( + EDCA5E032E3000000000A003 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, + ); productRefGroup = ED8DE15E2A6765F500215165 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -389,11 +395,27 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + EDCA5E032E3000000000A003 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ ED8DE1732A6767B900215165 /* FlowStack */ = { isa = XCSwiftPackageProductDependency; productName = FlowStack; }; + EDCA5E022E3000000000A002 /* CachedAsyncImage */ = { + isa = XCSwiftPackageProductDependency; + package = EDCA5E032E3000000000A003 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; + productName = CachedAsyncImage; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = ED8DE1552A6765F500215165 /* Project object */; diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 3272b8d..0000000 --- a/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swiftui-cached-async-image", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git", - "state" : { - "revision" : "467a3d17479887943ab917a379e62bbaff60ac8a", - "version" : "2.1.1" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift index 627a292..fc2c9a7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,21 +7,14 @@ let package = Package( name: "FlowStack", platforms: [.iOS(.v15)], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "FlowStack", targets: ["FlowStack"]), ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git", from: "2.1.0"), - ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "FlowStack", - dependencies: [.product(name: "CachedAsyncImage", package: "swiftui-cached-async-image")]), + resources: [.copy("PrivacyInfo.xcprivacy")]), .testTarget( name: "FlowStackTests", dependencies: ["FlowStack"]), diff --git a/Sources/FlowStack/PrivacyInfo.xcprivacy b/Sources/FlowStack/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..e08a130 --- /dev/null +++ b/Sources/FlowStack/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + From 05d701fd02f4a1e3e7d5bc29ec7fcbaae6272f4d Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:28:58 -0700 Subject: [PATCH 03/50] Fix FlowElement equality and hashing; harden FlowPath FlowElement.== compared value hashValues, so two distinct values with colliding hashes were treated as the same path element (wrong results from contains(_:atLevel:) and unstable ForEach identity). Compare and hash via AnyHashable so real value equality is used, and include the value in hash(into:) so element identity reflects content. Also in FlowPath: - removeLast(_:) now clamps to the available element count instead of crashing when asked to remove more elements than exist. - Add removeAll() for popping to the root programmatically. - Make isEmpty public (count already was). Replace the template test file with real coverage for FlowPath and FlowElement, including a hash-collision regression test. --- Sources/FlowStack/FlowPath.swift | 19 ++- Tests/FlowStackTests/FlowStackTests.swift | 155 +++++++++++++++++++++- 2 files changed, 161 insertions(+), 13 deletions(-) diff --git a/Sources/FlowStack/FlowPath.swift b/Sources/FlowStack/FlowPath.swift index 27b09ec..dd4249c 100644 --- a/Sources/FlowStack/FlowPath.swift +++ b/Sources/FlowStack/FlowPath.swift @@ -50,13 +50,12 @@ struct FlowElement: Equatable, Hashable { var index: Int static func == (lhs: FlowElement, rhs: FlowElement) -> Bool { - lhs.value.hashValue == rhs.value.hashValue && - _mangledTypeName(type(of: lhs.value)) == _mangledTypeName(type(of: rhs.value)) && + AnyHashable(lhs.value) == AnyHashable(rhs.value) && lhs.index == rhs.index } func hash(into hasher: inout Hasher) { - hasher.combine(_mangledTypeName(type(of: value))) + hasher.combine(AnyHashable(value)) hasher.combine(index) } } @@ -70,7 +69,8 @@ public struct FlowPath: Equatable, Hashable { elements = [] } - var isEmpty: Bool { + /// A Boolean that indicates whether the flow path is empty. + public var isEmpty: Bool { elements.isEmpty } @@ -84,10 +84,15 @@ public struct FlowPath: Equatable, Hashable { } /// Removes the specified number of elements from the end of the flow path. - /// - Parameter count: The number of elements to remove from the collection. Count must be greater than or equal to zero and must not exceed the number of elements in the flow path. + /// - Parameter count: The number of elements to remove from the flow path. If `count` exceeds the + /// number of elements in the flow path, all elements are removed. public mutating func removeLast(_ count: Int = 1) { - guard !isEmpty else { return } - elements.removeLast(count) + elements.removeLast(Swift.min(Swift.max(count, 0), elements.count)) + } + + /// Removes all elements from the flow path, returning to the root view. + public mutating func removeAll() { + elements.removeAll() } mutating func append

(_ newElement: P, context: PathContext?) where P: Hashable { diff --git a/Tests/FlowStackTests/FlowStackTests.swift b/Tests/FlowStackTests/FlowStackTests.swift index eef770c..6bfaab8 100644 --- a/Tests/FlowStackTests/FlowStackTests.swift +++ b/Tests/FlowStackTests/FlowStackTests.swift @@ -1,11 +1,154 @@ import XCTest @testable import FlowStack -final class FlowStackTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - // XCTAssertEqual(FlowStack().text, "Hello, World!") +final class FlowPathTests: XCTestCase { + + func testAppendUpdatesCountAndIsEmpty() { + var path = FlowPath() + XCTAssertTrue(path.isEmpty) + XCTAssertEqual(path.count, 0) + + path.append("first") + path.append(2) + + XCTAssertFalse(path.isEmpty) + XCTAssertEqual(path.count, 2) + } + + func testAppendAssignsIncreasingIndices() { + var path = FlowPath() + path.append("a") + path.append("b") + path.append("c") + + XCTAssertEqual(path.elements.map(\.index), [0, 1, 2]) + } + + func testRemoveLast() { + var path = FlowPath() + path.append("a") + path.append("b") + + path.removeLast() + XCTAssertEqual(path.count, 1) + + path.removeLast() + XCTAssertTrue(path.isEmpty) + } + + func testRemoveLastOnEmptyPathDoesNothing() { + var path = FlowPath() + path.removeLast() + XCTAssertTrue(path.isEmpty) + } + + func testRemoveLastClampsCountToAvailableElements() { + var path = FlowPath() + path.append("a") + path.append("b") + + path.removeLast(5) + XCTAssertTrue(path.isEmpty) + } + + func testRemoveLastIgnoresNegativeCount() { + var path = FlowPath() + path.append("a") + + path.removeLast(-1) + XCTAssertEqual(path.count, 1) + } + + func testRemoveAll() { + var path = FlowPath() + path.append("a") + path.append("b") + path.append("c") + + path.removeAll() + XCTAssertTrue(path.isEmpty) + } + + func testContainsAtAnyLevel() { + var path = FlowPath() + path.append("a") + path.append("b") + + XCTAssertTrue(path.contains("a", atLevel: nil)) + XCTAssertTrue(path.contains("b", atLevel: nil)) + XCTAssertFalse(path.contains("c", atLevel: nil)) + } + + func testContainsAtSpecificLevel() { + var path = FlowPath() + path.append("a") + path.append("b") + + XCTAssertTrue(path.contains("a", atLevel: 0)) + XCTAssertFalse(path.contains("a", atLevel: 1)) + XCTAssertTrue(path.contains("b", atLevel: 1)) + } + + func testEquality() { + var first = FlowPath() + first.append("a") + + var second = FlowPath() + second.append("a") + + XCTAssertEqual(first, second) + + second.append("b") + XCTAssertNotEqual(first, second) + } +} + +final class FlowElementTests: XCTestCase { + + /// A value with a deliberately constant hash, so that distinct values collide. + private struct CollidingValue: Hashable { + let id: Int + + func hash(into hasher: inout Hasher) { + hasher.combine(0) + } + } + + func testEqualValuesAtSameIndexAreEqual() { + let lhs = FlowElement(value: "a", context: nil, index: 0) + let rhs = FlowElement(value: "a", context: nil, index: 0) + XCTAssertEqual(lhs, rhs) + } + + func testDifferentIndicesAreNotEqual() { + let lhs = FlowElement(value: "a", context: nil, index: 0) + let rhs = FlowElement(value: "a", context: nil, index: 1) + XCTAssertNotEqual(lhs, rhs) + } + + func testDifferentValuesOfSameTypeAreNotEqual() { + let lhs = FlowElement(value: "a", context: nil, index: 0) + let rhs = FlowElement(value: "b", context: nil, index: 0) + XCTAssertNotEqual(lhs, rhs) + } + + func testDifferentValueTypesAreNotEqual() { + let lhs = FlowElement(value: "a", context: nil, index: 0) + let rhs = FlowElement(value: 1, context: nil, index: 0) + XCTAssertNotEqual(lhs, rhs) + } + + func testHashCollidingValuesAreNotEqual() { + // Distinct values whose hashes collide must still compare as different + // elements; equality is based on value equality, not hash equality. + let lhs = FlowElement(value: CollidingValue(id: 1), context: nil, index: 0) + let rhs = FlowElement(value: CollidingValue(id: 2), context: nil, index: 0) + XCTAssertNotEqual(lhs, rhs) + } + + func testHashIncorporatesValue() { + let lhs = FlowElement(value: "a", context: nil, index: 0) + let rhs = FlowElement(value: "b", context: nil, index: 0) + XCTAssertNotEqual(lhs.hashValue, rhs.hashValue) } } From 606fbca382f5355c665291c522616656bcc0e158 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:30:11 -0700 Subject: [PATCH 04/50] Harden FlowStack internals and remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hold DestinationLookup in @StateObject instead of @State so FlowStack re-renders when destinations register. This also fixes programmatic navigation racing destination registration: appending to the path before a flowDestination's onAppear no longer strands the element — the stack re-evaluates once the table updates. - Log an os.Logger warning when no flowDestination is registered for a presented value instead of silently rendering nothing. - Replace the force-unwrapped _mangledTypeName and the fatalError cast in flowDestination with assertionFailure + graceful fallback, and drop the path! force unwrap in FlowTransactionModifier. - Remove dead code: unused flowDismiss environment property on FlowStack, unused environmentList and non-private @State vars on FlowLink, and the write-only isEnded state in FlowPresentModifier. --- Sources/FlowStack/FlowLink.swift | 5 ++--- Sources/FlowStack/FlowStack.swift | 21 +++++++++++++++------ Sources/FlowStack/FlowTransition.swift | 3 --- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Sources/FlowStack/FlowLink.swift b/Sources/FlowStack/FlowLink.swift index b975fa4..51ff995 100644 --- a/Sources/FlowStack/FlowLink.swift +++ b/Sources/FlowStack/FlowLink.swift @@ -280,10 +280,9 @@ public struct FlowLink

(_ element: P, atLevel level: Int?) -> Bool where P: Hashable { - return elements.contains { $0 == FlowElement(value: element, context: $0.context, index: level ?? $0.index) } + self.element(presenting: element, atLevel: level) != nil + } + + func element

(presenting value: P, atLevel level: Int?) -> FlowElement? where P: Hashable { + elements.first { $0 == FlowElement(value: value, context: $0.context, index: level ?? $0.index) } } /// Removes the specified number of elements from the end of the flow path. @@ -96,8 +103,8 @@ public struct FlowPath: Equatable, Hashable { elements.removeAll() } - mutating func append

(_ newElement: P, context: PathContext?) where P: Hashable { - self.elements.append(.init(value: newElement, context: context, index: elements.count)) + mutating func append

(_ newElement: P, context: PathContext?, source: FlowLinkSource? = nil) where P: Hashable { + self.elements.append(.init(value: newElement, context: context, source: source, index: elements.count)) } /// Adds a new element at the end of the flow path. @@ -107,6 +114,17 @@ public struct FlowPath: Equatable, Hashable { self.append(newElement, context: nil) } + /// Adds a new element at the end of the flow path, presented from a particular flow link. + /// + /// Use this when more than one flow link presents `newElement`, to choose which of them + /// the destination zooms out of and back into. + /// - Parameters: + /// - newElement: The element to append to the flow path. + /// - linkID: The identifier given to the flow link with `flowLinkID(_:)`. + public mutating func append(_ newElement: P, linkID: ID) where P: Hashable, ID: Hashable { + self.append(newElement, context: nil, source: FlowLinkSource(link: .explicit(AnyHashable(linkID)))) + } + /// Does nothing. Flow links keep a snapshot for each color scheme, and the right one is /// chosen as a transition renders, so there is nothing to update when the scheme changes. @available(*, deprecated, message: "This no longer does anything and can be removed. The snapshot for the current color scheme is chosen automatically.") diff --git a/Sources/FlowStack/FlowStack.swift b/Sources/FlowStack/FlowStack.swift index ad5f38e..6908db3 100644 --- a/Sources/FlowStack/FlowStack.swift +++ b/Sources/FlowStack/FlowStack.swift @@ -300,7 +300,7 @@ public struct FlowStack: View { // 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) { + linkContexts.revealLink(for: AnyHashable(element.value), atLevel: element.index, source: element.source) { guard pathToUse.wrappedValue.elements.last == element else { return } withTransaction(transaction) { accessibilityManager.decrementIndex() @@ -336,7 +336,7 @@ public struct FlowStack: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .id(element.hashValue) - .transition(.flowTransition(with: element.context, value: AnyHashable(element.value), level: element.index)) + .transition(.flowTransition(with: element.context, source: element.source, value: AnyHashable(element.value), level: element.index)) .modifier(AccessibilityModifier(element: element.index)) } } diff --git a/Sources/FlowStack/FlowTransition.swift b/Sources/FlowStack/FlowTransition.swift index 4b8cded..a41106e 100644 --- a/Sources/FlowStack/FlowTransition.swift +++ b/Sources/FlowStack/FlowTransition.swift @@ -40,10 +40,10 @@ struct FlowDismissActionKey: EnvironmentKey { extension AnyTransition { - static func flowTransition(with context: PathContext?, value: AnyHashable, level: Int) -> AnyTransition { + static func flowTransition(with context: PathContext?, source: FlowLinkSource?, value: AnyHashable, level: Int) -> AnyTransition { AnyTransition.modifier( - active: FlowPresentModifier(percent: 0, context: context, value: value, level: level), - identity: FlowPresentModifier(percent: 1, context: context, value: value, level: level) + active: FlowPresentModifier(percent: 0, context: context, source: source, value: value, level: level), + identity: FlowPresentModifier(percent: 1, context: context, source: source, value: value, level: level) ) } @@ -118,6 +118,8 @@ extension AnyTransition { /// The context captured when the flow link was activated, or `nil` for a /// destination that was appended to the flow path directly. var context: PathContext? + /// The flow link the destination was presented from, where that is known. + var source: FlowLinkSource? var value: AnyHashable var level: Int @@ -140,7 +142,7 @@ extension AnyTransition { /// each time the view renders lets a dismissal find the link where it is now. A /// destination appended directly adopts the context of a matching link, if any. private var resolvedContext: PathContext { - guard let live = linkContexts?.context(for: value, atLevel: level) else { return context ?? .init() } + guard let live = linkContexts?.context(for: value, atLevel: level, source: source) else { return context ?? .init() } guard var resolved = context else { return live } resolved.anchor = live.anchor resolved.overrideAnchor = live.overrideAnchor @@ -281,7 +283,7 @@ extension AnyTransition { // The pull is just starting, so the destination still covers // its link. Waiting for the release would move the link in // plain sight behind the shrunken destination. - linkContexts?.revealLink(for: value, atLevel: level) + linkContexts?.revealLink(for: value, atLevel: level, source: source) } self.snapCornerRadiusZero = false self.panOffset = offset diff --git a/Tests/FlowStackTests/FlowStackTests.swift b/Tests/FlowStackTests/FlowStackTests.swift index b04c45f..5feabd5 100644 --- a/Tests/FlowStackTests/FlowStackTests.swift +++ b/Tests/FlowStackTests/FlowStackTests.swift @@ -547,3 +547,134 @@ final class SafeAreaCompensationTests: XCTestCase { XCTAssertFalse(portrait.insetBy(dx: 0, dy: 0.5).isApproximatelyEqual(to: portrait)) } } + +final class FlowLinkIdentityTests: XCTestCase { + + private func key(_ value: String, level: Int? = 0) -> FlowLinkContextStore.Key { + .init(value: AnyHashable(value), level: level) + } + + private let a = AnyHashable("a") + + // MARK: Which link a destination came from + + func testDestinationComesFromTheLinkThatPresentedIt() { + let tapped = FlowLinkIdentity.instance(owner()), twin = FlowLinkIdentity.instance(owner()) + let source = FlowLinkSource(link: tapped, passedOver: [twin]) + + XCTAssertTrue(source.includes(tapped)) + XCTAssertFalse(source.includes(twin)) + } + + func testLinkCreatedSinceIsTakenForTheOneThatWasRecreated() { + // A change of layout replaces the link that was activated with another instance. + let source = FlowLinkSource(link: .instance(owner()), passedOver: [.instance(owner())]) + XCTAssertTrue(source.includes(.instance(owner()))) + } + + func testExplicitIdentitiesOnlyEverMatchThemselves() { + let source = FlowLinkSource(link: .explicit("featured")) + + XCTAssertTrue(source.includes(.explicit("featured"))) + XCTAssertFalse(source.includes(.explicit("list"))) + XCTAssertFalse(source.includes(.instance(owner()))) + // Nor is a link with an identifier ever taken for a recreated link without one. + XCTAssertFalse(FlowLinkSource(link: .instance(owner())).includes(.explicit("featured"))) + } + + func testAppendingWithALinkIDRecordsTheLink() { + var path = FlowPath() + path.append("a", linkID: "featured") + path.append("b") + + XCTAssertEqual(path.elements[0].source, FlowLinkSource(link: .explicit("featured"))) + XCTAssertNil(path.elements[1].source) + } + + // MARK: Links presenting the same value + + func testEachLinkPresentingAValueKeepsItsOwnContext() { + let store = FlowLinkContextStore() + let featured = owner(), listed = owner() + store.update(PathContext(cornerRadius: 1), for: key("a"), owner: featured, reveal: {}) + store.update(PathContext(cornerRadius: 2), for: key("a"), owner: listed, reveal: {}) + + let fromFeatured = FlowLinkSource(link: .instance(featured), passedOver: [.instance(listed)]) + let fromListed = FlowLinkSource(link: .instance(listed), passedOver: [.instance(featured)]) + XCTAssertEqual(store.context(for: a, atLevel: 0, source: fromFeatured)?.cornerRadius, 1) + XCTAssertEqual(store.context(for: a, atLevel: 0, source: fromListed)?.cornerRadius, 2) + + // The link reporting again, as it does when it scrolls, changes nothing. + store.update(PathContext(cornerRadius: 1), for: key("a"), owner: featured, reveal: {}) + XCTAssertEqual(store.context(for: a, atLevel: 0, source: fromListed)?.cornerRadius, 2) + } + + func testLinkKnowsWhichOthersPresentItsValue() { + let store = FlowLinkContextStore() + let featured = owner(), listed = owner(), overlaid = owner(), deeper = owner(), unrelated = owner() + store.update(PathContext(), for: key("a"), owner: featured, reveal: {}) + store.update(PathContext(), for: key("a"), owner: listed, reveal: {}) + store.update(PathContext(), for: key("a", level: nil), owner: overlaid, reveal: {}) + store.update(PathContext(), for: key("a", level: 1), owner: deeper, reveal: {}) + store.update(PathContext(), for: key("b"), owner: unrelated, reveal: {}) + + let others = store.identities(presentingSameValueAs: key("a"), otherThan: .instance(featured)) + XCTAssertEqual(others, [.instance(listed), .instance(overlaid)]) + } + + func testDestinationReturnsToTheRecreatedLinkRatherThanTheOneItPassedOver() { + let store = FlowLinkContextStore() + let featured = owner(), listed = owner(), recreated = owner() + store.update(PathContext(cornerRadius: 1), for: key("a"), owner: featured, reveal: {}) + store.update(PathContext(cornerRadius: 2), for: key("a"), owner: listed, reveal: {}) + let source = FlowLinkSource(link: .instance(featured), passedOver: [.instance(listed)]) + + // The replacement registers before the link it replaces disappears, and the + // destination stays with the link it came from for as long as that lasts. + store.update(PathContext(cornerRadius: 3), for: key("a"), owner: recreated, reveal: {}) + XCTAssertEqual(store.context(for: a, atLevel: 0, source: source)?.cornerRadius, 1) + + store.remove(key("a"), owner: featured) + XCTAssertEqual(store.context(for: a, atLevel: 0, source: source)?.cornerRadius, 3) + + // With only the link it passed over left, it has nowhere to return to. + store.remove(key("a"), owner: recreated) + XCTAssertNil(store.context(for: a, atLevel: 0, source: source)) + } + + func testLinkWithAnIdentifierIsFoundAgainHoweverItIsRecreated() { + let store = FlowLinkContextStore() + let featured = owner(), listed = owner() + store.update(PathContext(cornerRadius: 1), for: key("a"), identity: .explicit("featured"), owner: featured, reveal: {}) + store.update(PathContext(cornerRadius: 2), for: key("a"), identity: .explicit("list"), owner: listed, reveal: {}) + let source = FlowLinkSource(link: .explicit("featured")) + + // Both links are recreated, the listed one last. + let newFeatured = owner(), newListed = owner() + store.update(PathContext(cornerRadius: 3), for: key("a"), identity: .explicit("featured"), owner: newFeatured, reveal: {}) + store.update(PathContext(cornerRadius: 4), for: key("a"), identity: .explicit("list"), owner: newListed, reveal: {}) + store.remove(key("a"), owner: featured) + store.remove(key("a"), owner: listed) + + XCTAssertEqual(store.context(for: a, atLevel: 0, source: source)?.cornerRadius, 3) + } + + func testValueAppendedDirectlyReturnsToTheLatestLinkPresentingIt() { + let store = FlowLinkContextStore() + store.update(PathContext(cornerRadius: 1), for: key("a"), owner: owner(), reveal: {}) + store.update(PathContext(cornerRadius: 2), for: key("a"), owner: owner(), reveal: {}) + + XCTAssertEqual(store.context(for: a, atLevel: 0)?.cornerRadius, 2) + } + + func testRevealScrollsTheLinkTheDestinationCameFrom() { + let store = FlowLinkContextStore() + let featured = owner(), listed = owner() + var revealed: [String] = [] + store.update(PathContext(), for: key("a"), owner: featured, reveal: { revealed.append("featured") }) + store.update(PathContext(), for: key("a"), owner: listed, reveal: { revealed.append("listed") }) + + store.revealLink(for: a, atLevel: 0, source: FlowLinkSource(link: .instance(featured), passedOver: [.instance(listed)])) + XCTAssertEqual(revealed, ["featured"]) + } +} From a0bd84f9ce1de3305e625e55cb63c0e3fe0b2c73 Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 22 Sep 2026 00:24:06 -0700 Subject: [PATCH 36/50] Hold every edge of a nested navigation stack's content during a pull, on every OS A navigation controller works its content's safe area out for itself, from where the content sits on screen, so holding the destination's own safe area never reached it. Its content gains inset on an edge as it overhangs that edge of the screen: on a folded iPhone Duo an edge swipe took the trailing inset from 84 to 84 plus the overhang (111 at 27pt), narrowing the content under the finger so its text rewrapped, while the destination itself held at 84 throughout. A pull down likewise took the top inset from 24 to 10. The hold on a nested navigation stack, which so far held only the top inset and only before iOS 26, now holds all four edges of the top view controller's safe area at their at-rest values on every OS, through its additional safe area insets. Moving the bar back stays a pre-iOS 26 thing, as later systems move it with the view. The content's safe area is observed directly, since the navigation controller sets it in its own layout pass, after everything else that was being watched has moved. Measured on the folded Duo (iOS 27.1): edge swipe R84 and pull down T24 held throughout; on iOS 18.6 the pull down holds T100 as before. --- .../FlowStack/View+InteractiveDismiss.swift | 105 ++++++++++-------- 1 file changed, 58 insertions(+), 47 deletions(-) diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift index 8b3f99f..5032597 100644 --- a/Sources/FlowStack/View+InteractiveDismiss.swift +++ b/Sources/FlowStack/View+InteractiveDismiss.swift @@ -160,7 +160,7 @@ class InteractiveDismissViewController: UIHostingController: UIHostingController Constants.tolerance { - bar.transform = CGAffineTransform(translationX: 0, y: offset) + if let barMinY = held.barMinY { + let offset = barMinY - (bar.center.y - bar.bounds.height / 2) + if abs(bar.transform.ty - offset) > Constants.tolerance { + bar.transform = CGAffineTransform(translationX: 0, y: offset) + } } guard let content = navigationController.topViewController else { continue } - let added = held.addedInset + held.contentTopInset - content.view.safeAreaInsets.top - if abs(added - held.addedInset) > Constants.tolerance { - heldNavigationBars?[index].addedInset = added - content.additionalSafeAreaInsets.top += added - held.addedInset + let added = held.addedInsets + held.contentInsets - content.view.safeAreaInsets + if !added.isApproximatelyEqual(to: held.addedInsets, tolerance: Constants.tolerance) { + heldNavigationStacks?[index].addedInsets = added + content.additionalSafeAreaInsets = content.additionalSafeAreaInsets + added - held.addedInsets } } } - private func releaseNavigationBars() { - for held in heldNavigationBars ?? [] { - held.observation?.invalidate() + private func releaseNavigationStacks() { + for held in heldNavigationStacks ?? [] { + held.observations.forEach { $0.invalidate() } guard let navigationController = held.navigationController else { continue } navigationController.navigationBar.transform = .identity - if held.addedInset != 0, let content = navigationController.topViewController { - content.additionalSafeAreaInsets.top -= held.addedInset + if held.addedInsets != .zero, let content = navigationController.topViewController { + content.additionalSafeAreaInsets = content.additionalSafeAreaInsets - held.addedInsets } } - heldNavigationBars = nil + heldNavigationStacks = nil } private func navigationControllers(in viewController: UIViewController) -> [UINavigationController] { @@ -516,6 +519,14 @@ extension View { } extension UIEdgeInsets { + static func + (lhs: UIEdgeInsets, rhs: UIEdgeInsets) -> UIEdgeInsets { + UIEdgeInsets(top: lhs.top + rhs.top, left: lhs.left + rhs.left, bottom: lhs.bottom + rhs.bottom, right: lhs.right + rhs.right) + } + + static func - (lhs: UIEdgeInsets, rhs: UIEdgeInsets) -> UIEdgeInsets { + UIEdgeInsets(top: lhs.top - rhs.top, left: lhs.left - rhs.left, bottom: lhs.bottom - rhs.bottom, right: lhs.right - rhs.right) + } + /// Whether the insets differ by less than could be seen. Setting a view controller's /// additional safe area insets changes its safe area, which is one of the things that /// prompts them to be worked out, so this is what lets that settle. From b2d58a59dcf584ee73498a8c5d300dbd82f5e5ff Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 22 Sep 2026 00:41:35 -0700 Subject: [PATCH 37/50] Hold a navigation stack's floating items in place during a pull From iOS 26 a navigation stack can float its toolbar items in a pocket beside the screen's system UI, as a folded iPhone Duo does beside its camera column. The pocket is placed on screen, so as a pull moved the destination the items stayed put on screen and slid about inside it: a close button pulled down and right drifted up and left in the destination by a third of the pull, and pulled down and left, where the pocket can't follow, it climbed toward the destination's corner. Each control in such a pocket is now moved back to where it sat at rest, with the compact views around it that its layout places as one, by a transform that the layout leaves be. Moving the pocket's container instead doesn't work: the layout reads where the container is on screen and places the pocket there again, and on the side where nothing clamps it that never settles and hangs the app. Measured on the folded Duo (iOS 27.1): the close button holds at its resting position through pulls in every direction and an edge swipe, and is released on settle. A plain iPhone on iOS 27.0 keeps its items in the bar, which moves with the destination already, and is unaffected. --- .../FlowStack/View+InteractiveDismiss.swift | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift index 5032597..31d3f62 100644 --- a/Sources/FlowStack/View+InteractiveDismiss.swift +++ b/Sources/FlowStack/View+InteractiveDismiss.swift @@ -185,6 +185,25 @@ class InteractiveDismissViewController: UIHostingController: UIHostingController: UIHostingController Constants.tolerance || abs(translation.y - item.transform.ty) > Constants.tolerance { + item.transform = CGAffineTransform(translationX: translation.x, y: translation.y) + } + } + guard let content = navigationController.topViewController else { continue } let added = held.addedInsets + held.contentInsets - content.view.safeAreaInsets if !added.isApproximatelyEqual(to: held.addedInsets, tolerance: Constants.tolerance) { @@ -244,6 +277,10 @@ class InteractiveDismissViewController: UIHostingController: UIHostingController [HeldFloatingItem] { + guard #available(iOS 26.0, *) else { return [] } + let content = navigationController.topViewController?.view + + return navigationController.view.subviews.flatMap { container -> [HeldFloatingItem] in + guard container !== navigationController.navigationBar, content?.isDescendant(of: container) != true else { return [] } + + return controls(in: container).compactMap { control in + var item: UIView = control + while let superview = item.superview, superview !== container, + superview.bounds.width <= Constants.maxFloatingItemSize, superview.bounds.height <= Constants.maxFloatingItemSize { + item = superview + } + guard item.transform.isIdentity, let superview = control.superview else { return nil } + + var held = HeldFloatingItem(item: item, control: control, center: superview.convert(control.center, to: view)) + var ancestor: UIView? = item + while let current = ancestor, current !== container { + held.observations.append(current.layer.observe(\.position) { [weak self] _, _ in + self?.holdNavigationStacks() + }) + ancestor = current.superview + } + return held + } + } + } + + private func controls(in view: UIView) -> [UIView] { + view.subviews.flatMap { $0 is UIControl ? [$0] : controls(in: $0) } + } + private func navigationControllers(in viewController: UIViewController) -> [UINavigationController] { viewController.children.flatMap { child -> [UINavigationController] in if let navigationController = child as? UINavigationController { @@ -263,6 +335,8 @@ class InteractiveDismissViewController: UIHostingController Date: Tue, 22 Sep 2026 09:23:39 -0700 Subject: [PATCH 38/50] Place a nested navigation bar again once a spring presentation has settled Before iOS 26, a navigation controller places its bar by how far its view sits under the status bar, but only works that out now and then: when the view first reaches the status bar, and when it starts or stops overhanging the top of the screen. A presentation's spring overshoots, so the view overhangs briefly on arriving, and when it settles back the bar is placed from a stale measurement taken partway through the presentation. Presented from a button at the bottom of the screen, a search destination's bar sat under the status bar, with its Done button over the battery. A linear animation, or a slow spring, never showed it, which is why it went unseen. Once the view has come to rest after overhanging the top, the bar is now hidden and shown again in one turn of the run loop, which has it measured afresh without anything being drawn in between. Nothing else tried had any effect: a layout pass on the navigation controller's view or its bar, re- adding the bar, or changing the safe area. It is skipped during a pull, on iOS 26 and later, where the bar moves with the view, and while anything in the navigation stack is the first responder. Measured on iOS 18.6: the bar rests at 56.33 after every presentation now, where before it landed anywhere from 6.64 to 62 depending on the frame at which the spring first reached the status bar. The example gains a floating glass search button over the flow stack, with a search destination, to show a link in a flow stack's overlay, and the README documents the overlay. --- .../FlowStackExample/ContentView.swift | 36 ++++++++++++- .../FlowStackExample/ProductDetails.swift | 38 ++++++++++++++ README.md | 22 ++++++++ .../FlowStack/View+InteractiveDismiss.swift | 52 +++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) diff --git a/FlowStackExample/FlowStackExample/ContentView.swift b/FlowStackExample/FlowStackExample/ContentView.swift index 5dc443e..9d9fb43 100644 --- a/FlowStackExample/FlowStackExample/ContentView.swift +++ b/FlowStackExample/FlowStackExample/ContentView.swift @@ -15,7 +15,7 @@ struct ContentView: View { @StateObject private var detailOptions = DetailOptions() var body: some View { - FlowStack { + FlowStack(overlayAlignment: .bottomTrailing) { // The navigation container goes inside the flow stack. Outside it, the // navigation bar would be drawn over presented destinations. NavigationContainer { @@ -41,6 +41,9 @@ struct ContentView: View { } } } + .flowDestination(for: SearchRequest.self) { _ in + ProductSearch() + } .flowDestination(for: Product.self) { product in ProductDetails( product: product, @@ -52,10 +55,25 @@ struct ContentView: View { .accessibilityLabel("ProductDetails from flowDestination in contentView") } + } overlay: { + // The overlay stays in front of whatever the flow stack presents. A flow link in + // it can present from anywhere in the stack, at any depth. + searchButton + .padding(20) } .accessibilityElement(children: .contain) } + private var searchButton: some View { + FlowLink(value: SearchRequest(), configuration: .init(cornerRadius: 28, presentationStyle: presentationStyle)) { + Image(systemName: "magnifyingglass") + .font(.title2.weight(.semibold)) + .frame(width: 56, height: 56) + .glassCircle() + } + .accessibilityLabel("Search") + } + private var presentationStyleMenu: some View { Menu { Picker("Presentation Style", selection: $presentationStyle) { @@ -166,6 +184,22 @@ private extension FlowPresentationStyle { } } +private extension View { + /// A circle of glass on systems that have it, and of material before that. + @ViewBuilder + func glassCircle() -> some View { + #if compiler(>=6.2) + if #available(iOS 26.0, *) { + glassEffect(.regular.interactive(), in: .circle) + } else { + background(.regularMaterial, in: Circle()) + } + #else + background(.regularMaterial, in: Circle()) + #endif + } +} + /// A navigation container, so a view can host toolbar items. struct NavigationContainer: View { @ViewBuilder var content: () -> Content diff --git a/FlowStackExample/FlowStackExample/ProductDetails.swift b/FlowStackExample/FlowStackExample/ProductDetails.swift index a94c1c7..fc18410 100644 --- a/FlowStackExample/FlowStackExample/ProductDetails.swift +++ b/FlowStackExample/FlowStackExample/ProductDetails.swift @@ -230,6 +230,44 @@ private struct SafeAreaReadout: View { } } +/// What the search button presents. There is nothing to it: the search is the destination. +struct SearchRequest: Hashable {} + +/// A search over the products, presented from the button floating over the flow stack. +struct ProductSearch: View { + @Environment(\.flowDismiss) var flowDismiss + @State private var query = "" + + private var results: [Product] { + let query = query.trimmingCharacters(in: .whitespaces) + return query.isEmpty ? Product.allProducts : Product.allProducts.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + NavigationContainer { + List(results) { product in + VStack(alignment: .leading) { + Text(product.name) + Text(product.released) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .searchable(text: $query, prompt: "Products") + .navigationTitle("Search") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if #available(iOS 26.0, *) { + Button(role: .close) { flowDismiss() } + } else { + Button("Done") { flowDismiss() } + } + } + } + } + } +} + struct ProductDetails_Previews: PreviewProvider { static var previews: some View { ProductDetails(product: .appleII) diff --git a/README.md b/README.md index 88aeb4c..9f13c04 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,28 @@ Without an identifier, a value appended to the flow path zooms out of the most r One thing to watch for: FlowStack brings a link that a lazy container has let go back [by scrolling to its row's identifier](#returning-to-a-link). Two `ForEach`es over the same values in one scroll view give two rows the same identifier, and the scroll view may go to the wrong one. Identify the rows of one of them some other way, as the example app does for its featured products. +## An overlay over the stack + +A flow stack can take an `overlay`, a view that stays in front of whatever the stack presents. A flow link in it presents from wherever the stack is, at any depth, and the destination zooms out of and back into the link as usual. That suits a floating button for something like search: + +```swift +FlowStack(overlayAlignment: .bottomTrailing) { + ProductList() + .flowDestination(for: SearchRequest.self) { _ in + ProductSearch() + } +} overlay: { + FlowLink(value: SearchRequest(), configuration: .init(cornerRadius: 28)) { + Image(systemName: "magnifyingglass") + .frame(width: 56, height: 56) + .glassEffect(.regular.interactive(), in: .circle) // iOS 26 and later + } + .padding(20) +} +``` + +The link's label can be anything, including Liquid Glass on iOS 26 and later; the example app falls back to a material circle before that. The overlay is laid out within the stack's safe area, so it keeps clear of system UI on every device. + ## Manage navigation state By default, a flow stack manages state for any view contained, added or removed from the stack. If you need direct access and control of the state, you can create a binding to a FlowPath and initialize a flow stack with the flow path binding. diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift index 31d3f62..14feb69 100644 --- a/Sources/FlowStack/View+InteractiveDismiss.swift +++ b/Sources/FlowStack/View+InteractiveDismiss.swift @@ -124,6 +124,7 @@ class InteractiveDismissViewController: UIHostingController: UIHostingController= -Constants.tolerance else { return } + overhungTop = false + + for navigationController in navigationControllers(in: self) { + guard !navigationController.isNavigationBarHidden, + !Self.containsFirstResponder(navigationController.view) else { continue } + navigationController.setNavigationBarHidden(true, animated: false) + navigationController.setNavigationBarHidden(false, animated: false) + } + } + + private static func containsFirstResponder(_ view: UIView) -> Bool { + view.isFirstResponder || view.subviews.contains(where: containsFirstResponder) + } + // MARK: Navigation stacks /// A navigation stack inside the content, and where its bar and content sat at rest. @@ -335,6 +385,8 @@ class InteractiveDismissViewController: UIHostingController Date: Tue, 22 Sep 2026 09:34:54 -0700 Subject: [PATCH 39/50] Example: a focused search prompt over the stack, and a hold that can't recurse The search destination now works the way a prompt over a flow stack should: its field takes focus as it appears, so the keyboard rises with the zoom, and gives it up as it is dismissed, so the keyboard goes with it. The search button fades out while anything is presented rather than floating over other destinations, and the keyboard is kept out of the flow stack's safe area so it doesn't squash the stack or the zoom. The README's overlay section shows all of this. Pulling the search with the keyboard up crashed. Holding the navigation stack's content set its additional safe area insets, which had UIKit work out the content's safe area again, which the hold observed and answered by setting them again, without end: the bottom inset is UIKit's keyboard overlap, worked out from the very insets the hold sets, so it never settled. Now the hold ignores the changes it causes itself, and only holds an edge that nothing but the navigation controller contributes to. The top always is, for the bar; an edge with a keyboard or toolbar against it is left to UIKit, which places them by their own overlap anyway. --- .../FlowStackExample/ContentView.swift | 20 +++++++++--- .../FlowStackExample/ProductDetails.swift | 21 +++++++++++- README.md | 32 ++++++++++++++----- .../FlowStack/View+InteractiveDismiss.swift | 25 +++++++++++++-- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/FlowStackExample/FlowStackExample/ContentView.swift b/FlowStackExample/FlowStackExample/ContentView.swift index 9d9fb43..ba2cf15 100644 --- a/FlowStackExample/FlowStackExample/ContentView.swift +++ b/FlowStackExample/FlowStackExample/ContentView.swift @@ -12,10 +12,11 @@ struct ContentView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass let cornerRadius: CGFloat = 24 @State private var presentationStyle: FlowPresentationStyle = .automatic + @State private var path = FlowPath() @StateObject private var detailOptions = DetailOptions() var body: some View { - FlowStack(overlayAlignment: .bottomTrailing) { + FlowStack(path: $path, overlayAlignment: .bottomTrailing) { // The navigation container goes inside the flow stack. Outside it, the // navigation bar would be drawn over presented destinations. NavigationContainer { @@ -56,11 +57,20 @@ struct ContentView: View { } } overlay: { - // The overlay stays in front of whatever the flow stack presents. A flow link in - // it can present from anywhere in the stack, at any depth. - searchButton - .padding(20) + // The overlay stays in front of whatever the flow stack presents, so a flow link + // in it can present from anywhere in the stack. This one is only wanted over the + // root, and fades out while anything is presented. + if path.isEmpty { + searchButton + .padding(20) + .transition(.opacity) + } } + .animation(.easeInOut(duration: 0.2), value: path.isEmpty) + // The search brings up the keyboard. Kept out of the flow stack's safe area, it + // doesn't shrink the stack, and the zoom to and from the search button with it; the + // destination keeps clear of the keyboard by itself. + .ignoresSafeArea(.keyboard, edges: .bottom) .accessibilityElement(children: .contain) } diff --git a/FlowStackExample/FlowStackExample/ProductDetails.swift b/FlowStackExample/FlowStackExample/ProductDetails.swift index fc18410..752525d 100644 --- a/FlowStackExample/FlowStackExample/ProductDetails.swift +++ b/FlowStackExample/FlowStackExample/ProductDetails.swift @@ -234,9 +234,13 @@ private struct SafeAreaReadout: View { struct SearchRequest: Hashable {} /// A search over the products, presented from the button floating over the flow stack. +/// +/// The field takes focus as the search appears, so the keyboard rises with it, and gives +/// it up as the search is dismissed, so the keyboard goes with it. struct ProductSearch: View { @Environment(\.flowDismiss) var flowDismiss @State private var query = "" + @FocusState private var isSearching: Bool private var results: [Product] { let query = query.trimmingCharacters(in: .whitespaces) @@ -253,7 +257,20 @@ struct ProductSearch: View { .foregroundStyle(.secondary) } } - .searchable(text: $query, prompt: "Products") + .safeAreaInset(edge: .bottom) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Products", text: $query) + .focused($isSearching) + .submitLabel(.search) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.thinMaterial, in: Capsule()) + .padding(.horizontal) + .padding(.bottom, 8) + } .navigationTitle("Search") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { @@ -265,6 +282,8 @@ struct ProductSearch: View { } } } + .onAppear { isSearching = true } + .withFlowAnimation(onDismiss: { isSearching = false }) } } diff --git a/README.md b/README.md index 9f13c04..6c8bab0 100644 --- a/README.md +++ b/README.md @@ -130,25 +130,41 @@ One thing to watch for: FlowStack brings a link that a lazy container has let go ## An overlay over the stack -A flow stack can take an `overlay`, a view that stays in front of whatever the stack presents. A flow link in it presents from wherever the stack is, at any depth, and the destination zooms out of and back into the link as usual. That suits a floating button for something like search: +A flow stack can take an `overlay`, a view that stays in front of whatever the stack presents. A flow link in it presents from wherever the stack is, at any depth, and the destination zooms out of and back into the link as usual. That suits a floating button for something like search, or a prompt: ```swift -FlowStack(overlayAlignment: .bottomTrailing) { +@State private var path = FlowPath() + +FlowStack(path: $path, overlayAlignment: .bottomTrailing) { ProductList() .flowDestination(for: SearchRequest.self) { _ in ProductSearch() } } overlay: { - FlowLink(value: SearchRequest(), configuration: .init(cornerRadius: 28)) { - Image(systemName: "magnifyingglass") - .frame(width: 56, height: 56) - .glassEffect(.regular.interactive(), in: .circle) // iOS 26 and later + if path.isEmpty { // only wanted over the root + FlowLink(value: SearchRequest(), configuration: .init(cornerRadius: 28)) { + Image(systemName: "magnifyingglass") + .frame(width: 56, height: 56) + .glassEffect(.regular.interactive(), in: .circle) // iOS 26 and later + } + .padding(20) + .transition(.opacity) } - .padding(20) } +.animation(.easeInOut(duration: 0.2), value: path.isEmpty) +.ignoresSafeArea(.keyboard, edges: .bottom) ``` -The link's label can be anything, including Liquid Glass on iOS 26 and later; the example app falls back to a material circle before that. The overlay is laid out within the stack's safe area, so it keeps clear of system UI on every device. +The link's label can be anything, including Liquid Glass on iOS 26 and later; the example app falls back to a material circle before that. The overlay is laid out within the stack's safe area, so it keeps clear of system UI on every device. The link itself hides while its destination is presented; hiding the whole overlay on `path.isEmpty`, as here, keeps it from floating over other destinations too. + +A destination that brings up the keyboard, like a search, wants two more things. Keep the keyboard out of the flow stack's safe area with `.ignoresSafeArea(.keyboard)`, as above, so the stack and the zoom to and from the button aren't squashed by it; the destination keeps clear of the keyboard on its own. And give the field focus as the destination appears and take it back as it is dismissed, so the keyboard comes and goes with the zoom: + +```swift +@FocusState private var isSearching: Bool +... +.onAppear { isSearching = true } +.withFlowAnimation(onDismiss: { isSearching = false }) +``` ## Manage navigation state diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift index 14feb69..0c340d0 100644 --- a/Sources/FlowStack/View+InteractiveDismiss.swift +++ b/Sources/FlowStack/View+InteractiveDismiss.swift @@ -232,6 +232,11 @@ class InteractiveDismissViewController: UIHostingController: UIHostingController: UIHostingController: UIHostingController Date: Mon, 28 Sep 2026 16:37:51 -0700 Subject: [PATCH 40/50] Ignore the keyboard only in destinations that fill the flow stack included, where they had ignored only the container's. Before it, a destination was shrunk to end at the keyboard, and from iOS 26 the keyboard is translucent, so the root view showed through it: on an iPhone the list behind a search could be seen through its keyboard. Kept full size behind the keyboard, the destination shows through instead, and its content keeps clear of the keyboard through its safe area, as any full-screen view's does. That was right for a destination that fills the flow stack, but it applied to cards as well. A card presented over the flow stack, on iPad say, used to move up and shrink out of the keyboard's way, like a form sheet; it stayed put, and the keyboard covered the lower third of it. Now only a destination that fills the flow stack ignores the keyboard, and a card is laid out above it again. Whether a destination fills the flow stack is worked out inside the geometry reader that the safe area regions apply to, so it is now measured by a reader outside that one, which respects the keyboard. That is enough to tell, as it depends only on the width. The inner reader ignores every region for a destination that fills the flow stack: ignoring the keyboard alone stopped it short of the bottom by the home indicator's inset. Measured with the keyboard up: an iPhone destination fills the screen on iOS 18.6 and 27.0, with the destination showing through the keyboard on 27.0; an iPad card sits above the keyboard as before #51; an iPad destination presented with .fullScreen fills the screen. Without the keyboard, frames and safe areas are as they were. The README gains a section on text fields: a destination that ignores the safe area with .ignoresSafeArea() now ignores the keyboard with it, which leaves a field behind the keyboard, where .ignoresSafeArea(.container, edges: .top) keeps it clear. The example's product details follow that. --- .../FlowStackExample/ProductDetails.swift | 4 +- README.md | 21 +++- Sources/FlowStack/FlowTransition.swift | 118 ++++++++++-------- 3 files changed, 91 insertions(+), 52 deletions(-) diff --git a/FlowStackExample/FlowStackExample/ProductDetails.swift b/FlowStackExample/FlowStackExample/ProductDetails.swift index 752525d..299f5e4 100644 --- a/FlowStackExample/FlowStackExample/ProductDetails.swift +++ b/FlowStackExample/FlowStackExample/ProductDetails.swift @@ -131,7 +131,9 @@ struct ProductDetails: View { .accessibilityElement(children: .contain) .accessibilityAction(.escape) { flowDismiss() } } - .ignoresSafeArea() + // Only the container: ignoring the keyboard too would leave any text field in the + // details behind it. + .ignoresSafeArea(.container) .overlay(alignment: .bottom) { if showsSafeArea { SafeAreaReadout(insets: proxy.safeAreaInsets, size: proxy.size) diff --git a/README.md b/README.md index 6c8bab0..dd498be 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ FlowStack(path: $path, overlayAlignment: .bottomTrailing) { The link's label can be anything, including Liquid Glass on iOS 26 and later; the example app falls back to a material circle before that. The overlay is laid out within the stack's safe area, so it keeps clear of system UI on every device. The link itself hides while its destination is presented; hiding the whole overlay on `path.isEmpty`, as here, keeps it from floating over other destinations too. -A destination that brings up the keyboard, like a search, wants two more things. Keep the keyboard out of the flow stack's safe area with `.ignoresSafeArea(.keyboard)`, as above, so the stack and the zoom to and from the button aren't squashed by it; the destination keeps clear of the keyboard on its own. And give the field focus as the destination appears and take it back as it is dismissed, so the keyboard comes and goes with the zoom: +A destination that brings up the keyboard, like a search, wants two more things. FlowStack keeps the destination itself clear of the keyboard (see [Text fields and the keyboard](#text-fields-and-the-keyboard)), but the root view and the overlay are laid out around it like any other view. `.ignoresSafeArea(.keyboard)` on the flow stack, as above, stops them from moving behind the destination as the keyboard comes and goes. And give the field focus as the destination appears and take it back as it is dismissed, so the keyboard comes and goes with the zoom: ```swift @FocusState private var isSearching: Bool @@ -222,6 +222,25 @@ FlowLink(value: video, configuration: .init(cornerRadius: cornerRadius, presenta Destinations that extend under system UI should respect the safe area on *every* edge their content touches, not just the top: on iPhone Duo, the camera and status items sit in the trailing corner and are reported as a trailing inset. For controls like a close button, prefer a toolbar item inside a `NavigationStack` in the destination over positioning one by hand; the system places toolbar items clear of system UI on every device. +## Text fields and the keyboard + +When the keyboard comes up in a destination that fills the flow stack, the destination stays full size behind the keyboard, as any full-screen view does, and its content keeps clear of the keyboard through its safe area. A destination presented as a card moves up out of the keyboard's way instead, like a form sheet. + +So a destination that extends under system UI, like a hero image running up under the status bar, should ignore only the safe area it means to. `.ignoresSafeArea()` ignores the keyboard too, and leaves a text field in the destination behind it: + +```swift +ScrollView { + ParkHeader(park: park) + ParkDescription(park: park) +} +.safeAreaInset(edge: .bottom) { + TextField("Add a note", text: $note) +} +.ignoresSafeArea(.container, edges: .top) // not .ignoresSafeArea(), which puts the field behind the keyboard +``` + +A destination hosted in a `NavigationStack` keeps its content clear of the keyboard either way, since the navigation stack works out its content's safe area for itself. + ## Animation anchors ![flow_animation_anchor_example_1](https://github.com/velos/FlowStack/assets/11927517/fa6b20da-be32-45d4-a00e-b0aba4f0602d) diff --git a/Sources/FlowStack/FlowTransition.swift b/Sources/FlowStack/FlowTransition.swift index a41106e..11b583b 100644 --- a/Sources/FlowStack/FlowTransition.swift +++ b/Sources/FlowStack/FlowTransition.swift @@ -268,58 +268,76 @@ extension AnyTransition { } func body(content: Content) -> some View { - GeometryReader { proxy in - let zoomRect = zoomRect(with: proxy, anchor: resolvedContext.overrideAnchor ?? resolvedContext.anchor, percent: percent, pullOffset: panOffset) - let scaleRatio = resolvedContext.shouldScaleHorizontally ? zoomRect.size.width / proxy.size.width : 1.0 - - content - .onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: resolvedContext.swipeUpToDismiss, onDismiss: { - guard !isDisabled else { return } - defer { isDismissing = true } - dismiss() - }, onPan: { offset in - guard !isDisabled else { return } - if snapCornerRadiusZero { - // The pull is just starting, so the destination still covers - // its link. Waiting for the release would move the link in - // plain sight behind the shrunken destination. - linkContexts?.revealLink(for: value, atLevel: level, source: source) - } - self.snapCornerRadiusZero = false - self.panOffset = offset - }, onEnded: { _ in - // TODO: FS-34: Handle snap corner radius 0 on interactive dismiss cancel - withTransaction(transaction) { - panOffset = .zero - } - }) - .onPreferenceChange(InteractiveDismissDisabledKey.self) { isDisabled in - self.isDisabled = isDisabled - } - .overlay(alignment: .top) { - if let image = activeSnapshot, percent < 1 { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .opacity(snapshotPercent) - } - } - .clipShape(UnevenCornerShape(radii: cornerRadii(with: proxy).map { $0 / scaleRatio }, style: cornerStyle)) - .shadow(color: resolvedContext.shadowColor ?? .clear, radius: resolvedContext.shadowRadius, x: resolvedContext.shadowOffset.x, y: resolvedContext.shadowOffset.y) - .frame( - width: resolvedContext.shouldScaleHorizontally ? proxy.size.width : zoomRect.size.width, - height: zoomRect.size.height / scaleRatio - ) - .scaleEffect(x: scaleRatio, y: scaleRatio, anchor: .center) - .transformEffect(.init(translationX: resolvedContext.anchor == nil ? (1 - percent) * proxy.size.width : 0, y: 0)) - .position( - x: zoomRect.origin.x, - y: zoomRect.origin.y - ) - .opacity(resolvedContext.anchor == nil ? percent : 1) + // The keyboard is only ignored by a destination that fills the flow stack. That one + // stays full size behind the keyboard, as any full-screen view does, and its content + // keeps clear of the keyboard by its own safe area; were it shrunk to end at the + // keyboard instead, whatever is behind it would show through the keyboard, which is + // translucent from iOS 26. A card is laid out above the keyboard, so that it moves + // up out of the keyboard's way like a form sheet. Whether it fills the flow stack + // depends only on the width, which the keyboard never changes, so it is measured + // here, outside the keyboard. Ignoring the keyboard alone would stop the destination + // short of the bottom edge by the home indicator's inset, which the keyboard covers, + // so a full-screen destination ignores that too. + GeometryReader { container in + GeometryReader { proxy in + presentation(of: content, in: proxy) + } + .ignoresSafeArea(isPresentedFullscreen(availableSize: container.size) ? .all : [], edges: .all) } - .ignoresSafeArea(.all, edges: .all) + .ignoresSafeArea(.container, edges: .all) .allowsHitTesting(isPresented) } + + @ViewBuilder + private func presentation(of content: Content, in proxy: GeometryProxy) -> some View { + let zoomRect = zoomRect(with: proxy, anchor: resolvedContext.overrideAnchor ?? resolvedContext.anchor, percent: percent, pullOffset: panOffset) + let scaleRatio = resolvedContext.shouldScaleHorizontally ? zoomRect.size.width / proxy.size.width : 1.0 + + content + .onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: resolvedContext.swipeUpToDismiss, onDismiss: { + guard !isDisabled else { return } + defer { isDismissing = true } + dismiss() + }, onPan: { offset in + guard !isDisabled else { return } + if snapCornerRadiusZero { + // The pull is just starting, so the destination still covers + // its link. Waiting for the release would move the link in + // plain sight behind the shrunken destination. + linkContexts?.revealLink(for: value, atLevel: level, source: source) + } + self.snapCornerRadiusZero = false + self.panOffset = offset + }, onEnded: { _ in + // TODO: FS-34: Handle snap corner radius 0 on interactive dismiss cancel + withTransaction(transaction) { + panOffset = .zero + } + }) + .onPreferenceChange(InteractiveDismissDisabledKey.self) { isDisabled in + self.isDisabled = isDisabled + } + .overlay(alignment: .top) { + if let image = activeSnapshot, percent < 1 { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .opacity(snapshotPercent) + } + } + .clipShape(UnevenCornerShape(radii: cornerRadii(with: proxy).map { $0 / scaleRatio }, style: cornerStyle)) + .shadow(color: resolvedContext.shadowColor ?? .clear, radius: resolvedContext.shadowRadius, x: resolvedContext.shadowOffset.x, y: resolvedContext.shadowOffset.y) + .frame( + width: resolvedContext.shouldScaleHorizontally ? proxy.size.width : zoomRect.size.width, + height: zoomRect.size.height / scaleRatio + ) + .scaleEffect(x: scaleRatio, y: scaleRatio, anchor: .center) + .transformEffect(.init(translationX: resolvedContext.anchor == nil ? (1 - percent) * proxy.size.width : 0, y: 0)) + .position( + x: zoomRect.origin.x, + y: zoomRect.origin.y + ) + .opacity(resolvedContext.anchor == nil ? percent : 1) + } } } From 7422cb8a5d6d9babc7d768cc1d0250f77f552fcc Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 29 Sep 2026 00:15:28 -0700 Subject: [PATCH 41/50] Respect disabled dismissal and cancel an active pull safely --- Sources/FlowStack/FlowTransition.swift | 2 +- .../FlowStack/View+InteractiveDismiss.swift | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Sources/FlowStack/FlowTransition.swift b/Sources/FlowStack/FlowTransition.swift index 11b583b..00b29c8 100644 --- a/Sources/FlowStack/FlowTransition.swift +++ b/Sources/FlowStack/FlowTransition.swift @@ -296,8 +296,8 @@ extension AnyTransition { content .onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: resolvedContext.swipeUpToDismiss, onDismiss: { guard !isDisabled else { return } - defer { isDismissing = true } dismiss() + isDismissing = true }, onPan: { offset in guard !isDisabled else { return } if snapCornerRadiusZero { diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift index 0c340d0..73d378c 100644 --- a/Sources/FlowStack/View+InteractiveDismiss.swift +++ b/Sources/FlowStack/View+InteractiveDismiss.swift @@ -480,9 +480,13 @@ class InteractiveDismissCoordinator: NSObject, UIGestureRecognizerDelegate { guard oldValue != isEnabled else { return } panGestureRecognizer?.isEnabled = isEnabled edgeGestureRecognizer?.isEnabled = isEnabled - guard !isEnabled else { return } + + // The content can disable dismissal after a pull has already begun. + // Return it to rest now, without waiting for the finger to lift. + guard !isEnabled, isUpdating, !isDismissing else { return } isUpdating = false isPastThreshold = false + scrollView?.isScrollEnabled = true onEnded(false) } } @@ -572,14 +576,7 @@ class InteractiveDismissCoordinator: NSObject, UIGestureRecognizerDelegate { } private func update(offset: CGPoint, isEdge: Bool, state: UIGestureRecognizer.State) { - guard isEnabled else { - if state == .ended || state == .cancelled || state == .failed { - isUpdating = false - onEnded(false) - } - return - } - + guard isEnabled, !isDismissing else { return } isUpdating = true onPan(offset) @@ -610,7 +607,7 @@ class InteractiveDismissCoordinator: NSObject, UIGestureRecognizerDelegate { } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard isEnabled else { return false } + guard isEnabled, !isDismissing else { return false } guard let scrollView = scrollView else { return true } scrollView.isScrollEnabled = true guard gestureRecognizer == panGestureRecognizer else { return true } @@ -625,7 +622,7 @@ class InteractiveDismissCoordinator: NSObject, UIGestureRecognizerDelegate { } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - guard isEnabled else { return true } + guard isEnabled, !isDismissing else { return false } guard gestureRecognizer == panGestureRecognizer || gestureRecognizer == edgeGestureRecognizer, let scrollView = scrollView else { return true } From b77d0a92b0b57f4aa2b3f4eec664ff97d9a1cd96 Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 29 Sep 2026 00:16:00 -0700 Subject: [PATCH 42/50] Refresh link registration when its value or identity changes --- Sources/FlowStack/FlowLink.swift | 42 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/Sources/FlowStack/FlowLink.swift b/Sources/FlowStack/FlowLink.swift index da12dd1..a4e3549 100644 --- a/Sources/FlowStack/FlowLink.swift +++ b/Sources/FlowStack/FlowLink.swift @@ -279,6 +279,18 @@ public struct FlowLink