From 77dbd797809d645e2ffe3a162e8ffee730b211c4 Mon Sep 17 00:00:00 2001 From: Brody Roberts Date: Thu, 29 Oct 2026 15:18:59 -0700 Subject: [PATCH 1/2] Toolbar fix --- .../FlowStackExampleApp.swift | 80 ++++++++++++++++--- Sources/FlowStack/CornerRadii.swift | 8 +- Sources/FlowStack/FlowTransition.swift | 56 ++++++++++--- .../FlowStack/View+InteractiveDismiss.swift | 51 ++++++++++-- 4 files changed, 166 insertions(+), 29 deletions(-) diff --git a/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift b/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift index 1605abc..ce0af5e 100644 --- a/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift +++ b/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift @@ -1,17 +1,79 @@ -// -// FlowStackExampleApp.swift -// FlowStackExample -// -// Created by Charles Hieger on 6/7/23. -// - import SwiftUI - +import FlowStack @main struct FlowStackExampleApp: App { + @StateObject var model = PlacementModel() var body: some Scene { WindowGroup { - ContentView() + PlacementFixture(model: model) + .task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) { + for window in scene.windows where window.isKeyWindow { + dumpPlacement(window, tag: "ROOT") + activateLink(window) + try? await Task.sleep(nanoseconds: 2_000_000_000) + dumpPlacement(window, tag: "DEST") + if let root = window.rootViewController { + print("AFTER AUTOMATIC PLACEMENT") + try? await Task.sleep(nanoseconds: 500_000_000) + dumpPlacement(window, tag: "REFRESHED") + } + } + } + } + } + } +} +func activateLink(_ view: UIView) { + if let control = view as? UIControl { + for target in control.allTargets { + let selector = NSSelectorFromString("onTouchUpInside") + if let object = target as? NSObject, object.responds(to: selector) { object.perform(selector); return } + } + } + view.subviews.forEach(activateLink) +} +func dumpPlacement(_ view: UIView, tag: String, indent: String = "") { + print("PLACEMENT \(tag) \(indent)\(type(of: view)) frame=\(view.frame) safe=\(view.safeAreaInsets)") + view.subviews.forEach { dumpPlacement($0, tag: tag, indent: indent + " ") } +} +final class PlacementModel: ObservableObject { + @Published var path = FlowPath() +} + +struct PlacementFixture: View { + @ObservedObject var model: PlacementModel + var body: some View { + FlowStack(path: $model.path) { + NavigationStack { + ScrollView { + FlowLink(value: "destination", configuration: .init(presentationStyle: .fullScreen)) { + Text("Open").frame(width: 200, height: 150).background(.blue) + } + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Root") {} } + ToolbarItem(placement: .topBarTrailing) { Button("Share") {} } + } + } + .flowDestination(for: String.self) { _ in + NavigationStack { + ScrollView { Text("Destination").frame(maxWidth: .infinity) } + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Close") {} } + ToolbarItem(placement: .topBarTrailing) { Button("Share") {} } + } + } + } } } } + +func refreshBars(_ controller: UIViewController) { + if let nav = controller as? UINavigationController { + nav.setNavigationBarHidden(true, animated: false) + nav.setNavigationBarHidden(false, animated: false) + } + controller.children.forEach(refreshBars) +} diff --git a/Sources/FlowStack/CornerRadii.swift b/Sources/FlowStack/CornerRadii.swift index b5b0d7c..b56c223 100644 --- a/Sources/FlowStack/CornerRadii.swift +++ b/Sources/FlowStack/CornerRadii.swift @@ -67,6 +67,7 @@ extension CornerRadii: Animatable { struct UnevenCornerShape: Shape { var radii: CornerRadii var style: RoundedCornerStyle + var renderingRect: CGRect? = nil var animatableData: CornerRadii.AnimatableData { get { radii.animatableData } @@ -74,8 +75,9 @@ struct UnevenCornerShape: Shape { } func path(in rect: CGRect) -> Path { + let pathRect = renderingRect ?? rect if radii.isUniform { - return RoundedRectangle(cornerRadius: radii.topLeft, style: style).path(in: rect) + return RoundedRectangle(cornerRadius: radii.topLeft, style: style).path(in: pathRect) } if #available(iOS 16.0, *) { @@ -87,9 +89,9 @@ struct UnevenCornerShape: Shape { bottomTrailing: radii.bottomRight, topTrailing: radii.topRight ) - return Path(roundedRect: rect, cornerRadii: cornerRadii, style: style) + return Path(roundedRect: pathRect, cornerRadii: cornerRadii, style: style) } - return RoundedRectangle(cornerRadius: radii.maximum, style: style).path(in: rect) + return RoundedRectangle(cornerRadius: radii.maximum, style: style).path(in: pathRect) } } diff --git a/Sources/FlowStack/FlowTransition.swift b/Sources/FlowStack/FlowTransition.swift index c3d46f3..eea44c1 100644 --- a/Sources/FlowStack/FlowTransition.swift +++ b/Sources/FlowStack/FlowTransition.swift @@ -301,13 +301,34 @@ extension AnyTransition { .allowsHitTesting(isPresented) } + private func usesStableLayout(availableSize: CGSize) -> Bool { + if #available(iOS 27.0, *) { + return isPresentedFullscreen(availableSize: availableSize) && resolvedContext.shouldScaleHorizontally + } + return false + } + @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 + let usesStableLayout = usesStableLayout(availableSize: proxy.size) + let slideOffset = resolvedContext.anchor == nil ? (1 - percent) * proxy.size.width : 0 + let renderingRect = CGRect( + x: zoomRect.origin.x - zoomRect.width / 2 + slideOffset, + y: zoomRect.origin.y - zoomRect.height / 2, + width: zoomRect.width, + height: zoomRect.height + ) + let renderTransform = CGAffineTransform( + a: scaleRatio, b: 0, c: 0, d: scaleRatio, + tx: zoomRect.origin.x - proxy.size.width / 2 + slideOffset, + ty: zoomRect.origin.y - proxy.size.height / 2 + ) + content - .onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: resolvedContext.swipeUpToDismiss, onDismiss: { + .onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: resolvedContext.swipeUpToDismiss, renderTransform: usesStableLayout ? renderTransform : nil, onDismiss: { guard !isDisabled else { return } dismiss() isDismissing = true @@ -336,23 +357,36 @@ extension AnyTransition { } .overlay(alignment: .top) { if let image = activeSnapshot, percent < 1 { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .opacity(snapshotPercent) + if usesStableLayout { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: renderingRect.width, height: renderingRect.height) + .position(x: renderingRect.midX, y: renderingRect.midY) + .opacity(snapshotPercent) + } else { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .opacity(snapshotPercent) + } } } - .clipShape(UnevenCornerShape(radii: cornerRadii(with: proxy).map { $0 / scaleRatio }, style: cornerStyle)) + .clipShape(UnevenCornerShape( + radii: cornerRadii(with: proxy).map { usesStableLayout ? $0 : $0 / scaleRatio }, + style: cornerStyle, + renderingRect: usesStableLayout ? renderingRect : nil + )) .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 + height: usesStableLayout ? proxy.size.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)) + .scaleEffect(x: usesStableLayout ? 1 : scaleRatio, y: usesStableLayout ? 1 : scaleRatio, anchor: .center) + .transformEffect(.init(translationX: usesStableLayout ? 0 : slideOffset, y: 0)) .position( - x: zoomRect.origin.x, - y: zoomRect.origin.y + x: usesStableLayout ? proxy.size.width / 2 : zoomRect.origin.x, + y: usesStableLayout ? proxy.size.height / 2 : zoomRect.origin.y ) .opacity(resolvedContext.anchor == nil ? percent : 1) } diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift index e53bb50..f074c41 100644 --- a/Sources/FlowStack/View+InteractiveDismiss.swift +++ b/Sources/FlowStack/View+InteractiveDismiss.swift @@ -56,12 +56,13 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { var onEnded: (Bool) -> Void let content: T + var renderTransform: CGAffineTransform? = nil - func makeUIViewController(context: Context) -> InteractiveDismissViewController { - return InteractiveDismissViewController(rootView: content, coordinator: context.coordinator) + func makeUIViewController(context: Context) -> InteractiveDismissRenderingController { + InteractiveDismissRenderingController(rootView: content, coordinator: context.coordinator) } - func updateUIViewController(_ uiViewController: InteractiveDismissViewController, context: Context) { + func updateUIViewController(_ uiViewController: InteractiveDismissRenderingController, context: Context) { context.coordinator.threshold = threshold context.coordinator.swipeUpToDismiss = swipeUpToDismiss context.coordinator.onPan = onPan @@ -69,7 +70,9 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { context.coordinator.onEnded = onEnded context.coordinator.isDismissing = isDismissing context.coordinator.isEnabled = isEnabled - uiViewController.rootView = content + uiViewController.host.rootView = content + uiViewController.renderTransform = renderTransform + uiViewController.view.setNeedsLayout() } func makeCoordinator() -> InteractiveDismissCoordinator { @@ -77,6 +80,42 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { } } +/// Keeps UIKit's layout at its resting size while the layer tree performs the zoom. +class InteractiveDismissRenderingController: UIViewController { + let host: InteractiveDismissViewController + var renderTransform: CGAffineTransform? + + init(rootView: Content, coordinator: InteractiveDismissCoordinator) { + host = InteractiveDismissViewController(rootView: rootView, coordinator: coordinator) + super.init(nibName: nil, bundle: nil) + } + + @MainActor required dynamic init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + addChild(host) + view.addSubview(host.view) + host.didMove(toParent: self) + } + + override func viewWillLayoutSubviews() { + view.layer.sublayerTransform = CATransform3DIdentity + super.viewWillLayoutSubviews() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + host.view.frame = view.bounds + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + view.layer.sublayerTransform = CATransform3DMakeAffineTransform(renderTransform ?? .identity) + } +} + class InteractiveDismissViewController: UIHostingController { private var coordinator: InteractiveDismissCoordinator @@ -663,8 +702,8 @@ class InteractiveDismissCoordinator: NSObject, UIGestureRecognizerDelegate { } extension View { - func onInteractiveDismissGesture(threshold: Double = 50, isEnabled: Bool = true, isDismissing: Bool = false, swipeUpToDismiss: Bool, onDismiss: @escaping () -> Void, onPan: @escaping (CGPoint) -> Void = { _ in }, onEnded: @escaping (Bool) -> Void = { _ in }) -> some View { - InteractiveDismissContainer(threshold: threshold, onPan: onPan, isEnabled: isEnabled, isDismissing: isDismissing, swipeUpToDismiss: swipeUpToDismiss, onDismiss: onDismiss, onEnded: onEnded, content: self) + func onInteractiveDismissGesture(threshold: Double = 50, isEnabled: Bool = true, isDismissing: Bool = false, swipeUpToDismiss: Bool, renderTransform: CGAffineTransform? = nil, onDismiss: @escaping () -> Void, onPan: @escaping (CGPoint) -> Void = { _ in }, onEnded: @escaping (Bool) -> Void = { _ in }) -> some View { + InteractiveDismissContainer(threshold: threshold, onPan: onPan, isEnabled: isEnabled, isDismissing: isDismissing, swipeUpToDismiss: swipeUpToDismiss, onDismiss: onDismiss, onEnded: onEnded, content: self, renderTransform: renderTransform) } } From 8606f33305bce8cdfe7217556268d4b0034832fd Mon Sep 17 00:00:00 2001 From: Brody Roberts Date: Thu, 29 Oct 2026 15:58:22 -0700 Subject: [PATCH 2/2] Toolbar fix for duo --- .../FlowStackExampleApp.swift | 80 ++--------- Sources/FlowStack/CornerRadii.swift | 22 +++- Sources/FlowStack/FlowTransition.swift | 18 ++- .../FlowStack/View+InteractiveDismiss.swift | 124 +++++++++++++++--- 4 files changed, 146 insertions(+), 98 deletions(-) diff --git a/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift b/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift index ce0af5e..1605abc 100644 --- a/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift +++ b/FlowStackExample/FlowStackExample/FlowStackExampleApp.swift @@ -1,79 +1,17 @@ +// +// FlowStackExampleApp.swift +// FlowStackExample +// +// Created by Charles Hieger on 6/7/23. +// + import SwiftUI -import FlowStack + @main struct FlowStackExampleApp: App { - @StateObject var model = PlacementModel() var body: some Scene { WindowGroup { - PlacementFixture(model: model) - .task { - try? await Task.sleep(nanoseconds: 2_000_000_000) - for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) { - for window in scene.windows where window.isKeyWindow { - dumpPlacement(window, tag: "ROOT") - activateLink(window) - try? await Task.sleep(nanoseconds: 2_000_000_000) - dumpPlacement(window, tag: "DEST") - if let root = window.rootViewController { - print("AFTER AUTOMATIC PLACEMENT") - try? await Task.sleep(nanoseconds: 500_000_000) - dumpPlacement(window, tag: "REFRESHED") - } - } - } - } - } - } -} -func activateLink(_ view: UIView) { - if let control = view as? UIControl { - for target in control.allTargets { - let selector = NSSelectorFromString("onTouchUpInside") - if let object = target as? NSObject, object.responds(to: selector) { object.perform(selector); return } - } - } - view.subviews.forEach(activateLink) -} -func dumpPlacement(_ view: UIView, tag: String, indent: String = "") { - print("PLACEMENT \(tag) \(indent)\(type(of: view)) frame=\(view.frame) safe=\(view.safeAreaInsets)") - view.subviews.forEach { dumpPlacement($0, tag: tag, indent: indent + " ") } -} -final class PlacementModel: ObservableObject { - @Published var path = FlowPath() -} - -struct PlacementFixture: View { - @ObservedObject var model: PlacementModel - var body: some View { - FlowStack(path: $model.path) { - NavigationStack { - ScrollView { - FlowLink(value: "destination", configuration: .init(presentationStyle: .fullScreen)) { - Text("Open").frame(width: 200, height: 150).background(.blue) - } - } - .toolbar { - ToolbarItem(placement: .topBarLeading) { Button("Root") {} } - ToolbarItem(placement: .topBarTrailing) { Button("Share") {} } - } - } - .flowDestination(for: String.self) { _ in - NavigationStack { - ScrollView { Text("Destination").frame(maxWidth: .infinity) } - .toolbar { - ToolbarItem(placement: .topBarLeading) { Button("Close") {} } - ToolbarItem(placement: .topBarTrailing) { Button("Share") {} } - } - } - } + ContentView() } } } - -func refreshBars(_ controller: UIViewController) { - if let nav = controller as? UINavigationController { - nav.setNavigationBarHidden(true, animated: false) - nav.setNavigationBarHidden(false, animated: false) - } - controller.children.forEach(refreshBars) -} diff --git a/Sources/FlowStack/CornerRadii.swift b/Sources/FlowStack/CornerRadii.swift index b56c223..4ef43e8 100644 --- a/Sources/FlowStack/CornerRadii.swift +++ b/Sources/FlowStack/CornerRadii.swift @@ -69,9 +69,25 @@ struct UnevenCornerShape: Shape { var style: RoundedCornerStyle var renderingRect: CGRect? = nil - var animatableData: CornerRadii.AnimatableData { - get { radii.animatableData } - set { radii.animatableData = newValue } + typealias RectData = AnimatablePair, AnimatablePair> + + var animatableData: AnimatablePair { + get { + let rect = renderingRect ?? .zero + return AnimatablePair(radii.animatableData, AnimatablePair( + AnimatablePair(rect.origin.x, rect.origin.y), + AnimatablePair(rect.width, rect.height) + )) + } + set { + radii.animatableData = newValue.first + if renderingRect != nil { + renderingRect = CGRect( + x: newValue.second.first.first, y: newValue.second.first.second, + width: newValue.second.second.first, height: newValue.second.second.second + ) + } + } } func path(in rect: CGRect) -> Path { diff --git a/Sources/FlowStack/FlowTransition.swift b/Sources/FlowStack/FlowTransition.swift index eea44c1..21ac495 100644 --- a/Sources/FlowStack/FlowTransition.swift +++ b/Sources/FlowStack/FlowTransition.swift @@ -301,6 +301,9 @@ extension AnyTransition { .allowsHitTesting(isPresented) } + // A scaled fullscreen destination needs its final UIKit layout before the first + // visible frame. Only its rendering changes size; its navigation bars and safe area + // keep the same layout throughout the zoom and an interactive pull. private func usesStableLayout(availableSize: CGSize) -> Bool { if #available(iOS 27.0, *) { return isPresentedFullscreen(availableSize: availableSize) && resolvedContext.shouldScaleHorizontally @@ -321,10 +324,15 @@ extension AnyTransition { width: zoomRect.width, height: zoomRect.height ) - let renderTransform = CGAffineTransform( - a: scaleRatio, b: 0, c: 0, d: scaleRatio, - tx: zoomRect.origin.x - proxy.size.width / 2 + slideOffset, - ty: zoomRect.origin.y - proxy.size.height / 2 + // Cover even a source that's taller in proportion than the destination. The + // mask supplies the zoom's viewport, while the content keeps its resting bounds. + let renderScale = max(zoomRect.width / proxy.size.width, zoomRect.height / proxy.size.height) + let renderTransform = FlowRenderTransform( + scale: renderScale, + translation: CGPoint( + x: zoomRect.origin.x - proxy.size.width / 2 + slideOffset, + y: renderingRect.minY + proxy.size.height * (renderScale - 1) / 2 + ) ) content @@ -361,7 +369,7 @@ extension AnyTransition { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: renderingRect.width, height: renderingRect.height) + .frame(width: renderingRect.width, height: renderingRect.height, alignment: .top) .position(x: renderingRect.midX, y: renderingRect.midY) .opacity(snapshotPercent) } else { diff --git a/Sources/FlowStack/View+InteractiveDismiss.swift b/Sources/FlowStack/View+InteractiveDismiss.swift index f074c41..87c533f 100644 --- a/Sources/FlowStack/View+InteractiveDismiss.swift +++ b/Sources/FlowStack/View+InteractiveDismiss.swift @@ -42,7 +42,27 @@ public extension View { } } -struct InteractiveDismissContainer: UIViewControllerRepresentable { +/// A visual zoom, kept separate from the bounds UIKit uses to lay out system bars. +struct FlowRenderTransform: Equatable, Animatable { + var scale: CGFloat + var translation: CGPoint + + static let identity = FlowRenderTransform(scale: 1, translation: .zero) + + var affineTransform: CGAffineTransform { + CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: translation.x, ty: translation.y) + } + + var animatableData: AnimatablePair> { + get { AnimatablePair(scale, AnimatablePair(translation.x, translation.y)) } + set { + scale = newValue.first + translation = CGPoint(x: newValue.second.first, y: newValue.second.second) + } + } +} + +struct InteractiveDismissContainer: UIViewControllerRepresentable, Animatable { var threshold: Double @@ -56,13 +76,19 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { var onEnded: (Bool) -> Void let content: T - var renderTransform: CGAffineTransform? = nil + var renderTransform: FlowRenderTransform? = nil + + var animatableData: FlowRenderTransform.AnimatableData { + get { (renderTransform ?? .identity).animatableData } + set { renderTransform?.animatableData = newValue } + } func makeUIViewController(context: Context) -> InteractiveDismissRenderingController { InteractiveDismissRenderingController(rootView: content, coordinator: context.coordinator) } func updateUIViewController(_ uiViewController: InteractiveDismissRenderingController, context: Context) { + uiViewController.renderTransform = renderTransform context.coordinator.threshold = threshold context.coordinator.swipeUpToDismiss = swipeUpToDismiss context.coordinator.onPan = onPan @@ -71,7 +97,6 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { context.coordinator.isDismissing = isDismissing context.coordinator.isEnabled = isEnabled uiViewController.host.rootView = content - uiViewController.renderTransform = renderTransform uiViewController.view.setNeedsLayout() } @@ -83,7 +108,9 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { /// Keeps UIKit's layout at its resting size while the layer tree performs the zoom. class InteractiveDismissRenderingController: UIViewController { let host: InteractiveDismissViewController - var renderTransform: CGAffineTransform? + var renderTransform: FlowRenderTransform? { + didSet { host.usesStableLayout = renderTransform != nil } + } init(rootView: Content, coordinator: InteractiveDismissCoordinator) { host = InteractiveDismissViewController(rootView: rootView, coordinator: coordinator) @@ -94,6 +121,11 @@ class InteractiveDismissRenderingController: UIViewController { fatalError("init(coder:) has not been implemented") } + override var childForStatusBarStyle: UIViewController? { host } + override var childForStatusBarHidden: UIViewController? { host } + override var childForHomeIndicatorAutoHidden: UIViewController? { host } + override var childForScreenEdgesDeferringSystemGestures: UIViewController? { host } + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear @@ -103,7 +135,7 @@ class InteractiveDismissRenderingController: UIViewController { } override func viewWillLayoutSubviews() { - view.layer.sublayerTransform = CATransform3DIdentity + applyRenderTransform(CATransform3DIdentity) super.viewWillLayoutSubviews() } @@ -112,7 +144,15 @@ class InteractiveDismissRenderingController: UIViewController { host.view.frame = view.bounds host.view.setNeedsLayout() host.view.layoutIfNeeded() - view.layer.sublayerTransform = CATransform3DMakeAffineTransform(renderTransform ?? .identity) + host.renderedLayoutDidChange(isAtRest: renderTransform == nil || renderTransform == .identity) + applyRenderTransform(CATransform3DMakeAffineTransform(renderTransform?.affineTransform ?? .identity)) + } + + private func applyRenderTransform(_ transform: CATransform3D) { + CATransaction.begin() + CATransaction.setDisableActions(true) + view.layer.sublayerTransform = transform + CATransaction.commit() } } @@ -120,6 +160,23 @@ class InteractiveDismissViewController: UIHostingController: UIHostingController: UIHostingController: UIHostingController: UIHostingController: UIHostingController [HeldFloatingItem] { guard #available(iOS 26.0, *) else { return [] } let content = navigationController.topViewController?.view return navigationController.view.subviews.flatMap { container -> [HeldFloatingItem] in - guard container !== navigationController.navigationBar, content?.isDescendant(of: container) != true else { return [] } + guard (usesStableLayout || container !== navigationController.navigationBar), + content?.isDescendant(of: container) != true else { return [] } - return controls(in: container).compactMap { control in + // SwiftUI toolbar buttons are hosted views, rather than UIControls. Keep each + // compact chrome group (including its glass) in place while the view is pulled. + let items = usesStableLayout ? compactChromeViews(in: container) : controls(in: container) + return items.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 } + guard item.transform.isIdentity, control.superview != nil else { return nil } - var held = HeldFloatingItem(item: item, control: control, center: superview.convert(control.center, to: view)) + let center = control.convert(CGPoint(x: control.bounds.midX, y: control.bounds.midY), to: view) + var held = HeldFloatingItem(item: item, control: control, center: center) var ancestor: UIView? = item while let current = ancestor, current !== container { held.observations.append(current.layer.observe(\.position) { [weak self] _, _ in self?.holdNavigationStacks() }) + held.observations.append(current.layer.observe(\.bounds) { [weak self] _, _ in + self?.holdNavigationStacks() + }) ancestor = current.superview } return held @@ -435,6 +510,17 @@ class InteractiveDismissViewController: UIHostingController [UIView] { + view.subviews.flatMap { child in + if child.bounds.width > 0, child.bounds.height > 0, + child.bounds.width <= Constants.maxFloatingItemSize, + child.bounds.height <= Constants.maxFloatingItemSize { + return [child] + } + return compactChromeViews(in: child) + } + } + private func controls(in view: UIView) -> [UIView] { view.subviews.flatMap { $0 is UIControl ? [$0] : controls(in: $0) } } @@ -702,7 +788,7 @@ class InteractiveDismissCoordinator: NSObject, UIGestureRecognizerDelegate { } extension View { - func onInteractiveDismissGesture(threshold: Double = 50, isEnabled: Bool = true, isDismissing: Bool = false, swipeUpToDismiss: Bool, renderTransform: CGAffineTransform? = nil, onDismiss: @escaping () -> Void, onPan: @escaping (CGPoint) -> Void = { _ in }, onEnded: @escaping (Bool) -> Void = { _ in }) -> some View { + func onInteractiveDismissGesture(threshold: Double = 50, isEnabled: Bool = true, isDismissing: Bool = false, swipeUpToDismiss: Bool, renderTransform: FlowRenderTransform? = nil, onDismiss: @escaping () -> Void, onPan: @escaping (CGPoint) -> Void = { _ in }, onEnded: @escaping (Bool) -> Void = { _ in }) -> some View { InteractiveDismissContainer(threshold: threshold, onPan: onPan, isEnabled: isEnabled, isDismissing: isDismissing, swipeUpToDismiss: swipeUpToDismiss, onDismiss: onDismiss, onEnded: onEnded, content: self, renderTransform: renderTransform) } }