and CGPoint are not Hashable), which
+ // still satisfies the Hashable contract: equal values hash equally.
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(snapshot)
+ hasher.combine(linkDepth)
+ hasher.combine(cornerRadius)
+ hasher.combine(shadowRadius)
+ hasher.combine(shadowColor)
+ hasher.combine(shouldShowScrim)
+ hasher.combine(shouldScaleHorizontally)
+ hasher.combine(swipeUpToDismiss)
+ hasher.combine(presentationStyle)
+ }
}
struct FlowElement: Equatable, Hashable {
var value: (any (Equatable & Hashable))
var context: PathContext?
+ /// The flow link the value was presented from, where that is known. A value appended to
+ /// the flow path directly comes from whichever link presents it.
+ var source: FlowLinkSource?
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)
}
}
@@ -66,11 +69,13 @@ public struct FlowPath: Equatable, Hashable {
var elements: [FlowElement]
+ /// Creates a new, empty flow path.
public init() {
elements = []
}
- var isEmpty: Bool {
+ /// A Boolean that indicates whether the flow path is empty.
+ public var isEmpty: Bool {
elements.isEmpty
}
@@ -80,18 +85,27 @@ public struct FlowPath: Equatable, Hashable {
}
func contains(_ 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.
- /// - 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))
}
- mutating func append
(_ newElement: P, context: PathContext?) where P: Hashable {
- self.elements.append(.init(value: newElement, context: context, index: 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?, 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.
@@ -101,18 +115,21 @@ public struct FlowPath: Equatable, Hashable {
self.append(newElement, context: nil)
}
- /// Adds a method to tell flow path to use the correct snapshot for the currently set colorScheme
+ /// 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:
- /// - colorScheme: The new color scheme to be used for snapshots
- public mutating func updateSnapshots(from colorScheme: ColorScheme) {
- for i in elements.indices {
- guard var context = elements[i].context else { continue }
- if let newSnapshot = context.snapshotDict[colorScheme] {
- context.snapshot = newSnapshot
- elements[i].context?.snapshot = context.snapshot
- }
- }
+ /// - 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.")
+ public mutating func updateSnapshots(from colorScheme: ColorScheme) { }
}
struct FlowPathKey: EnvironmentKey {
@@ -120,6 +137,10 @@ struct FlowPathKey: EnvironmentKey {
}
public extension EnvironmentValues {
+ /// A binding to the path of the enclosing flow stack.
+ ///
+ /// Inside a flow stack, this is the stack's ``FlowPath``, whether the stack manages it
+ /// or it was passed in. Outside one, it's a constant, empty path.
var flowPath: Binding? {
get { self[FlowPathKey.self] }
set { self[FlowPathKey.self] = newValue }
diff --git a/Sources/FlowStack/FlowPresentationStyle.swift b/Sources/FlowStack/FlowPresentationStyle.swift
new file mode 100644
index 0000000..a0ed6ca
--- /dev/null
+++ b/Sources/FlowStack/FlowPresentationStyle.swift
@@ -0,0 +1,42 @@
+//
+// FlowPresentationStyle.swift
+//
+
+import SwiftUI
+
+/// How a flow stack sizes a presented destination view.
+///
+/// Set a style with `FlowLink.Configuration`. Destinations presented by
+/// appending to a `FlowPath` directly use ``automatic``.
+public enum FlowPresentationStyle: Hashable, Sendable, CaseIterable {
+
+ /// Fills the flow stack on iPhone — including wide iPhone displays, like an
+ /// unfolded iPhone Duo or a landscape iPhone Pro Max — and behaves like
+ /// ``card`` on every other device.
+ case automatic
+
+ /// Always fills the flow stack.
+ case fullScreen
+
+ /// Presents a centered card over the dimmed flow stack when the flow stack
+ /// is wide enough to fit one, and otherwise fills the flow stack.
+ case card
+}
+
+extension FlowPresentationStyle {
+
+ /// Whether a destination fills the flow stack rather than floating as a card.
+ /// - Parameters:
+ /// - cardFits: Whether the flow stack has room to present a card.
+ /// - idiom: The idiom of the device presenting the destination.
+ func isFullScreen(cardFits: Bool, idiom: UIUserInterfaceIdiom) -> Bool {
+ switch self {
+ case .fullScreen:
+ return true
+ case .card:
+ return !cardFits
+ case .automatic:
+ return !cardFits || idiom == .phone
+ }
+ }
+}
diff --git a/Sources/FlowStack/FlowStack.swift b/Sources/FlowStack/FlowStack.swift
index a3d9111..8cad043 100644
--- a/Sources/FlowStack/FlowStack.swift
+++ b/Sources/FlowStack/FlowStack.swift
@@ -5,8 +5,11 @@
//
import Combine
+import OSLog
import SwiftUI
+let flowStackLogger = Logger(subsystem: "com.velosmobile.FlowStack", category: "FlowStack")
+
struct AnyDestination: Equatable {
static func == (lhs: AnyDestination, rhs: AnyDestination) -> Bool {
@@ -27,8 +30,8 @@ class DestinationLookup: ObservableObject {
class AccessibilityManager: ObservableObject {
@Published var zIndex: Double = 0.0
- var skrimIndex: Double { zIndex - 0.1 }
- var behindSkrim: Double { zIndex - 0.2 }
+ var scrimIndex: Double { zIndex - 0.1 }
+ var behindScrim: Double { zIndex - 0.2 }
// Setup VoiceOver Observer
@Published var isVoiceOverRunning: Bool = UIAccessibility.isVoiceOverRunning
@@ -43,9 +46,9 @@ class AccessibilityManager: ObservableObject {
func decrementIndex() { self.zIndex -= 1.0 }
- func calcSkrim() -> Double {
- if isVoiceOverRunning { return skrimIndex }
- return zIndex > 1.0 ? zIndex : skrimIndex
+ func calcScrim() -> Double {
+ if isVoiceOverRunning { return scrimIndex }
+ return zIndex > 1.0 ? zIndex : scrimIndex
}
}
@@ -73,9 +76,14 @@ struct FlowDestinationModifier: ViewModifier {
func body(content: Content) -> some View {
content
- .zIndex(accessibilityManager.isVoiceOverRunning ? accessibilityManager.zIndex : accessibilityManager.behindSkrim)
- // swiftlint:disable:next force_unwrapping
- .onAppear { destinationLookup.table.merge([_mangledTypeName(dataType)!: destination], uniquingKeysWith: { _, rhs in rhs }) }
+ .zIndex(accessibilityManager.isVoiceOverRunning ? accessibilityManager.zIndex : accessibilityManager.behindScrim)
+ .onAppear {
+ guard let typeName = _mangledTypeName(dataType) else {
+ assertionFailure("FlowStack: Unable to resolve a mangled type name for \(dataType).")
+ return
+ }
+ destinationLookup.table.merge([typeName: destination], uniquingKeysWith: { _, rhs in rhs })
+ }
}
}
@@ -111,21 +119,22 @@ public extension View {
/// if it needs to present more than one kind of data.
///
/// Do not put a navigation destination modifier inside a "lazy" container,
- /// like ``List`` or ``LazyVStack``. These containers create child views
+ /// like `List` or `LazyVStack`. These containers create child views
/// only when needed to render on screen. Add the flow destination
/// modifier outside these containers so that the flow stack can
/// always see the destination.
///
/// - Parameters:
- /// - data: The type of data that this destination matches.
+ /// - type: The type of data that this destination matches.
/// - destination: A view builder that defines a view to display
/// when the stack's flow navigation state contains a value of
- /// type `data`. The closure takes one argument, which is the value
+ /// that type. The closure takes one argument, which is the value
/// of the data to present.
func flowDestination(for type: D.Type, @ViewBuilder destination: @escaping (D) -> C) -> some View where D: Hashable, C: View {
let destination = AnyDestination(dataType: type, content: { param in
guard let param = AnyDestination.cast(data: param, to: type) else {
- fatalError()
+ assertionFailure("FlowStack: Expected value of type \(type) but received \(Swift.type(of: param)).")
+ return AnyView(EmptyView())
}
return AnyView (
destination(param)
@@ -219,16 +228,20 @@ public struct FlowStack: View {
private var usesInternalPath: Bool = false
- @State private var destinationLookup: DestinationLookup = .init()
+ @StateObject private var destinationLookup: DestinationLookup = .init()
@StateObject var accessibilityManager: AccessibilityManager = .init()
+ @StateObject private var linkContexts = FlowLinkContextStore()
+
+ /// The presented destinations that have disabled interactive dismissal.
+ @State private var dismissDisabled: Set = []
/// Creates a flow stack that manages its own navigation state.
/// - Parameters:
/// - overlayAlignment: The alignment applied to the overlay.
- /// - animation: The animation to use during flow transitions.
+ /// - customSmoothAnimation: The animation to use during flow transitions.
/// - 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, @ViewBuilder root: @escaping () -> Root, @ViewBuilder overlay: @escaping () -> Overlay) {
self.root = root
self.overlay = overlay
self.overlayAlignment = overlayAlignment
@@ -242,10 +255,10 @@ public struct FlowStack: View {
/// - Parameters:
/// - path: A Binding to the flow path for this stack.
/// - overlayAlignment: The alignment applied to the overlay.
- /// - animation: The animation to use during flow transitions.
+ /// - customSmoothAnimation: The animation to use during flow transitions.
/// - 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, @ViewBuilder root: @escaping () -> Root, @ViewBuilder overlay: @escaping () -> Overlay) {
self.root = root
self.overlay = overlay
self.overlayAlignment = overlayAlignment
@@ -255,6 +268,7 @@ public struct FlowStack: View {
private func destination(for instance: any (Hashable & Equatable)) -> AnyDestination? {
guard let typeName = _mangledTypeName(type(of: instance)), let destination = destinationLookup.table[typeName] else {
+ flowStackLogger.warning("No flowDestination(for:destination:) registered for value of type \(String(describing: type(of: instance))). The value will not be presented. Ensure the flowDestination modifier is attached to a view that has appeared inside this FlowStack (and is not inside a lazy container).")
return nil
}
@@ -262,15 +276,20 @@ public struct FlowStack: View {
}
@ViewBuilder
- private func skrim(for element: FlowElement) -> some View {
- if element == pathToUse.wrappedValue.elements.last, element.context?.shouldShowSkrim == true {
+ private func scrim(for element: FlowElement) -> some View {
+ if element == pathToUse.wrappedValue.elements.last, element.context?.shouldShowScrim == true {
Rectangle()
.foregroundColor(Color.black.opacity(0.7))
- .transition(.opacity)
+ // Fading in, the scrim keeps touches from the flow stack beneath it. Fading
+ // out, it would only be in the way of a flow stack that looks ready to use.
+ .transition(.asymmetric(insertion: .opacity, removal: .opacity.combined(with: .untouchable)))
.ignoresSafeArea()
- .zIndex(accessibilityManager.calcSkrim())
+ .zIndex(accessibilityManager.calcScrim())
.id(element.hashValue)
.onTapGesture {
+ // Tapping outside a destination dismisses it as surely as pulling it away
+ // does, so a destination that disables one disables both.
+ guard !dismissDisabled.contains(element) else { return }
flowDismissAction()
}
}
@@ -283,9 +302,16 @@ public struct FlowStack: View {
private var flowDismissAction: FlowDismissAction {
FlowDismissAction(
onDismiss: {
- withTransaction(transaction) {
- accessibilityManager.decrementIndex()
- pathToUse.wrappedValue.removeLast()
+ 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()
+ }
}
})
}
@@ -295,24 +321,38 @@ public struct FlowStack: View {
transaction.disablesAnimations = true
return transaction
}
- @Environment(\.flowDismiss) var flowDismiss
public var body: some View {
ZStack {
- root()
- .accessibilityElement(children: .contain)
- .accessibilityHidden(accessibilityManager.zIndex != 0)
- .environment(\.flowDepth, 0)
+ ScrollViewReader { proxy in
+ root()
+ .onAppear { linkContexts.setScrollProxy(proxy, forLevel: 0) }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityHidden(accessibilityManager.zIndex != 0)
+ .environment(\.flowDepth, 0)
ForEach(pathToUse.wrappedValue.elements, id: \.self) { element in
if let destination = destination(for: element.value) {
- skrim(for: element)
+ scrim(for: element)
- destination.content(element.value)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .id(element.hashValue)
- .transition(.flowTransition(with: element.context ?? .init()))
- .modifier(AccessibilityModifier(element: element.index))
+ ScrollViewReader { proxy in
+ destination.content(element.value)
+ .onAppear { linkContexts.setScrollProxy(proxy, forLevel: element.index + 1) }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .id(element.hashValue)
+ .transition(.flowTransition(with: element.context, source: element.source, value: AnyHashable(element.value), level: element.index))
+ .modifier(AccessibilityModifier(element: element.index))
+ .onPreferenceChange(InteractiveDismissDisabledKey.self) { isDisabled in
+ // A destination being removed can still report, and would otherwise
+ // be remembered for one that later presents the same value.
+ if isDisabled, pathToUse.wrappedValue.elements.contains(element) {
+ dismissDisabled.insert(element)
+ } else {
+ dismissDisabled.remove(element)
+ }
+ }
}
}
}
@@ -322,11 +362,25 @@ public struct FlowStack: View {
.environment(\.flowDepth, -1)
}
.environment(\.flowPath, pathToUse)
+ .environment(\.flowLinkContexts, linkContexts)
.environment(\.flowAnimationDuration, customSmoothAnimation.duration)
.environment(\.flowTransaction, transaction)
.environmentObject(destinationLookup)
.environmentObject(accessibilityManager)
.environment(\.flowDismiss, flowDismissAction)
+ // Keep the accessibility z-index in sync with the path even when the
+ // path is mutated directly (e.g. flowPath.removeLast(2), removeAll()),
+ // which bypasses FlowDismissAction's decrement.
+ .onChange(of: pathToUse.wrappedValue.count) { newCount in
+ accessibilityManager.setIndex(newCount - 1)
+ }
+ .onAppear {
+ linkContexts.pathDidChange(to: pathToUse.wrappedValue.elements)
+ }
+ .onChange(of: pathToUse.wrappedValue.elements) { elements in
+ linkContexts.pathDidChange(to: elements)
+ dismissDisabled.formIntersection(elements)
+ }
}
}
@@ -334,7 +388,7 @@ public extension FlowStack where Overlay == EmptyView {
/// Creates a flow stack that manages its own navigation state.
/// - Parameters:
- /// - animation: The animation to use during flow transitions.
+ /// - customSmoothAnimation: The animation to use during flow transitions.
/// - root: The view to display when the stack is empty.
init(customSmoothAnimation: CustomSmoothAnimation? = nil, @ViewBuilder root: @escaping () -> Root) {
self.root = root
@@ -349,14 +403,14 @@ public extension FlowStack where Overlay == EmptyView {
/// Creates a flow stack with heterogeneous navigation state that you can control.
/// - Parameters:
/// - path: A Binding to the flow path for this stack.
- /// - animation: The animation to use during flow transitions.
+ /// - customSmoothAnimation: The animation to use during flow transitions.
/// - root: The view to display when the stack is empty.
init(path: Binding, customSmoothAnimation: CustomSmoothAnimation? = nil, @ViewBuilder root: @escaping () -> Root) {
self.root = root
self.overlay = { EmptyView() }
self.overlayAlignment = .center
self._path = path
- self.customSmoothAnimation = customSmoothAnimation ?? CustomSmoothAnimation.default
+ self.customSmoothAnimation = customSmoothAnimation ?? CustomSmoothAnimation.default
}
}
@@ -376,6 +430,7 @@ struct FlowPathAnimationKey: EnvironmentKey {
}
public extension EnvironmentValues {
+ /// The duration, in seconds, of the enclosing flow stack's transitions.
var flowAnimationDuration: Double {
get { self[FlowPathAnimationKey.self] }
set { self[FlowPathAnimationKey.self] = newValue }
@@ -398,12 +453,22 @@ struct FlowStack_Previews: PreviewProvider {
}
}
-/// Object for passable parameters for smooth Animation
-/// Had to be an animation type with a duration value so that it's trackable for flowStack
+/// The timing of a flow stack's transitions, which are smooth spring animations.
+///
+/// Pass one to a ``FlowStack`` initializer to change the timing of all of the stack's
+/// transitions:
+///
+/// FlowStack(customSmoothAnimation: .init(duration: 0.3, bounce: 0.1)) {
+/// ...
+/// }
public struct CustomSmoothAnimation {
var duration: Double
var bounce: Double
+ /// Creates the timing for a flow stack's transitions.
+ /// - Parameters:
+ /// - duration: The perceived duration of a transition, in seconds.
+ /// - bounce: How much bounce to add to the end of a transition, from 0 for none.
public init(duration: Double = 0.24, bounce: Double = 0.2) {
self.duration = duration
self.bounce = bounce
@@ -464,7 +529,7 @@ struct FlowTransactionModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear(perform: {
- initialPathCount = path!.elements.count
+ initialPathCount = path?.elements.count ?? 0
withTransaction(transaction) {
onPresent?()
}
diff --git a/Sources/FlowStack/FlowTransition.swift b/Sources/FlowStack/FlowTransition.swift
index e534f1c..c3d46f3 100644
--- a/Sources/FlowStack/FlowTransition.swift
+++ b/Sources/FlowStack/FlowTransition.swift
@@ -19,15 +19,28 @@ struct OpacityTransitionKey: EnvironmentKey {
}
/// An action that dismisses the current presented view.
+///
+/// Read it from the environment with ``SwiftUICore/EnvironmentValues/flowDismiss`` and call it as a function:
+///
+/// @Environment(\.flowDismiss) private var flowDismiss
+///
+/// Button("Done") {
+/// flowDismiss()
+/// }
public struct FlowDismissAction {
var onDismiss: () -> Void = { }
+ /// Dismisses the current presented view.
public func callAsFunction() {
onDismiss()
}
}
public extension EnvironmentValues {
+ /// An action that dismisses the current presented view.
+ ///
+ /// Dismissing with this, rather than by removing from the ``FlowPath`` yourself, scrolls
+ /// the link that presented the view into sight before the view zooms back into it.
var flowDismiss: FlowDismissAction {
get { return self[FlowDismissActionKey.self] }
set { self[FlowDismissActionKey.self] = newValue }
@@ -40,10 +53,10 @@ struct FlowDismissActionKey: EnvironmentKey {
extension AnyTransition {
- static func flowTransition(with context: PathContext) -> AnyTransition {
+ static func flowTransition(with context: PathContext?, source: FlowLinkSource?, value: AnyHashable, level: Int) -> AnyTransition {
AnyTransition.modifier(
- active: FlowPresentModifier(percent: 0, context: context),
- identity: FlowPresentModifier(percent: 1, context: context)
+ active: FlowPresentModifier(percent: 0, context: context, source: source, value: value, level: level),
+ identity: FlowPresentModifier(percent: 1, context: context, source: source, value: value, level: level)
)
}
@@ -62,6 +75,50 @@ extension AnyTransition {
}
}
+ /// Keeps a view hidden for as long as it is transitioning in, and hides it the
+ /// moment it starts transitioning out.
+ struct VisibleOnceSettledModifier: Animatable, ViewModifier {
+ var percent: Double
+
+ /// Springs overshoot their target and ring around it, so a threshold of exactly 1
+ /// would flicker. The view is indistinguishable from settled just short of it.
+ static let threshold: Double = 0.97
+
+ var animatableData: Double {
+ get { percent }
+ set { percent = newValue }
+ }
+
+ func body(content: Content) -> some View {
+ content
+ .opacity(percent >= Self.threshold ? 1 : 0)
+ }
+ }
+
+ static var visibleOnceSettled: AnyTransition {
+ AnyTransition.modifier(
+ active: VisibleOnceSettledModifier(percent: 0),
+ identity: VisibleOnceSettledModifier(percent: 1)
+ )
+ }
+
+ /// Stops a view from taking touches while it is being removed. A view stays in the
+ /// hierarchy until its removal has finished animating, in the way of whatever is beneath.
+ struct HitTestingModifier: ViewModifier {
+ var isEnabled: Bool
+
+ func body(content: Content) -> some View {
+ content.allowsHitTesting(isEnabled)
+ }
+ }
+
+ static var untouchable: AnyTransition {
+ AnyTransition.modifier(
+ active: HitTestingModifier(isEnabled: false),
+ identity: HitTestingModifier(isEnabled: true)
+ )
+ }
+
static var opacityPercent: AnyTransition {
AnyTransition.modifier(
active: OpacityPercentModifier(percent: 0),
@@ -71,14 +128,51 @@ extension AnyTransition {
struct FlowPresentModifier: Animatable, ViewModifier {
var percent: CGFloat
- var context: PathContext
+ /// 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
+
+ @Environment(\.flowLinkContexts) private var linkContexts
+ @Environment(\.flowPath) private var flowPath
+
+ /// Whether the destination is still in the flow path, as opposed to on its way out.
+ ///
+ /// A destination being dismissed stays in the view hierarchy until its transition has
+ /// finished, which for a spring means until it has fully settled, well after it looks
+ /// done. All that time it would take touches meant for the flow stack beneath it, and
+ /// not only where it appears to be: a destination hosted in UIKit is hit-tested by its
+ /// container, which still fills the flow stack after the destination has shrunk.
+ private var isPresented: Bool {
+ flowPath?.wrappedValue.contains(value, atLevel: level) ?? true
+ }
+
+ /// The transition is captured when the destination is inserted, so `context`
+ /// describes where the link was then. Resolving against the link's latest report
+ /// 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, source: source) else { return context ?? .init() }
+ guard var resolved = context else { return live }
+ resolved.anchor = live.anchor
+ resolved.overrideAnchor = live.overrideAnchor
+
+ // A link retakes its snapshots when its layout changes, so its own are the ones
+ // that show it as it is now. It has none if it doesn't transition from a snapshot.
+ if !live.snapshotDict.isEmpty {
+ resolved.snapshotDict = live.snapshotDict
+ resolved.snapshot = live.snapshot
+ }
+ return resolved
+ }
- @State var panOffset: CGPoint = .zero
- @State var isEnded: Bool = false
+ @State private var panOffset: CGPoint = .zero
@State private var isDisabled: Bool = false
- @State var isDismissing: Bool = false
+ @State private var isDismissing: Bool = false
@State private var snapCornerRadiusZero: Bool = true
- @State private var availableSize: CGSize = .zero
@Environment(\.colorScheme) private var colorScheme
@@ -86,37 +180,59 @@ extension AnyTransition {
max(0, 1 - percent / 0.2)
}
- @Environment(\.flowDismiss) var dismiss
- @Environment(\.flowTransaction) var transaction
- @Environment(\.horizontalSizeClass) var horizontalSizeClass
+ @Environment(\.flowDismiss) private var dismiss
+ @Environment(\.flowTransaction) private var transaction
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var activeSnapshot: UIImage? {
- context.snapshotDict[colorScheme] ?? context.snapshot
+ resolvedContext.snapshotDict[colorScheme] ?? resolvedContext.snapshot
}
- var cornerRadius: CGFloat { context.cornerRadius + ((UIScreen.displayCornerRadius ?? 20) - context.cornerRadius) * percent }
+ private func isPresentedFullscreen(availableSize: CGSize) -> Bool {
+ let cardFits = horizontalSizeClass == .regular && availableSize.width - 2 * Constants.minVerticalPadding >= Constants.maxWidth
+ return resolvedContext.presentationStyle.isFullScreen(cardFits: cardFits, idiom: UIDevice.current.userInterfaceIdiom)
+ }
+
+ /// The corner radii of the fully presented view, which the transition
+ /// animates toward from the flow link's corner radius.
+ private func presentedCornerRadii(with proxy: GeometryProxy) -> CornerRadii {
+ // The display's corner radius only suits a view whose corners sit on
+ // the display's corners. Anything floating away from them gets a
+ // sheet-like radius instead, which on displays with very round
+ // corners is considerably smaller.
+ let floatingRadius = min(UIScreen.displayCornerRadius, Constants.maxFloatingCornerRadius)
+
+ guard isPresentedFullscreen(availableSize: proxy.size) else {
+ return CornerRadii(uniform: floatingRadius)
+ }
+
+ #if compiler(>=6.4)
+ // Displays can have a different radius at each corner (e.g. the
+ // hinge side of a foldable), which a single value can't describe.
+ if #available(iOS 27.0, *), let radii = proxy.concentricCornerRadii {
+ // A corner resolves to zero when it isn't near a corner of the container.
+ return CornerRadii(
+ topLeft: radii.topLeading,
+ topRight: radii.topTrailing,
+ bottomLeft: radii.bottomLeading,
+ bottomRight: radii.bottomTrailing
+ ).map { $0 > 0 ? $0 : floatingRadius }
+ }
+ #endif
- var isPresentedFullscreen: Bool {
- horizontalSizeClass == .compact || availableSize.width - 2 * Constants.minVerticalPadding < Constants.maxWidth
+ return CornerRadii(uniform: UIScreen.displayCornerRadius)
}
- var conditionalCornerRadius: CGFloat {
- if isPresentedFullscreen {
- if percent >= 1 {
- if snapCornerRadiusZero {
- return 0
- } else {
- return cornerRadius
- }
- } else {
- return cornerRadius
- }
- } else {
- return cornerRadius
+ private func cornerRadii(with proxy: GeometryProxy) -> CornerRadii {
+ // At rest a fullscreen view is clipped by the display itself.
+ if isPresentedFullscreen(availableSize: proxy.size), percent >= 1, snapCornerRadiusZero {
+ return .zero
}
+
+ return presentedCornerRadii(with: proxy).interpolated(from: resolvedContext.cornerRadius, percent: percent)
}
- var cornerStyle: RoundedCornerStyle { percent > 0.5 ? .continuous : context.cornerStyle }
+ var cornerStyle: RoundedCornerStyle { percent > 0.5 ? .continuous : resolvedContext.cornerStyle }
var animatableData: CGFloat {
get { percent }
@@ -149,79 +265,96 @@ extension AnyTransition {
static let maxWidth: CGFloat = 706
static let maxHeight: CGFloat = 998
static let minVerticalPadding: CGFloat = 44
+ /// Matches the corner radius of system sheets.
+ static let maxFloatingCornerRadius: CGFloat = 40
}
private func presentationSize(availableSize: CGSize) -> CGSize {
- if horizontalSizeClass == .regular && availableSize.width - 2 * Constants.minVerticalPadding >= Constants.maxWidth {
+ if isPresentedFullscreen(availableSize: availableSize) {
+ return availableSize
+ } else {
let width = Constants.maxWidth
let height = min(Constants.maxHeight, availableSize.height - Constants.minVerticalPadding * 2)
return CGSize(width: width, height: height)
- } else {
- return availableSize
}
}
func body(content: Content) -> some View {
- GeometryReader { proxy in
- let zoomRect = zoomRect(with: proxy, anchor: context.overrideAnchor ?? context.anchor, percent: percent, pullOffset: panOffset)
- let scaleRatio = context.shouldScaleHorizontally ? zoomRect.size.width / proxy.size.width : 1.0
-
- content
- .onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: context.swipeUpToDismiss, onDismiss: {
- guard !isDisabled else { return }
- defer { isDismissing = true }
- dismiss()
- }, onPan: { offset in
- defer { self.isEnded = false }
- guard !isDisabled else { return }
- self.snapCornerRadiusZero = false
- self.panOffset = offset
- }, onEnded: { isDismissing in
- // TODO: FS-34: Handle snap corner radius 0 on interactive dismiss cancel
+ // 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(.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 }
+ dismiss()
+ isDismissing = true
+ }, 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
+ // Disabling an active pull ends it from updateUIViewController.
+ // Wait until that view update is over before changing SwiftUI state.
+ DispatchQueue.main.async {
withTransaction(transaction) {
panOffset = .zero
- isEnded = true
}
- })
- .onPreferenceChange(InteractiveDismissDisabledKey.self) { isDisabled in
- self.isDisabled = isDisabled
}
- .preference(key: SizePreferenceKey.self, value: proxy.size)
- .onPreferenceChange(SizePreferenceKey.self, perform: { value in
- availableSize = value
- })
- .overlay(alignment: .top) {
- if let image = activeSnapshot, percent < 1 {
- Image(uiImage: image)
- .resizable()
- .aspectRatio(contentMode: .fit)
- .opacity(snapshotPercent)
- }
+ })
+ .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(RoundedRectangle(cornerRadius: conditionalCornerRadius / scaleRatio, style: cornerStyle))
- .shadow(color: context.shadowColor ?? .clear, radius: context.shadowRadius, x: context.shadowOffset.x, y: context.shadowOffset.y)
- .frame(
- width: context.shouldScaleHorizontally ? proxy.size.width : zoomRect.size.width,
- height: zoomRect.size.height / scaleRatio
- )
- .scaleEffect(x: scaleRatio, y: scaleRatio, anchor: .center)
- .transformEffect(.init(translationX: context.anchor == nil ? (1 - percent) * proxy.size.width : 0, y: 0))
- .position(
- x: zoomRect.origin.x,
- y: zoomRect.origin.y
- )
- .opacity(context.anchor == nil ? percent : 1)
- }
- .ignoresSafeArea(.all, edges: .all)
+ }
+ .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)
}
}
}
-
-struct SizePreferenceKey: PreferenceKey {
- static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
- value = nextValue()
- }
-
- static var defaultValue: CGSize = .zero
-}
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
+
+
+
diff --git a/Sources/FlowStack/ScrollReveal.swift b/Sources/FlowStack/ScrollReveal.swift
new file mode 100644
index 0000000..1e7e7ff
--- /dev/null
+++ b/Sources/FlowStack/ScrollReveal.swift
@@ -0,0 +1,111 @@
+//
+// ScrollReveal.swift
+//
+
+import SwiftUI
+
+/// Scrolls the scroll views containing a view so that the view is fully visible.
+///
+/// An `ObservableObject` only so that a view can hold it in a `StateObject`, which creates it
+/// once. It publishes nothing.
+final class ScrollRevealController: ObservableObject {
+
+ /// The breathing room kept between the revealed view and the edge of the visible area.
+ static let margin: CGFloat = 12
+
+ fileprivate weak var view: UIView?
+
+ /// Moves the view fully into the visible area of every scroll view that contains it,
+ /// without animation. The visible area excludes anything covering the scroll view's
+ /// edges, like a navigation bar, so a view underneath one is brought out from under it.
+ func reveal() {
+ // Deferred so that UIKit has the frames for the layout that prompted the reveal.
+ DispatchQueue.main.async { [weak self] in
+ guard let view = self?.view, view.window != nil else { return }
+
+ var ancestor = view.superview
+ while let current = ancestor {
+ if let scrollView = current as? UIScrollView, scrollView.isScrollEnabled {
+ Self.reveal(view, in: scrollView)
+ }
+ ancestor = current.superview
+ }
+ }
+ }
+
+ private static func reveal(_ view: UIView, in scrollView: UIScrollView) {
+ let offset = contentOffset(
+ revealing: view.convert(view.bounds, to: scrollView),
+ in: scrollView.bounds,
+ insets: scrollView.adjustedContentInset,
+ contentSize: scrollView.contentSize
+ )
+
+ guard offset != scrollView.contentOffset else { return }
+ scrollView.setContentOffset(offset, animated: false)
+ }
+
+ /// The content offset that brings `frame` fully into the visible area of a scroll view.
+ /// - Parameters:
+ /// - frame: The frame to reveal, in the scroll view's coordinate space.
+ /// - bounds: The scroll view's bounds, whose origin is its current content offset.
+ /// - insets: The scroll view's adjusted content inset.
+ /// - contentSize: The size of the scroll view's content.
+ static func contentOffset(revealing frame: CGRect, in bounds: CGRect, insets: UIEdgeInsets, contentSize: CGSize) -> CGPoint {
+ let visible = bounds.inset(by: insets).insetBy(dx: margin, dy: margin)
+
+ return CGPoint(
+ x: offset(
+ bounds.minX,
+ revealing: frame.minX...frame.maxX,
+ in: visible.minX...max(visible.minX, visible.maxX),
+ limits: -insets.left...max(-insets.left, contentSize.width - bounds.width + insets.right)
+ ),
+ y: offset(
+ bounds.minY,
+ revealing: frame.minY...frame.maxY,
+ in: visible.minY...max(visible.minY, visible.maxY),
+ limits: -insets.top...max(-insets.top, contentSize.height - bounds.height + insets.bottom)
+ )
+ )
+ }
+
+ private static func offset(_ offset: CGFloat, revealing frame: ClosedRange, in visible: ClosedRange, limits: ClosedRange) -> CGFloat {
+ // The content doesn't scroll along this axis.
+ guard limits.upperBound > limits.lowerBound else { return offset }
+
+ var offset = offset
+ let frameLength = frame.upperBound - frame.lowerBound
+ let visibleLength = visible.upperBound - visible.lowerBound
+
+ // A frame longer than the visible area is aligned to its start.
+ if frame.lowerBound < visible.lowerBound || frameLength > visibleLength {
+ offset += frame.lowerBound - visible.lowerBound
+ } else if frame.upperBound > visible.upperBound {
+ offset += frame.upperBound - visible.upperBound
+ }
+
+ return min(max(offset, limits.lowerBound), limits.upperBound)
+ }
+}
+
+/// An invisible view that gives a `ScrollRevealController` a foothold in the UIKit hierarchy.
+struct ScrollRevealView: UIViewRepresentable {
+ let controller: ScrollRevealController
+
+ func makeUIView(context: Context) -> ScrollRevealAnchorView {
+ let view = ScrollRevealAnchorView()
+ view.isUserInteractionEnabled = false
+ view.backgroundColor = .clear
+ controller.view = view
+ return view
+ }
+
+ func updateUIView(_ uiView: ScrollRevealAnchorView, context: Context) {
+ controller.view = uiView
+ }
+}
+
+/// The view a `ScrollRevealView` puts in the UIKit hierarchy, a type of its own so that it
+/// can be picked out of one.
+final class ScrollRevealAnchorView: UIView {}
diff --git a/Sources/FlowStack/UIScreen+DisplayCorners.swift b/Sources/FlowStack/UIScreen+DisplayCorners.swift
index ea26ce0..94d2ff1 100644
--- a/Sources/FlowStack/UIScreen+DisplayCorners.swift
+++ b/Sources/FlowStack/UIScreen+DisplayCorners.swift
@@ -21,18 +21,33 @@ extension UIScreen {
return components.reversed().joined()
}()
- /// The corner radius of the display. Uses a private property of `UIScreen`,
- /// and may report 0 if the API changes.
- static var displayCornerRadius: CGFloat? = {
-
- guard let screen = UIWindowScene.firstForegroundScene?.screen else {
- return nil
+ /// The corner radius used when the display's real corner radius can't be
+ /// determined, or on square-cornered displays where a small radius still
+ /// looks better for presented views.
+ private static let fallbackCornerRadius: CGFloat = 12
+
+ private static var cachedDisplayCornerRadius: CGFloat?
+
+ /// The corner radius of the display. Uses a private property of `UIScreen`
+ /// and falls back to a sensible default if the API changes.
+ ///
+ /// This is a single value, so on displays whose corners differ (e.g. the
+ /// hinge side of a foldable) it only describes the roundest ones. Prefer
+ /// `GeometryProxy.concentricCornerRadii` where it's available.
+ static var displayCornerRadius: CGFloat {
+ if let cached = cachedDisplayCornerRadius {
+ return cached
}
- guard let cornerRadius = screen.value(forKey: cornerRadiusKey) as? CGFloat else {
- return nil
+ // Don't cache the fallback: a lookup that fails because no scene is
+ // foreground yet may succeed on a later attempt.
+ guard let screen = UIWindowScene.firstForegroundScene?.screen,
+ let cornerRadius = screen.value(forKey: cornerRadiusKey) as? CGFloat else {
+ return fallbackCornerRadius
}
- return cornerRadius > 0 ? cornerRadius : 12
- }()
+ let radius = cornerRadius > 0 ? cornerRadius : fallbackCornerRadius
+ cachedDisplayCornerRadius = radius
+ return radius
+ }
}
diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift
index a77c2d7..e53bb50 100644
--- a/Sources/FlowStack/View+InteractiveDismiss.swift
+++ b/Sources/FlowStack/View+InteractiveDismiss.swift
@@ -30,8 +30,13 @@ struct InteractiveDismissDisabledKey: PreferenceKey {
public extension View {
- /// A modifier that allows for disabling or enabling interactive dismiss functionality for the view.
- /// - Parameter isDisabled: A `bool` that that determines if interactive dismiss functionality should be disabled for the view.
+ /// Keeps a presented view from being dismissed by dragging it away, or, when it's
+ /// presented as a card, by tapping outside it.
+ ///
+ /// The view can still be dismissed with ``SwiftUICore/EnvironmentValues/flowDismiss``, or by
+ /// removing it from the ``FlowPath``.
+ ///
+ /// - Parameter isDisabled: Whether dragging the view away is disabled.
func flowInteractiveDismissDisabled(_ isDisabled: Bool = true) -> some View {
preference(key: InteractiveDismissDisabledKey.self, value: isDisabled)
}
@@ -57,8 +62,14 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable {
}
func updateUIViewController(_ uiViewController: InteractiveDismissViewController, context: Context) {
- context.coordinator.isEnabled = isEnabled
+ context.coordinator.threshold = threshold
+ context.coordinator.swipeUpToDismiss = swipeUpToDismiss
+ context.coordinator.onPan = onPan
+ context.coordinator.onDismiss = onDismiss
+ context.coordinator.onEnded = onEnded
context.coordinator.isDismissing = isDismissing
+ context.coordinator.isEnabled = isEnabled
+ uiViewController.rootView = content
}
func makeCoordinator() -> InteractiveDismissCoordinator {
@@ -80,18 +91,352 @@ class InteractiveDismissViewController: 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.
+ private struct HeldNavigationStack {
+ weak var navigationController: UINavigationController?
+ /// Where the bar sat, on systems that move it during a pull.
+ var barMinY: CGFloat?
+ var contentInsets: UIEdgeInsets
+ /// The edges of the content's safe area that are held. The top always is, for the bar.
+ /// Another edge is only held while nothing but the navigation controller contributes
+ /// to it: an edge with a keyboard against it, say, is worked out from the keyboard's
+ /// overlap by UIKit, which takes anything added to it into account and never settles.
+ var heldEdges: UIRectEdge
+ /// What has been added to the top view controller's safe area to hold its insets.
+ var addedInsets: UIEdgeInsets = .zero
+ var observations: [NSKeyValueObservation] = []
+ var floatingItems: [HeldFloatingItem] = []
+ }
+
+ /// An item floating over a navigation stack's content, and where it sat at rest in this view.
+ ///
+ /// From iOS 26 a navigation stack can float its 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 moves the view the items slide about inside it, staying put on
+ /// screen until the view's edge stops them. The pocket's layout works from where its
+ /// container is on screen, so the container can't be moved back without the layout
+ /// following it around; the item itself can, since the layout only places it.
+ private struct HeldFloatingItem {
+ /// The view that is moved back: the item's control, with the compact views around it
+ /// that its layout places as one.
+ weak var item: UIView?
+ weak var control: UIView?
+ /// Where the control sat at rest, in this view.
+ var center: CGPoint
+ var observations: [NSKeyValueObservation] = []
+ }
+
+ private var heldNavigationStacks: [HeldNavigationStack]?
+ /// Setting a safe area reports it changed, synchronously, which mustn't hold again.
+ private var isHoldingNavigationStacks = false
+
+ /// Holds the content of any navigation stack inside this view laid out as it was at rest.
+ ///
+ /// A navigation controller works its content's safe area out for itself, from where the
+ /// content sits on screen, so holding this view's safe area doesn't reach it. Its content
+ /// gains inset on an edge as the content overhangs that edge of the screen, which an edge
+ /// swipe does to the trailing edge, where iPhone Duo's camera and status items are. And
+ /// before iOS 26 the bar is placed by how far the content sits under the status bar: as
+ /// a pull moves the view down, the bar slides up inside it and the top inset shrinks.
+ private func holdNavigationStacks() {
+ guard !isHoldingNavigationStacks else { return }
+ isHoldingNavigationStacks = true
+ defer { isHoldingNavigationStacks = false }
+
+ if heldNavigationStacks == nil {
+ heldNavigationStacks = navigationControllers(in: self).compactMap { navigationController in
+ let bar = navigationController.navigationBar
+ guard bar.transform.isIdentity, let content = navigationController.topViewController else { return nil }
+
+ let contentInsets = content.view.safeAreaInsets
+ let ownInsets = navigationController.view.safeAreaInsets
+ var heldEdges: UIRectEdge = .top
+ if abs(contentInsets.left - ownInsets.left) < Constants.tolerance { heldEdges.insert(.left) }
+ if abs(contentInsets.right - ownInsets.right) < Constants.tolerance { heldEdges.insert(.right) }
+ if abs(contentInsets.bottom - ownInsets.bottom) < Constants.tolerance { heldEdges.insert(.bottom) }
+
+ var held = HeldNavigationStack(navigationController: navigationController, contentInsets: contentInsets, heldEdges: heldEdges)
+ if #unavailable(iOS 26.0), !navigationController.isNavigationBarHidden {
+ held.barMinY = bar.frame.minY
+ }
+ held.observations.append(bar.layer.observe(\.position) { [weak self] _, _ in
+ self?.holdNavigationStacks()
+ })
+ held.observations.append(content.view.observe(\.safeAreaInsets) { [weak self] _, _ in
+ self?.holdNavigationStacks()
+ })
+ held.floatingItems = floatingItems(in: navigationController)
+ return held
+ }
+ }
+
+ for index in (heldNavigationStacks ?? []).indices {
+ guard let held = heldNavigationStacks?[index], let navigationController = held.navigationController else { continue }
+
+ // Moved by a transform, which the navigation controller's own layout leaves be.
+ let bar = navigationController.navigationBar
+ 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)
+ }
+ }
+
+ for floating in held.floatingItems {
+ guard let item = floating.item, let control = floating.control, let superview = control.superview else { continue }
+ // Where the layout has put the control, in this view, were the item not moved back.
+ let placed = superview.convert(control.center, to: view)
+ let translation = CGPoint(
+ x: floating.center.x - placed.x + item.transform.tx,
+ y: floating.center.y - placed.y + item.transform.ty
+ )
+ if abs(translation.x - item.transform.tx) > 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 }
+ var added = held.addedInsets + held.contentInsets - content.view.safeAreaInsets
+ if !held.heldEdges.contains(.left) { added.left = 0 }
+ if !held.heldEdges.contains(.right) { added.right = 0 }
+ if !held.heldEdges.contains(.bottom) { added.bottom = 0 }
+ if !added.isApproximatelyEqual(to: held.addedInsets, tolerance: Constants.tolerance) {
+ heldNavigationStacks?[index].addedInsets = added
+ content.additionalSafeAreaInsets = content.additionalSafeAreaInsets + added - held.addedInsets
+ }
+ }
+ }
+
+ private func releaseNavigationStacks() {
+ for held in heldNavigationStacks ?? [] {
+ held.observations.forEach { $0.invalidate() }
+ guard let navigationController = held.navigationController else { continue }
+
+ navigationController.navigationBar.transform = .identity
+ for floating in held.floatingItems {
+ floating.observations.forEach { $0.invalidate() }
+ floating.item?.transform = .identity
+ }
+ if held.addedInsets != .zero, let content = navigationController.topViewController {
+ content.additionalSafeAreaInsets = content.additionalSafeAreaInsets - held.addedInsets
+ }
+ }
+ heldNavigationStacks = nil
+ }
+
+ /// The items floating over a navigation stack's content: each control in whatever the
+ /// navigation controller's view holds besides its bar and its content, taken with the
+ /// compact views around it that its layout places as one.
+ private func floatingItems(in navigationController: UINavigationController) -> [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 {
+ return [navigationController]
+ }
+ return navigationControllers(in: child)
+ }
+ }
+
+ private enum Constants {
+ static var tolerance: CGFloat { 0.1 }
+ static var settleTimeout: TimeInterval { 1 }
+ /// Long enough for a settling view to have stopped moving, short enough not to be seen.
+ static var barPlacementDelay: TimeInterval { 0.05 }
+ /// Larger than any floating item; smaller than the views that lay them out.
+ static var maxFloatingItemSize: CGFloat { 120 }
+ }
+
+ /// The safe area inset a view has lost on each edge since it was at rest.
+ ///
+ /// On each edge a view has the window's inset less however far it sits in from that edge,
+ /// and never less than none. A view that overhangs an edge gains nothing for it.
+ static func safeAreaCompensation(frame: CGRect, restingFrame: CGRect, windowBounds: CGRect, windowInsets: UIEdgeInsets) -> UIEdgeInsets {
+ func lost(_ distanceFromEdge: CGFloat, of inset: CGFloat) -> CGFloat {
+ min(max(0, distanceFromEdge), inset)
+ }
+
+ func compensation(_ distance: (CGRect) -> CGFloat, of inset: CGFloat) -> CGFloat {
+ lost(distance(frame), of: inset) - lost(distance(restingFrame), of: inset)
+ }
+
+ return UIEdgeInsets(
+ top: compensation({ $0.minY - windowBounds.minY }, of: windowInsets.top),
+ left: compensation({ $0.minX - windowBounds.minX }, of: windowInsets.left),
+ bottom: compensation({ windowBounds.maxY - $0.maxY }, of: windowInsets.bottom),
+ right: compensation({ windowBounds.maxX - $0.maxX }, of: windowInsets.right)
+ )
}
override func viewDidAppear(_ animated: Bool) {
@@ -132,7 +477,7 @@ class InteractiveDismissViewController: UIHostingController Void
@@ -141,9 +486,13 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
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)
}
}
@@ -159,13 +508,19 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
var onDismiss: () -> Void
var onEnded: (Bool) -> Void
- @SwiftUI.Environment(\.flowDismiss) var flowDismiss
-
private var panGestureRecognizer: UIPanGestureRecognizer!
private var edgeGestureRecognizer: UIScreenEdgePanGestureRecognizer!
- /// Bool that tracks active dragging to be used to track tool bar position for overlappingTopInset
- @Published var isUpdating: Bool = false
+ /// Whether a pull is under way, or has ended in a dismissal that is still animating.
+ var isUpdating: Bool = false {
+ didSet {
+ if isUpdating != oldValue {
+ onUpdatingChanged?()
+ }
+ }
+ }
+
+ var onUpdatingChanged: (() -> Void)?
private var isPastThreshold: Bool = false
private var impactGenerator: UIImpactFeedbackGenerator
@@ -215,7 +570,7 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
private func edgeGestureUpdated(recognizer: UIScreenEdgePanGestureRecognizer) {
guard let view = recognizer.view else { return }
let offset = recognizer.translation(in: view)
- update(offset: offset, isEdge: true, hasEnded: recognizer.state == .ended)
+ update(offset: offset, isEdge: true, state: recognizer.state)
}
@objc
@@ -223,32 +578,31 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
guard let view = recognizer.view else { return }
let offset = recognizer.translation(in: view)
- update(offset: offset, isEdge: false, hasEnded: recognizer.state == .ended)
+ update(offset: offset, isEdge: false, state: recognizer.state)
}
- private func update(offset: CGPoint, isEdge: Bool, hasEnded: Bool) {
- guard isEnabled else {
- if hasEnded {
- isUpdating = false
- onEnded(false)
- }
- return
- }
-
+ private func update(offset: CGPoint, isEdge: Bool, state: UIGestureRecognizer.State) {
+ guard isEnabled, !isDismissing else { return }
isUpdating = true
onPan(offset)
- let shouldDismiss = offset.y > threshold || (offset.x > threshold && isEdge) || (-offset.y > threshold * 2 && swipeUpToDismiss)
- if shouldDismiss != isPastThreshold && shouldDismiss, isEnabled {
+ let isPastThreshold = offset.y > threshold || (offset.x > threshold && isEdge) || (-offset.y > threshold * 2 && swipeUpToDismiss)
+ if isPastThreshold != self.isPastThreshold && isPastThreshold, isEnabled {
impactGenerator.impactOccurred()
}
- isPastThreshold = shouldDismiss
+ self.isPastThreshold = isPastThreshold
+
+ // The system can take a gesture away, for an incoming call, say. That has to end the
+ // pull as well, or the view is left wherever it had been pulled to. It shouldn't
+ // dismiss, though, since it wasn't the person's doing.
+ let hasEnded = state == .ended || state == .cancelled || state == .failed
+ let shouldDismiss = isPastThreshold && state == .ended
if hasEnded {
if shouldDismiss {
onDismiss()
- isPastThreshold = false
+ self.isPastThreshold = false
} else {
isUpdating = false
}
@@ -259,7 +613,7 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
}
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 }
@@ -267,15 +621,15 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
if panGestureRecognizer.translation(in: scrollView).y > 0 {
return scrollView.contentOffset.y - 5 <= -scrollView.contentInset.top
} else {
- let belowBounds = scrollView.contentOffset.y + UIScreen.main.bounds.height > scrollView.contentSize.height + 20 && swipeUpToDismiss
+ let belowBounds = scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height + 20 && swipeUpToDismiss
scrollView.isScrollEnabled = !belowBounds
return belowBounds
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
- guard isEnabled else { return true }
- guard (gestureRecognizer == panGestureRecognizer || gestureRecognizer == panGestureRecognizer), let scrollView = scrollView else {
+ guard isEnabled, !isDismissing else { return false }
+ guard gestureRecognizer == panGestureRecognizer || gestureRecognizer == edgeGestureRecognizer, let scrollView = scrollView else {
return true
}
scrollView.isScrollEnabled = true
@@ -313,3 +667,29 @@ extension View {
InteractiveDismissContainer(threshold: threshold, onPan: onPan, isEnabled: isEnabled, isDismissing: isDismissing, swipeUpToDismiss: swipeUpToDismiss, onDismiss: onDismiss, onEnded: onEnded, content: self)
}
}
+
+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.
+ func isApproximatelyEqual(to other: UIEdgeInsets, tolerance: CGFloat = 0.1) -> Bool {
+ abs(top - other.top) < tolerance && abs(left - other.left) < tolerance &&
+ abs(bottom - other.bottom) < tolerance && abs(right - other.right) < tolerance
+ }
+}
+
+extension CGRect {
+ /// Whether the rectangles differ by less than could be seen.
+ func isApproximatelyEqual(to other: CGRect, tolerance: CGFloat = 0.1) -> Bool {
+ abs(minX - other.minX) < tolerance && abs(minY - other.minY) < tolerance &&
+ abs(width - other.width) < tolerance && abs(height - other.height) < tolerance
+ }
+}
diff --git a/Tests/FlowStackTests/DismissDisabledTests.swift b/Tests/FlowStackTests/DismissDisabledTests.swift
new file mode 100644
index 0000000..9168bc5
--- /dev/null
+++ b/Tests/FlowStackTests/DismissDisabledTests.swift
@@ -0,0 +1,52 @@
+import SwiftUI
+import XCTest
+@testable import FlowStack
+
+@MainActor
+final class DismissDisabledTests: XCTestCase {
+ /// A destination that disables interactive dismissal can't be pulled away.
+ func testADestinationThatDisablesInteractiveDismissalCantBePulledAway() async throws {
+ let model = DismissDisabledModel()
+ let host = HostedView(DismissDisabledStack(model: model))
+ defer { host.close() }
+ let root: UIView = host.controller.view
+
+ withAnimation(.defaultFlow) { model.path.append(1) }
+ try await waitFor("the pull's gestures") { !self.pullGestures(in: root).isEmpty }
+ try await waitFor("the pull disabled") { self.pullGestures(in: root).allSatisfy { !$0.isEnabled } }
+
+ model.isDisabled = false
+ try await waitFor("the pull enabled") { self.pullGestures(in: root).allSatisfy(\.isEnabled) }
+ }
+
+ private func pullGestures(in view: UIView) -> [UIGestureRecognizer] {
+ let own = (view.gestureRecognizers ?? []).filter { $0.delegate is InteractiveDismissCoordinator }
+ return own + view.subviews.flatMap { pullGestures(in: $0) }
+ }
+}
+
+private final class DismissDisabledModel: ObservableObject {
+ @Published var path = FlowPath()
+ @Published var isDisabled = true
+}
+
+private struct DismissDisabledStack: View {
+ @ObservedObject var model: DismissDisabledModel
+
+ var body: some View {
+ FlowStack(path: $model.path) {
+ Color.white
+ .flowDestination(for: Int.self) { _ in
+ DismissDisabledDestination(model: model)
+ }
+ }
+ }
+}
+
+private struct DismissDisabledDestination: View {
+ @ObservedObject var model: DismissDisabledModel
+
+ var body: some View {
+ Color.blue.flowInteractiveDismissDisabled(model.isDisabled)
+ }
+}
diff --git a/Tests/FlowStackTests/DismissalRevealTests.swift b/Tests/FlowStackTests/DismissalRevealTests.swift
new file mode 100644
index 0000000..c03fce8
--- /dev/null
+++ b/Tests/FlowStackTests/DismissalRevealTests.swift
@@ -0,0 +1,171 @@
+import SwiftUI
+import XCTest
+@testable import FlowStack
+
+@available(iOS 16.0, *)
+@MainActor
+final class DismissalRevealTests: XCTestCase {
+ /// A link has a single view to reveal it from, even while it is being swapped for the
+ /// stand-in that holds its place as its destination is presented. A second one, made for
+ /// the stand-in, could leave the link holding the view that goes away with the link it
+ /// replaced, and with nothing to reveal it from as the destination is dismissed.
+ func testALinkKeepsOneViewToRevealItFromWhileItsDestinationIsPresented() async throws {
+ let model = BarredLinksModel()
+ let host = HostedView(BarredLinks(model: model))
+ defer { host.close() }
+ let root: UIView = host.controller.view
+
+ let target = try await linkTuckedUnderTheBar(in: root, model: model)
+ try await press(target, in: root)
+ try await waitFor("the destination") { model.path.count == 1 }
+
+ XCTAssertEqual(revealViews(over: target, in: root), 1)
+ }
+
+ /// A link that is partly under a bar is brought out from under it as its destination is
+ /// dismissed, including by a dismissal started from asynchronous code, which runs before
+ /// SwiftUI has updated the link for the destination leaving.
+ func testDismissalFromAsyncCodeRevealsALinkUnderABar() async throws {
+ let model = BarredLinksModel()
+ let host = HostedView(BarredLinks(model: model))
+ defer { host.close() }
+ let root: UIView = host.controller.view
+
+ let target = try await linkTuckedUnderTheBar(in: root, model: model)
+ let scrollView = try XCTUnwrap(scrollView(in: root))
+ try await press(target, in: root)
+ try await waitFor("the destination") { model.path.count == 1 }
+ try await Task.sleep(nanoseconds: 800_000_000)
+
+ model.dismiss?()
+
+ try await waitFor("the dismissal") { model.path.isEmpty }
+ try await waitFor("the link clear of the bar") {
+ let current = self.target(in: root)
+ return current.map { self.isClearOfBar($0, in: scrollView) } ?? false
+ }
+ }
+
+ /// Scrolls the target link part of the way under the bar, and returns it.
+ private func linkTuckedUnderTheBar(in root: UIView, model: BarredLinksModel) async throws -> UIView {
+ try await waitFor("the links and the dismiss action") {
+ model.dismiss != nil && self.scrollView(in: root) != nil && self.target(in: root) != nil
+ }
+ let scrollView = try XCTUnwrap(scrollView(in: root))
+ let target = try XCTUnwrap(target(in: root))
+
+ let frame = target.convert(target.bounds, to: scrollView)
+ scrollView.setContentOffset(CGPoint(x: 0, y: frame.minY - scrollView.adjustedContentInset.top + 60), animated: false)
+ try await waitFor("the link under the bar") { !self.isClearOfBar(target, in: scrollView) }
+ return target
+ }
+
+ /// Presses the link with its own button, as a tap would.
+ private func press(_ target: UIView, in root: UIView) async throws {
+ let button = try XCTUnwrap(linkButton(over: target, in: root))
+ let press = try XCTUnwrap(button.allTargets.lazy.compactMap { $0 as? GestureContainer.Coordinator }.first)
+ press.onEnter()
+ try await Task.sleep(nanoseconds: 100_000_000)
+ press.onTouchUpInside()
+ }
+
+ private func isClearOfBar(_ view: UIView, in scrollView: UIScrollView) -> Bool {
+ let visible = scrollView.bounds.inset(by: scrollView.adjustedContentInset)
+ return view.window != nil && visible.contains(view.convert(view.bounds, to: scrollView))
+ }
+
+ /// The button a flow link lays over its label.
+ private func linkButton(over target: UIView, in root: UIView) -> UIButton? {
+ guard let window = target.window else { return nil }
+ let point = target.convert(CGPoint(x: target.bounds.midX, y: target.bounds.maxY - 20), to: window)
+ func find(_ view: UIView) -> UIButton? {
+ if let button = view as? UIButton, button.allTargets.contains(where: { $0 is GestureContainer.Coordinator }),
+ button.convert(button.bounds, to: window).contains(point) {
+ return button
+ }
+ return view.subviews.lazy.compactMap { find($0) }.first
+ }
+ return find(root)
+ }
+
+ /// How many of the views that links are revealed from lie over the target link.
+ private func revealViews(over target: UIView, in root: UIView) -> Int {
+ guard let window = target.window else { return 0 }
+ let point = target.convert(CGPoint(x: target.bounds.midX, y: target.bounds.midY), to: window)
+ func count(_ view: UIView) -> Int {
+ let isOver = view is ScrollRevealAnchorView && view.window != nil && view.convert(view.bounds, to: window).contains(point)
+ return (isOver ? 1 : 0) + view.subviews.reduce(0) { $0 + count($1) }
+ }
+ return count(root)
+ }
+
+ private func scrollView(in view: UIView) -> UIScrollView? {
+ if let scrollView = view as? UIScrollView, scrollView.contentSize.height > scrollView.bounds.height { return scrollView }
+ return view.subviews.lazy.compactMap { self.scrollView(in: $0) }.first
+ }
+
+ private func target(in view: UIView) -> UIView? {
+ if view.accessibilityIdentifier == "barred-target", view.window != nil { return view }
+ return view.subviews.lazy.compactMap { self.target(in: $0) }.first
+ }
+}
+
+private final class BarredLinksModel: ObservableObject {
+ @Published var path = FlowPath()
+ var dismiss: FlowDismissAction?
+}
+
+private struct BarredRow: Identifiable, Hashable {
+ let id: Int
+}
+
+/// Links in a navigation stack, scrolling under a bar of the app's own.
+@available(iOS 16.0, *)
+private struct BarredLinks: View {
+ @ObservedObject var model: BarredLinksModel
+
+ var body: some View {
+ FlowStack(path: $model.path) {
+ NavigationStack {
+ ScrollView {
+ LazyVStack(spacing: 24) {
+ ForEach((0..<8).map(BarredRow.init)) { row in
+ FlowLink(value: row, configuration: .init(cornerRadius: 24)) {
+ BarredRowLabel(row: row).frame(height: 240)
+ }
+ }
+ }
+ .padding(.horizontal)
+ }
+ .toolbar(.hidden, for: .navigationBar)
+ .safeAreaInset(edge: .top, spacing: 0) {
+ Color.red.frame(height: 100)
+ }
+ }
+ .background(DismissProbe(model: model))
+ .flowDestination(for: BarredRow.self) { row in
+ Text("Row \(row.id)").frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.white)
+ }
+ }
+ }
+}
+
+private struct DismissProbe: View {
+ @Environment(\.flowDismiss) private var dismiss
+ let model: BarredLinksModel
+
+ var body: some View {
+ Color.clear.onAppear { model.dismiss = dismiss }
+ }
+}
+
+private struct BarredRowLabel: UIViewRepresentable {
+ let row: BarredRow
+
+ func makeUIView(context: Context) -> UILabel { UILabel() }
+
+ func updateUIView(_ label: UILabel, context: Context) {
+ label.text = "Row \(row.id)"
+ label.accessibilityIdentifier = row.id == 2 ? "barred-target" : nil
+ }
+}
diff --git a/Tests/FlowStackTests/FlowLinkLifecycleTests.swift b/Tests/FlowStackTests/FlowLinkLifecycleTests.swift
new file mode 100644
index 0000000..8351522
--- /dev/null
+++ b/Tests/FlowStackTests/FlowLinkLifecycleTests.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+import XCTest
+@testable import FlowStack
+
+@MainActor
+final class FlowLinkLifecycleTests: XCTestCase {
+ func testRegistrationFollowsValueIdentityAndDepthWithoutResizing() async throws {
+ let store = FlowLinkContextStore()
+ let model = LinkInputs()
+ let host = HostedView(LinkFixture(model: model, store: store))
+ defer { host.close() }
+
+ try await waitFor("initial link") { self.context(in: store, value: "before", id: "first", depth: 0) != nil }
+
+ model.value = "after"
+ try await waitFor("new value") { self.context(in: store, value: "after", id: "first", depth: 0) != nil }
+ XCTAssertNil(store.context(for: AnyHashable("before"), atLevel: 0), "A refreshed row must not remain a return target for its previous value")
+
+ model.linkID = "second"
+ try await waitFor("new explicit identity") { self.context(in: store, value: "after", id: "second", depth: 0) != nil }
+ XCTAssertNil(context(in: store, value: "after", id: "first", depth: 0))
+
+ model.depth = 1
+ try await waitFor("new depth") { self.context(in: store, value: "after", id: "second", depth: 1) != nil }
+ XCTAssertNil(store.context(for: AnyHashable("after"), atLevel: 0))
+
+ model.isVisible = false
+ try await waitFor("removal of the last registration") { store.context(for: AnyHashable("after"), atLevel: 1) == nil }
+ }
+
+ func testIneligibleLinksUnregisterAndCanRegisterAgain() async throws {
+ let store = FlowLinkContextStore()
+ let model = LinkInputs()
+ let host = HostedView(LinkFixture(model: model, store: store))
+ defer { host.close() }
+
+ try await waitFor("initial link") { store.context(for: AnyHashable("before"), atLevel: 0) != nil }
+ model.value = nil
+ try await waitFor("nil value to unregister") { store.context(for: AnyHashable("before"), atLevel: 0) == nil }
+
+ model.value = "before"
+ try await waitFor("restored value") { store.context(for: AnyHashable("before"), atLevel: 0) != nil }
+ model.animateFromAnchor = false
+ try await waitFor("disabled anchor to unregister") { store.context(for: AnyHashable("before"), atLevel: 0) == nil }
+
+ model.animateFromAnchor = true
+ try await waitFor("restored anchor") { store.context(for: AnyHashable("before"), atLevel: 0) != nil }
+ model.isVisible = false
+ try await waitFor("disappearance") { store.context(for: AnyHashable("before"), atLevel: 0) == nil }
+ model.isVisible = true
+ try await waitFor("recreated link") { store.context(for: AnyHashable("before"), atLevel: 0) != nil }
+ }
+
+ private func context(in store: FlowLinkContextStore, value: String, id: String, depth: Int) -> PathContext? {
+ store.context(for: AnyHashable(value), atLevel: depth, source: FlowLinkSource(link: .explicit(AnyHashable(id))))
+ }
+}
+
+private final class LinkInputs: ObservableObject {
+ @Published var value: String? = "before"
+ @Published var linkID = "first"
+ @Published var depth = 0
+ @Published var animateFromAnchor = true
+ @Published var isVisible = true
+}
+
+private struct LinkFixture: View {
+ @ObservedObject var model: LinkInputs
+ let store: FlowLinkContextStore
+
+ var body: some View {
+ Group {
+ if model.isVisible {
+ FlowLink(value: model.value, configuration: .init(animateFromAnchor: model.animateFromAnchor, transitionFromSnapshot: false)) {
+ Color.red.frame(width: 100, height: 100)
+ }
+ .flowLinkID(model.linkID)
+ .environment(\.flowDepth, model.depth)
+ }
+ }
+ .environment(\.flowLinkContexts, store)
+ }
+}
diff --git a/Tests/FlowStackTests/FlowStackTests.swift b/Tests/FlowStackTests/FlowStackTests.swift
index eef770c..5feabd5 100644
--- a/Tests/FlowStackTests/FlowStackTests.swift
+++ b/Tests/FlowStackTests/FlowStackTests.swift
@@ -1,11 +1,680 @@
+import SwiftUI
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!")
+/// Stands in for the object whose identity a flow link registers under.
+private final class OwnerToken {}
+private var ownerTokens: [OwnerToken] = []
+
+/// A distinct owner, kept alive so that its identity isn't reused.
+private func owner() -> ObjectIdentifier {
+ let token = OwnerToken()
+ ownerTokens.append(token)
+ return ObjectIdentifier(token)
+}
+
+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)
+ }
+}
+
+final class CornerRadiiTests: XCTestCase {
+
+ private let duo = CornerRadii(topLeft: 8, topRight: 59, bottomLeft: 8, bottomRight: 59)
+
+ func testUniformInitializer() {
+ let radii = CornerRadii(uniform: 12)
+ XCTAssertTrue(radii.isUniform)
+ XCTAssertEqual(radii.maximum, 12)
+ }
+
+ func testIsUniformDetectsDifferingCorners() {
+ XCTAssertFalse(duo.isUniform)
+ XCTAssertEqual(duo.maximum, 59)
+ }
+
+ func testInterpolationStartsAtSharedRadius() {
+ XCTAssertEqual(duo.interpolated(from: 20, percent: 0), CornerRadii(uniform: 20))
+ }
+
+ func testInterpolationEndsAtEachCornersRadius() {
+ XCTAssertEqual(duo.interpolated(from: 20, percent: 1), duo)
+ }
+
+ func testInterpolationMovesEachCornerIndependently() {
+ let halfway = duo.interpolated(from: 20, percent: 0.5)
+ XCTAssertEqual(halfway.topLeft, 14)
+ XCTAssertEqual(halfway.bottomLeft, 14)
+ XCTAssertEqual(halfway.topRight, 39.5)
+ XCTAssertEqual(halfway.bottomRight, 39.5)
+ }
+
+ func testInterpolationNeverProducesNegativeRadii() {
+ // Bouncy animations overshoot percent past 1, which extrapolates a
+ // corner that shrinks (20 -> 8) toward and beyond zero.
+ let overshot = duo.interpolated(from: 20, percent: 3)
+ XCTAssertEqual(overshot.topLeft, 0)
+ XCTAssertEqual(overshot.bottomLeft, 0)
+ }
+
+ func testAnimatableDataRoundTrips() {
+ var radii = CornerRadii.zero
+ radii.animatableData = duo.animatableData
+ XCTAssertEqual(radii, duo)
+ }
+
+ func testMapTransformsEveryCorner() {
+ XCTAssertEqual(duo.map { $0 / 2 }, CornerRadii(topLeft: 4, topRight: 29.5, bottomLeft: 4, bottomRight: 29.5))
+ }
+}
+
+final class FlowPresentationStyleTests: XCTestCase {
+
+ func testFullScreenAlwaysFills() {
+ for idiom in [UIUserInterfaceIdiom.phone, .pad] {
+ XCTAssertTrue(FlowPresentationStyle.fullScreen.isFullScreen(cardFits: true, idiom: idiom))
+ XCTAssertTrue(FlowPresentationStyle.fullScreen.isFullScreen(cardFits: false, idiom: idiom))
+ }
+ }
+
+ func testCardPresentsCardWheneverOneFits() {
+ for idiom in [UIUserInterfaceIdiom.phone, .pad] {
+ XCTAssertFalse(FlowPresentationStyle.card.isFullScreen(cardFits: true, idiom: idiom))
+ XCTAssertTrue(FlowPresentationStyle.card.isFullScreen(cardFits: false, idiom: idiom))
+ }
+ }
+
+ func testAutomaticFillsOnPhoneEvenWhenCardFits() {
+ // e.g. an unfolded iPhone Duo or a landscape iPhone Pro Max.
+ XCTAssertTrue(FlowPresentationStyle.automatic.isFullScreen(cardFits: true, idiom: .phone))
+ XCTAssertTrue(FlowPresentationStyle.automatic.isFullScreen(cardFits: false, idiom: .phone))
+ }
+
+ func testAutomaticPresentsCardOnPadWhenOneFits() {
+ XCTAssertFalse(FlowPresentationStyle.automatic.isFullScreen(cardFits: true, idiom: .pad))
+ // e.g. iPad Split View or Slide Over at compact width.
+ XCTAssertTrue(FlowPresentationStyle.automatic.isFullScreen(cardFits: false, idiom: .pad))
+ }
+
+ func testConfigurationDefaultsToAutomatic() {
+ XCTAssertEqual(FlowLink.Configuration().presentationStyle, .automatic)
+ XCTAssertEqual(PathContext().presentationStyle, .automatic)
+ }
+}
+
+final class ScrollRevealTests: XCTestCase {
+
+ private let margin = ScrollRevealController.margin
+
+ /// A 400x800 scroll view under a 100pt navigation bar, showing 3000pt of content.
+ private func offset(revealing frame: CGRect, scrolledTo y: CGFloat, contentHeight: CGFloat = 3000) -> CGPoint {
+ ScrollRevealController.contentOffset(
+ revealing: frame,
+ in: CGRect(x: 0, y: y, width: 400, height: 800),
+ insets: UIEdgeInsets(top: 100, left: 0, bottom: 34, right: 0),
+ contentSize: CGSize(width: 400, height: contentHeight)
+ )
+ }
+
+ func testFullyVisibleFrameLeavesOffsetUnchanged() {
+ let frame = CGRect(x: 16, y: 1300, width: 368, height: 300)
+ XCTAssertEqual(offset(revealing: frame, scrolledTo: 1000), CGPoint(x: 0, y: 1000))
+ }
+
+ func testFrameUnderTheNavigationBarIsBroughtOutFromUnderIt() {
+ // Visible content starts at 1000 + 100; this frame starts above that.
+ let frame = CGRect(x: 16, y: 1050, width: 368, height: 300)
+ let result = offset(revealing: frame, scrolledTo: 1000)
+ XCTAssertEqual(result.y, 1050 - 100 - margin)
+ }
+
+ func testFrameBelowTheVisibleAreaIsAlignedToItsBottom() {
+ let frame = CGRect(x: 16, y: 1700, width: 368, height: 300)
+ let result = offset(revealing: frame, scrolledTo: 1000)
+ // Visible content ends at offset + 800 - 34 - margin.
+ XCTAssertEqual(result.y + 800 - 34 - margin, frame.maxY)
+ }
+
+ func testFrameTallerThanTheVisibleAreaIsAlignedToItsTop() {
+ let frame = CGRect(x: 16, y: 1200, width: 368, height: 900)
+ let result = offset(revealing: frame, scrolledTo: 1000)
+ XCTAssertEqual(result.y, 1200 - 100 - margin)
+ }
+
+ func testOffsetIsClampedToTheStartOfTheContent() {
+ // The first item can't be given a margin without scrolling past the top.
+ let frame = CGRect(x: 16, y: 0, width: 368, height: 300)
+ let result = offset(revealing: frame, scrolledTo: 40)
+ XCTAssertEqual(result.y, -100)
+ }
+
+ func testOffsetIsClampedToTheEndOfTheContent() {
+ let frame = CGRect(x: 16, y: 2700, width: 368, height: 300)
+ let result = offset(revealing: frame, scrolledTo: 2000)
+ XCTAssertEqual(result.y, 3000 - 800 + 34)
+ }
+
+ func testContentThatDoesNotScrollIsLeftAlone() {
+ let frame = CGRect(x: 16, y: -50, width: 368, height: 300)
+ let result = offset(revealing: frame, scrolledTo: -100, contentHeight: 400)
+ XCTAssertEqual(result, CGPoint(x: 0, y: -100))
+ }
+}
+
+final class FlowLinkContextStoreTests: XCTestCase {
+
+ private func key(_ value: String, level: Int? = 0) -> FlowLinkContextStore.Key {
+ .init(value: AnyHashable(value), level: level)
+ }
+
+ func testContextIsResolvedByValueAndLevel() {
+ let store = FlowLinkContextStore()
+ store.update(PathContext(cornerRadius: 7), for: key("a"), owner: owner(), reveal: {})
+
+ XCTAssertEqual(store.context(for: AnyHashable("a"), atLevel: 0)?.cornerRadius, 7)
+ XCTAssertNil(store.context(for: AnyHashable("a"), atLevel: 1))
+ XCTAssertNil(store.context(for: AnyHashable("b"), atLevel: 0))
+ }
+
+ func testLinkWithoutALevelMatchesAnyLevel() {
+ let store = FlowLinkContextStore()
+ store.update(PathContext(cornerRadius: 7), for: key("a", level: nil), owner: owner(), reveal: {})
+
+ XCTAssertEqual(store.context(for: AnyHashable("a"), atLevel: 3)?.cornerRadius, 7)
+ }
+
+ func testRemovingIsIgnoredOnceAnotherLinkHasTakenOver() {
+ // When a layout swaps containers, the replacement link registers before
+ // the link it replaces disappears.
+ let store = FlowLinkContextStore()
+ let old = owner(), replacement = owner()
+ store.update(PathContext(cornerRadius: 1), for: key("a"), owner: old, reveal: {})
+ store.update(PathContext(cornerRadius: 2), for: key("a"), owner: replacement, reveal: {})
+
+ store.remove(key("a"), owner: old)
+ XCTAssertEqual(store.context(for: AnyHashable("a"), atLevel: 0)?.cornerRadius, 2)
+
+ store.remove(key("a"), owner: replacement)
+ XCTAssertNil(store.context(for: AnyHashable("a"), atLevel: 0))
+ }
+
+ func testDismissingAnElementRevealsItsLink() {
+ let store = FlowLinkContextStore()
+ var revealed: [String] = []
+ store.update(PathContext(), for: key("a"), owner: owner(), reveal: { revealed.append("a") })
+ store.update(PathContext(), for: key("b", level: 1), owner: owner(), reveal: { revealed.append("b") })
+
+ var path = FlowPath()
+ path.append("a")
+ path.append("b")
+ store.pathDidChange(to: path.elements)
+ waitForNextRunLoopPass()
+ XCTAssertEqual(revealed, [], "Presenting must never move a link")
+
+ path.removeLast()
+ store.pathDidChange(to: path.elements)
+ waitForNextRunLoopPass()
+ XCTAssertEqual(revealed, ["b"])
+
+ path.removeAll()
+ store.pathDidChange(to: path.elements)
+ waitForNextRunLoopPass()
+ XCTAssertEqual(revealed, ["b", "a"])
+ }
+
+ func testDismissalSeenInThePathIsHandledAfterTheViewUpdate() {
+ // The path changes during a view update, where a snapshot can't be taken.
+ let store = FlowLinkContextStore()
+ var events: [String] = []
+ store.update(PathContext(), for: key("a"), owner: owner(), reveal: { events.append("reveal") }, prepareSnapshot: { events.append("snapshot") })
+
+ var path = FlowPath()
+ path.append("a")
+ store.pathDidChange(to: path.elements)
+ path.removeLast()
+ store.pathDidChange(to: path.elements)
+ XCTAssertEqual(events, [], "Nothing may happen until the view update is over")
+
+ waitForNextRunLoopPass()
+ XCTAssertEqual(events, ["reveal", "snapshot"])
+ }
+
+ func testDismissalThatRevealedItsLinkDoesNotRevealItAgain() {
+ // FlowDismissAction reveals the link before it removes the element, so the path
+ // change that follows has nothing left to do.
+ let store = FlowLinkContextStore()
+ var reveals = 0
+ store.update(PathContext(), for: key("a"), owner: owner(), reveal: { reveals += 1 })
+
+ var path = FlowPath()
+ path.append("a")
+ store.pathDidChange(to: path.elements)
+
+ store.revealLink(for: AnyHashable("a"), atLevel: 0) { path.removeLast() }
+ store.pathDidChange(to: path.elements)
+ waitForNextRunLoopPass()
+ XCTAssertEqual(reveals, 1)
+
+ // Presented and dismissed again, this time by removing it from the path directly.
+ path.append("a")
+ store.pathDidChange(to: path.elements)
+ path.removeLast()
+ store.pathDidChange(to: path.elements)
+ waitForNextRunLoopPass()
+ XCTAssertEqual(reveals, 2)
+ }
+
+ private func waitForNextRunLoopPass() {
+ let passed = expectation(description: "next run loop pass")
+ DispatchQueue.main.async { passed.fulfill() }
+ wait(for: [passed], timeout: 1)
+ }
+}
+
+final class FlowLinkRevealTests: XCTestCase {
+
+ func testRevealingAnExistingLinkCompletesImmediately() {
+ let store = FlowLinkContextStore()
+ var events: [String] = []
+ store.update(
+ PathContext(),
+ for: .init(value: AnyHashable("a"), level: 0),
+ owner: owner(),
+ reveal: { events.append("reveal") },
+ prepareSnapshot: { events.append("snapshot") }
+ )
+
+ store.revealLink(for: AnyHashable("a"), atLevel: 0) { events.append("completion") }
+
+ // The snapshot is ready before the dismissal is allowed to begin.
+ XCTAssertEqual(events, ["reveal", "snapshot", "completion"])
+ }
+
+ func testRevealingWithNothingToScrollCompletesImmediately() {
+ // No link and no scroll view to find one in: the dismissal must not be held up.
+ let store = FlowLinkContextStore()
+ var completed = false
+
+ store.revealLink(for: AnyHashable("a"), atLevel: 0) { completed = true }
+
+ XCTAssertTrue(completed)
+ }
+}
+
+final class SnapshotInputsTests: XCTestCase {
+
+ private func inputs(size: CGSize? = CGSize(width: 300, height: 200), dynamicTypeSize: DynamicTypeSize = .large, value: AnyHashable? = AnyHashable("a")) -> SnapshotInputs {
+ SnapshotInputs(
+ value: value,
+ size: size,
+ dynamicTypeSize: dynamicTypeSize,
+ legibilityWeight: .regular,
+ colorSchemeContrast: .standard,
+ layoutDirection: .leftToRight,
+ locale: Locale(identifier: "en_US"),
+ displayScale: 3
+ )
+ }
+
+ func testUnchangedInputsKeepASnapshotCurrent() {
+ XCTAssertEqual(inputs(), inputs())
+ }
+
+ func testResizingMakesASnapshotStale() {
+ XCTAssertNotEqual(inputs(), inputs(size: CGSize(width: 150, height: 100)))
+ }
+
+ func testRestylingWithoutResizingMakesASnapshotStale() {
+ // Larger text can reflow inside a frame that doesn't change.
+ XCTAssertNotEqual(inputs(), inputs(dynamicTypeSize: .accessibility2))
+ }
+
+ func testPresentingAnotherValueMakesASnapshotStale() {
+ XCTAssertNotEqual(inputs(), inputs(value: AnyHashable("b")))
+ }
+}
+
+final class SafeAreaCompensationTests: XCTestCase {
+
+ private typealias Controller = InteractiveDismissViewController
+
+ private let portrait = CGRect(x: 0, y: 0, width: 393, height: 852)
+ private let portraitInsets = UIEdgeInsets(top: 59, left: 0, bottom: 34, right: 0)
+
+ private let landscape = CGRect(x: 0, y: 0, width: 852, height: 393)
+ private let landscapeInsets = UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59)
+
+ private func compensation(_ frame: CGRect, restingAt resting: CGRect? = nil, in window: CGRect, insets: UIEdgeInsets) -> UIEdgeInsets {
+ Controller.safeAreaCompensation(frame: frame, restingFrame: resting ?? window, windowBounds: window, windowInsets: insets)
+ }
+
+ func testViewAtRestNeedsNoCompensation() {
+ XCTAssertEqual(compensation(portrait, in: portrait, insets: portraitInsets), .zero)
+ }
+
+ func testPullingDownMakesUpTheTopInsetItLoses() {
+ let pulled = portrait.offsetBy(dx: 0, dy: 40)
+ XCTAssertEqual(compensation(pulled, in: portrait, insets: portraitInsets).top, 40)
+ }
+
+ func testNoMoreIsMadeUpThanTheInsetThereWas() {
+ let pulled = portrait.offsetBy(dx: 0, dy: 200)
+ XCTAssertEqual(compensation(pulled, in: portrait, insets: portraitInsets).top, 59)
+ }
+
+ func testOverhangingAnEdgeGainsNothing() {
+ // Pulled down, the view hangs off the bottom of the screen.
+ let pulled = portrait.offsetBy(dx: 0, dy: 40)
+ XCTAssertEqual(compensation(pulled, in: portrait, insets: portraitInsets).bottom, 0)
+ }
+
+ func testEdgeSwipeInLandscapeMakesUpTheLeadingInset() {
+ // The island can be on either side; an edge swipe drags the view away from the left.
+ let swiped = landscape.offsetBy(dx: 30, dy: 0)
+ let result = compensation(swiped, in: landscape, insets: landscapeInsets)
+ XCTAssertEqual(result.left, 30)
+ XCTAssertEqual(result.right, 0)
+ }
+
+ func testEveryEdgeIsMadeUpForAViewThatShrinksInPlace() {
+ let shrunk = landscape.insetBy(dx: 20, dy: 10)
+ let result = compensation(shrunk, in: landscape, insets: landscapeInsets)
+ XCTAssertEqual(result, UIEdgeInsets(top: 0, left: 20, bottom: 10, right: 20))
+ }
+
+ func testViewThatRestsAwayFromTheEdgesDoesNotJumpWhenAPullBegins() {
+ // A card on iPad rests clear of every inset, so it has none to lose.
+ let window = CGRect(x: 0, y: 0, width: 1024, height: 1366)
+ let insets = UIEdgeInsets(top: 24, left: 0, bottom: 20, right: 0)
+ let card = window.insetBy(dx: 159, dy: 184)
+
+ XCTAssertEqual(compensation(card, restingAt: card, in: window, insets: insets), .zero)
+ XCTAssertEqual(compensation(card.offsetBy(dx: 0, dy: 50), restingAt: card, in: window, insets: insets), .zero)
+ }
+
+ func testViewCountsAsBackAtRestOnceItIsTooCloseToTell() {
+ // A view that was let go springs back, and gets there by ever smaller amounts.
+ XCTAssertTrue(portrait.offsetBy(dx: 0, dy: 0.05).isApproximatelyEqual(to: portrait))
+ XCTAssertTrue(portrait.insetBy(dx: 0.02, dy: 0.02).isApproximatelyEqual(to: portrait))
+ XCTAssertFalse(portrait.offsetBy(dx: 0, dy: 0.5).isApproximatelyEqual(to: portrait))
+ 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"])
}
}
diff --git a/Tests/FlowStackTests/HostedDestinationTests.swift b/Tests/FlowStackTests/HostedDestinationTests.swift
new file mode 100644
index 0000000..686aae0
--- /dev/null
+++ b/Tests/FlowStackTests/HostedDestinationTests.swift
@@ -0,0 +1,198 @@
+import SwiftUI
+import XCTest
+@testable import FlowStack
+
+@MainActor
+final class HostedDestinationTests: XCTestCase {
+ func testDisabledDismissalKeepsTheDestinationInteractiveThenCanBeEnabled() async throws {
+ let model = StackInputs()
+ let host = HostedView(StackFixture(model: model))
+ defer { host.close() }
+ try await waitFor("presented destination") { self.button(in: host.controller.view)?.title(for: .normal) == "live:0" }
+ let (dismissView, coordinator) = try XCTUnwrap(dismissalHost(in: host.controller.view))
+ try await waitFor("disabled preference to reach the recognizer") { !coordinator.isEnabled }
+
+ let input = DismissGestureInput()
+ let inputView = UIView()
+ inputView.addGestureRecognizer(input)
+ try input.send(.ended, offset: CGPoint(x: 0, y: 100), to: coordinator)
+ let button = try XCTUnwrap(button(in: host.controller.view))
+ button.sendActions(for: .touchUpInside)
+ try await waitFor("content update after the rejected gesture") { button.title(for: .normal) == "live:1" }
+ XCTAssertEqual(model.path.count, 1)
+ var ancestor: UIView? = button
+ while let view = ancestor {
+ XCTAssertTrue(view.isUserInteractionEnabled, "A rejected dismissal must not disable the destination's view hierarchy")
+ ancestor = view.superview
+ }
+
+ model.disabled = false
+ try await waitFor("dismissal to be enabled") { coordinator.isEnabled }
+
+ // Disabling while the finger is down reaches the coordinator through a
+ // representable update, unlike the direct coordinator cancellation test.
+ let restingFrame = dismissView.convert(dismissView.bounds, to: host.controller.view)
+ try input.send(.began, offset: CGPoint(x: 0, y: 40), to: coordinator)
+ try await waitFor("destination to move with the pull") {
+ abs(dismissView.convert(dismissView.bounds, to: host.controller.view).minY - restingFrame.minY) > 1
+ }
+ model.disabled = true
+ try await waitFor("cancelled destination to return to rest") {
+ !coordinator.isEnabled && abs(dismissView.convert(dismissView.bounds, to: host.controller.view).minY - restingFrame.minY) < 1
+ }
+ XCTAssertEqual(model.path.count, 1)
+ model.disabled = false
+ try await waitFor("dismissal to be enabled again") { coordinator.isEnabled }
+ try input.send(.ended, offset: CGPoint(x: 0, y: 100), to: coordinator)
+ try await waitFor("accepted dismissal to remove the destination") { model.path.isEmpty }
+ }
+
+ func testNewInputsReachContentWithoutResettingItsState() async throws {
+ let model = DestinationInputs()
+ let host = HostedView(DestinationFixture(model: model))
+ defer { host.close() }
+
+ try await waitFor("initial destination") { self.button(in: host.controller.view)?.title(for: .normal) == "before:0" }
+ let button = try XCTUnwrap(button(in: host.controller.view))
+ button.sendActions(for: .touchUpInside)
+ try await waitFor("local state change") { button.title(for: .normal) == "before:1" }
+
+ model.value = "after"
+ try await waitFor("updated content with preserved state") { self.button(in: host.controller.view)?.title(for: .normal) == "after:1" }
+ let updatedButton = try XCTUnwrap(self.button(in: host.controller.view))
+ updatedButton.sendActions(for: .touchUpInside)
+ try await waitFor("content still accepting input") { updatedButton.title(for: .normal) == "after:2" }
+ }
+
+ func testContentUpdatePreservesFocusedTextEntry() async throws {
+ let model = DestinationInputs()
+ let host = HostedView(DestinationFixture(model: model))
+ defer { host.close() }
+ try await waitFor("text field") { self.textField(in: host.controller.view) != nil }
+ let field = try XCTUnwrap(textField(in: host.controller.view))
+ XCTAssertTrue(field.becomeFirstResponder())
+ field.text = "unsaved note"
+ field.sendActions(for: .editingChanged)
+ try await waitFor("text entry to reach SwiftUI state") { self.button(in: host.controller.view)?.accessibilityValue == "unsaved note" }
+
+ model.value = "after"
+ try await waitFor("content update while editing") { self.button(in: host.controller.view)?.title(for: .normal) == "after:0" }
+ let updatedField = try XCTUnwrap(textField(in: host.controller.view))
+ XCTAssertEqual(updatedField.text, "unsaved note")
+ XCTAssertTrue(updatedField.isFirstResponder, "Refreshing destination inputs must not dismiss its keyboard")
+ }
+
+ private func button(in view: UIView) -> UIButton? {
+ if let button = view as? UIButton, button.accessibilityIdentifier == "destination-counter" { return button }
+ return view.subviews.compactMap { button(in: $0) }.first
+ }
+
+ private func dismissalHost(in view: UIView) -> (UIView, InteractiveDismissCoordinator)? {
+ if let coordinator = view.gestureRecognizers?.compactMap({ $0.delegate as? InteractiveDismissCoordinator }).first { return (view, coordinator) }
+ return view.subviews.compactMap { dismissalHost(in: $0) }.first
+ }
+
+ private func textField(in view: UIView) -> UITextField? {
+ if let field = view as? UITextField { return field }
+ return view.subviews.compactMap { textField(in: $0) }.first
+ }
+}
+
+private final class StackInputs: ObservableObject {
+ @Published var path: FlowPath = {
+ var path = FlowPath()
+ path.append("destination")
+ return path
+ }()
+ @Published var disabled = true
+}
+
+private struct StackFixture: View {
+ @ObservedObject var model: StackInputs
+
+ var body: some View {
+ FlowStack(path: $model.path) {
+ Color.clear.flowDestination(for: String.self) { _ in
+ DismissibleDestination(model: model)
+ }
+ }
+ }
+}
+
+private struct DismissibleDestination: View {
+ @ObservedObject var model: StackInputs
+
+ var body: some View {
+ StatefulDestination(value: "live")
+ .flowInteractiveDismissDisabled(model.disabled)
+ }
+}
+
+private final class DestinationInputs: ObservableObject {
+ @Published var value = "before"
+}
+
+private struct DestinationFixture: View {
+ @ObservedObject var model: DestinationInputs
+
+ var body: some View {
+ InteractiveDismissContainer(threshold: 80, onPan: { _ in }, isEnabled: true, isDismissing: false, swipeUpToDismiss: false, onDismiss: {}, onEnded: { _ in }, content: StatefulDestination(value: model.value))
+ }
+}
+
+private struct StatefulDestination: View {
+ let value: String
+ @State private var count = 0
+ @State private var note = ""
+
+ var body: some View {
+ VStack {
+ CounterButton(title: "\(value):\(count)", note: note) { count += 1 }
+ NoteField(text: $note)
+ }
+ }
+}
+
+/// Gives the test a public UIKit control to read and activate inside real SwiftUI
+/// content. SwiftUI owns the counter and all of its update/lifetime behavior.
+private struct CounterButton: UIViewRepresentable {
+ let title: String
+ let note: String
+ let action: () -> Void
+ private static var actionID: UIAction.Identifier { UIAction.Identifier("increment") }
+
+ func makeUIView(context: Context) -> UIButton {
+ let button = UIButton(type: .system)
+ button.accessibilityIdentifier = "destination-counter"
+ return button
+ }
+
+ func updateUIView(_ button: UIButton, context: Context) {
+ button.setTitle(title, for: .normal)
+ button.accessibilityValue = note
+ button.removeAction(identifiedBy: Self.actionID, for: .touchUpInside)
+ button.addAction(UIAction(identifier: Self.actionID) { _ in action() }, for: .touchUpInside)
+ }
+}
+
+/// Uses public UIControl events to enter text deterministically. The behavior under
+/// test is hosting updates preserving SwiftUI state and UIKit first-responder status,
+/// not SwiftUI TextField's private keyboard event plumbing.
+private struct NoteField: UIViewRepresentable {
+ @Binding var text: String
+ private static var actionID: UIAction.Identifier { UIAction.Identifier("edit-note") }
+
+ func makeUIView(context: Context) -> UITextField {
+ let field = UITextField()
+ field.placeholder = "Note"
+ return field
+ }
+
+ func updateUIView(_ field: UITextField, context: Context) {
+ if field.text != text { field.text = text }
+ field.removeAction(identifiedBy: Self.actionID, for: .editingChanged)
+ field.addAction(UIAction(identifier: Self.actionID) { [weak field] _ in
+ text = field?.text ?? ""
+ }, for: .editingChanged)
+ }
+}
diff --git a/Tests/FlowStackTests/HostedView.swift b/Tests/FlowStackTests/HostedView.swift
new file mode 100644
index 0000000..c1bfd30
--- /dev/null
+++ b/Tests/FlowStackTests/HostedView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+import XCTest
+
+/// Runs real SwiftUI lifecycle and representable updates in a window. Each test owns
+/// its window and closes it explicitly; no simulator app state is shared between tests.
+@MainActor
+final class HostedView {
+ let controller: UIHostingController
+ private let window: UIWindow
+
+ init(_ content: Content) {
+ controller = UIHostingController(rootView: content)
+ window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800))
+ window.rootViewController = controller
+ window.makeKeyAndVisible()
+ controller.view.layoutIfNeeded()
+ }
+
+ func close() {
+ controller.view.endEditing(true)
+ window.isHidden = true
+ window.rootViewController = nil
+ }
+}
+
+extension XCTestCase {
+ /// Yield to SwiftUI until the observable result arrives, not for a presumed
+ /// number of frames. The timeout only bounds a failing test.
+ @MainActor
+ func waitFor(_ description: String, file: StaticString = #filePath, line: UInt = #line, until condition: () -> Bool) async throws {
+ let deadline = ProcessInfo.processInfo.systemUptime + 5
+ while !condition() {
+ guard ProcessInfo.processInfo.systemUptime < deadline else {
+ XCTFail("Timed out waiting for \(description)", file: file, line: line)
+ return
+ }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ }
+}
diff --git a/Tests/FlowStackTests/InteractiveDismissTests.swift b/Tests/FlowStackTests/InteractiveDismissTests.swift
new file mode 100644
index 0000000..bac84b9
--- /dev/null
+++ b/Tests/FlowStackTests/InteractiveDismissTests.swift
@@ -0,0 +1,137 @@
+import SwiftUI
+import XCTest
+@testable import FlowStack
+
+@MainActor
+final class InteractiveDismissTests: XCTestCase {
+ func testDisabledGesturesNeitherBeginNorDeliverDismissal() throws {
+ let gesture = GestureDriver()
+ let installed = (gesture.controller.view.gestureRecognizers ?? []).filter { $0.delegate === gesture.coordinator }
+ let pan = try XCTUnwrap(installed.first { $0 is UIPanGestureRecognizer && !($0 is UIScreenEdgePanGestureRecognizer) })
+ let edge = try XCTUnwrap(installed.first { $0 is UIScreenEdgePanGestureRecognizer })
+ let recognizers = [pan, edge]
+ for recognizer in recognizers {
+ XCTAssertTrue(gesture.coordinator.gestureRecognizerShouldBegin(recognizer))
+ }
+
+ gesture.coordinator.isEnabled = false
+ for recognizer in recognizers {
+ XCTAssertFalse(gesture.coordinator.gestureRecognizerShouldBegin(recognizer))
+ }
+ // Also guard an already queued delivery, not just the should-begin decision.
+ try gesture.send(.ended, offset: CGPoint(x: 0, y: 100))
+ try gesture.send(.ended, offset: CGPoint(x: 100, y: 0), edge: true)
+ XCTAssertEqual(gesture.events.dismissals, 0)
+ XCTAssertTrue(gesture.events.pans.isEmpty)
+ XCTAssertTrue(gesture.events.endings.isEmpty)
+ XCTAssertTrue(gesture.controller.view.isUserInteractionEnabled)
+ }
+
+ func testDisablingDuringAPullCancelsItAndAllowsALaterPull() throws {
+ let gesture = GestureDriver()
+ try gesture.send(.began, offset: CGPoint(x: 0, y: 20))
+ XCTAssertEqual(gesture.events.pans, [CGPoint(x: 0, y: 20)])
+
+ gesture.coordinator.isEnabled = false
+ XCTAssertEqual(gesture.events.endings, [false], "The destination must return to rest as soon as dismissal is disabled")
+ try gesture.send(.ended, offset: CGPoint(x: 0, y: 100))
+ XCTAssertEqual(gesture.events.dismissals, 0)
+ XCTAssertEqual(gesture.events.endings, [false], "A queued release must not finish the cancelled pull again")
+
+ gesture.coordinator.isEnabled = true
+ try gesture.send(.began, offset: CGPoint(x: 0, y: 20))
+ try gesture.send(.ended, offset: CGPoint(x: 0, y: 100))
+ XCTAssertEqual(gesture.events.dismissals, 1)
+ XCTAssertEqual(gesture.events.endings, [false, true])
+ }
+
+ func testSystemCancellationPastThresholdDoesNotDismissAndNextPullWorks() throws {
+ for terminalState in [UIGestureRecognizer.State.cancelled, .failed] {
+ let gesture = GestureDriver()
+ try gesture.send(.began, offset: .zero)
+ try gesture.send(.changed, offset: CGPoint(x: 0, y: 100))
+ try gesture.send(terminalState, offset: CGPoint(x: 0, y: 100))
+ XCTAssertEqual(gesture.events.dismissals, 0, "\(terminalState)")
+ XCTAssertEqual(gesture.events.endings, [false], "\(terminalState)")
+
+ try gesture.send(.began, offset: .zero)
+ try gesture.send(.ended, offset: CGPoint(x: 0, y: 100))
+ XCTAssertEqual(gesture.events.dismissals, 1)
+ XCTAssertEqual(gesture.events.endings, [false, true])
+ }
+ }
+
+ func testDismissalDirectionsAndShortPulls() throws {
+ let cases: [(offset: CGPoint, edge: Bool, swipeUp: Bool, dismisses: Bool)] = [
+ (CGPoint(x: 0, y: 20), false, false, false),
+ (CGPoint(x: 0, y: 100), false, false, true),
+ (CGPoint(x: 100, y: 0), false, false, false),
+ (CGPoint(x: 100, y: 0), true, false, true),
+ (CGPoint(x: 0, y: -200), false, false, false),
+ (CGPoint(x: 0, y: -200), false, true, true)
+ ]
+ for item in cases {
+ let gesture = GestureDriver()
+ gesture.coordinator.swipeUpToDismiss = item.swipeUp
+ try gesture.send(.began, offset: .zero, edge: item.edge)
+ try gesture.send(.ended, offset: item.offset, edge: item.edge)
+ XCTAssertEqual(gesture.events.dismissals, item.dismisses ? 1 : 0, "\(item)")
+ XCTAssertEqual(gesture.events.endings, [item.dismisses], "\(item)")
+ }
+ }
+}
+
+/// Supplies finger input to the coordinator's existing UIKit target-action entry
+/// points. Only recognizer state/translation are simulated; dismissal decisions,
+/// cancellation, callbacks and installed recognizer delegates are production code.
+@MainActor
+private final class GestureDriver {
+ final class Events {
+ var pans: [CGPoint] = []
+ var endings: [Bool] = []
+ var dismissals = 0
+ }
+
+ let events: Events
+ let coordinator: InteractiveDismissCoordinator
+ let controller: InteractiveDismissViewController
+ private let input = DismissGestureInput()
+ private let inputView = UIView()
+
+ init() {
+ let events = Events()
+ self.events = events
+ coordinator = InteractiveDismissCoordinator(threshold: 80, isEnabled: true, onPan: { events.pans.append($0) }, isDismissing: false, swipeUpToDismiss: false, onDismiss: { events.dismissals += 1 }, onEnded: { events.endings.append($0) })
+ controller = InteractiveDismissViewController(rootView: Color.clear, coordinator: coordinator)
+ controller.loadViewIfNeeded()
+ controller.findScrollViews()
+ // The action uses the recognizer's view as its translation coordinate space.
+ inputView.addGestureRecognizer(input)
+ }
+
+ func send(_ state: UIGestureRecognizer.State, offset: CGPoint, edge: Bool = false) throws {
+ try input.send(state, offset: offset, edge: edge, to: coordinator)
+ }
+}
+
+/// Test input at the UIKit action boundary, shared with the hosted FlowStack test.
+final class DismissGestureInput: UIScreenEdgePanGestureRecognizer {
+ private var deliveredState: UIGestureRecognizer.State = .possible
+ private var offset: CGPoint = .zero
+
+ func send(_ state: UIGestureRecognizer.State, offset: CGPoint, edge: Bool = false, to coordinator: InteractiveDismissCoordinator) throws {
+ deliveredState = state
+ self.offset = offset
+ let selector = NSSelectorFromString(edge ? "edgeGestureUpdatedWithRecognizer:" : "panGestureUpdatedWithRecognizer:")
+ // A renamed entry point must fail explicitly, not make negative tests pass.
+ guard coordinator.responds(to: selector) else {
+ throw NSError(domain: "Missing gesture action \(selector)", code: 1)
+ }
+ coordinator.perform(selector, with: self)
+ }
+ override var state: UIGestureRecognizer.State {
+ get { deliveredState }
+ set { }
+ }
+ override func translation(in view: UIView?) -> CGPoint { offset }
+}
diff --git a/Tests/FlowStackTests/LazyLinkRevealTests.swift b/Tests/FlowStackTests/LazyLinkRevealTests.swift
new file mode 100644
index 0000000..392bdc5
--- /dev/null
+++ b/Tests/FlowStackTests/LazyLinkRevealTests.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+import XCTest
+@testable import FlowStack
+
+@MainActor
+final class LazyLinkRevealTests: XCTestCase {
+ func testRevealCreatesAndScrollsToAnOffscreenIdentifiableValue() async throws {
+ let store = FlowLinkContextStore()
+ let host = HostedView(LazyLinks(store: store))
+ defer { host.close() }
+ let target = Row(id: 120)
+ try await waitFor("initial rows") { store.context(for: AnyHashable(Row(id: 0)), atLevel: 0) != nil }
+ XCTAssertNil(store.context(for: AnyHashable(target), atLevel: 0), "Start with a row the lazy container has not created")
+
+ var completed = false
+ store.revealLink(for: AnyHashable(target), atLevel: 0) { completed = true }
+ // Do not assert that creation beats the production reveal timeout: that is a
+ // scheduling/animation detail. Assert the real scroll and completion outcomes.
+ try await waitFor("lazy target and reveal completion") {
+ completed && store.context(for: AnyHashable(target), atLevel: 0) != nil &&
+ self.targetIsVisible(in: host.controller.view)
+ }
+ }
+
+ private func targetIsVisible(in root: UIView) -> Bool {
+ guard let target = targetView(in: root), let window = target.window else { return false }
+ let rect = target.convert(target.bounds, to: window)
+ return rect.height > 0 && window.bounds.inset(by: window.safeAreaInsets).contains(rect)
+ }
+
+ private func targetView(in view: UIView) -> UIView? {
+ if view.accessibilityIdentifier == "lazy-target" { return view }
+ return view.subviews.compactMap { targetView(in: $0) }.first
+ }
+}
+
+private struct Row: Identifiable, Hashable {
+ let id: Int
+}
+
+private struct LazyLinks: View {
+ let store: FlowLinkContextStore
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack {
+ ForEach((0..<150).map(Row.init)) { row in
+ FlowLink(value: row, configuration: .init(transitionFromSnapshot: false)) {
+ RowLabel(row: row).frame(height: 80)
+ }
+ }
+ }
+ }
+ .onAppear { store.setScrollProxy(proxy, forLevel: 0) }
+ }
+ .environment(\.flowLinkContexts, store)
+ }
+}
+
+private struct RowLabel: UIViewRepresentable {
+ let row: Row
+
+ func makeUIView(context: Context) -> UILabel { UILabel() }
+
+ func updateUIView(_ label: UILabel, context: Context) {
+ label.text = "Row \(row.id)"
+ label.accessibilityIdentifier = row.id == 120 ? "lazy-target" : nil
+ }
+}