diff --git a/Sources/FlowStack/CornerRadii.swift b/Sources/FlowStack/CornerRadii.swift index b5b0d7c..4ef43e8 100644 --- a/Sources/FlowStack/CornerRadii.swift +++ b/Sources/FlowStack/CornerRadii.swift @@ -67,15 +67,33 @@ extension CornerRadii: Animatable { struct UnevenCornerShape: Shape { var radii: CornerRadii 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 { + 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 +105,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..21ac495 100644 --- a/Sources/FlowStack/FlowTransition.swift +++ b/Sources/FlowStack/FlowTransition.swift @@ -301,13 +301,42 @@ 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 + } + 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 + ) + // 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 - .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 +365,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, alignment: .top) + .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..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,12 +76,19 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { var onEnded: (Bool) -> Void let content: T + var renderTransform: FlowRenderTransform? = nil + + var animatableData: FlowRenderTransform.AnimatableData { + get { (renderTransform ?? .identity).animatableData } + set { renderTransform?.animatableData = newValue } + } - 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) { + uiViewController.renderTransform = renderTransform context.coordinator.threshold = threshold context.coordinator.swipeUpToDismiss = swipeUpToDismiss context.coordinator.onPan = onPan @@ -69,7 +96,8 @@ struct InteractiveDismissContainer: UIViewControllerRepresentable { context.coordinator.onEnded = onEnded context.coordinator.isDismissing = isDismissing context.coordinator.isEnabled = isEnabled - uiViewController.rootView = content + uiViewController.host.rootView = content + uiViewController.view.setNeedsLayout() } func makeCoordinator() -> InteractiveDismissCoordinator { @@ -77,10 +105,78 @@ 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: FlowRenderTransform? { + didSet { host.usesStableLayout = renderTransform != nil } + } + + 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 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 + addChild(host) + view.addSubview(host.view) + host.didMove(toParent: self) + } + + override func viewWillLayoutSubviews() { + applyRenderTransform(CATransform3DIdentity) + super.viewWillLayoutSubviews() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + host.view.frame = view.bounds + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + 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() + } +} + class InteractiveDismissViewController: UIHostingController { private var coordinator: InteractiveDismissCoordinator private var frameObservation: NSKeyValueObservation? + var usesStableLayout = false { + didSet { + if usesStableLayout != oldValue { endSafeAreaCompensation() } + } + } + private var renderedLayoutIsAtRest = true + + /// A cancelled pull keeps its toolbar positions until the visual spring reaches rest. + func renderedLayoutDidChange(isAtRest: Bool) { + renderedLayoutIsAtRest = isAtRest + guard usesStableLayout else { return } + if coordinator.isUpdating || (heldNavigationStacks != nil && !isAtRest) { + holdNavigationStacks() + } else { + releaseNavigationStacks() + } + } init(rootView: Content, coordinator: InteractiveDismissCoordinator) { self.coordinator = coordinator @@ -124,6 +220,12 @@ class InteractiveDismissViewController: 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 @@ -396,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) } } @@ -663,8 +788,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: 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) } }