79 lines
2.9 KiB
Swift
79 lines
2.9 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Call Bar Safe Area Bridge
|
|
|
|
/// UIKit bridge that pushes NavigationStack's nav bar down by setting
|
|
/// `additionalSafeAreaInsets.top` on every UINavigationController in the window.
|
|
///
|
|
/// `UIViewController.navigationController` walks UP the parent chain — but the
|
|
/// bridge VC is a SIBLING of NavigationStack's UINavigationController, not a child.
|
|
/// So we walk DOWN from the window's root VC to find all navigation controllers.
|
|
struct CallBarSafeAreaBridge: UIViewRepresentable {
|
|
let topInset: CGFloat
|
|
|
|
func makeUIView(context: Context) -> CallBarInsetView {
|
|
let view = CallBarInsetView()
|
|
view.isHidden = true
|
|
view.isUserInteractionEnabled = false
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ view: CallBarInsetView, context: Context) {
|
|
view.topInset = topInset
|
|
view.applyInset()
|
|
}
|
|
}
|
|
|
|
/// Invisible UIView that finds all UINavigationControllers in the window
|
|
/// and sets `additionalSafeAreaInsets.top` on each.
|
|
final class CallBarInsetView: UIView {
|
|
var topInset: CGFloat = 0
|
|
private var lastAppliedInset: CGFloat = -1
|
|
|
|
override func didMoveToWindow() {
|
|
super.didMoveToWindow()
|
|
lastAppliedInset = -1 // Force re-apply
|
|
applyInset()
|
|
}
|
|
|
|
func applyInset() {
|
|
guard topInset != lastAppliedInset else { return }
|
|
guard let window, let rootVC = window.rootViewController else { return }
|
|
let inset = topInset
|
|
// Animate in sync with SwiftUI spring transition to avoid jerk.
|
|
// layoutIfNeeded() forces the layout pass INSIDE the animation block —
|
|
// without it, additionalSafeAreaInsets triggers layout on the next
|
|
// run loop pass, which is outside the animation scope → instant jump.
|
|
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.85,
|
|
initialSpringVelocity: 0, options: [.beginFromCurrentState]) {
|
|
Self.apply(inset: inset, in: rootVC)
|
|
rootVC.view.layoutIfNeeded()
|
|
}
|
|
lastAppliedInset = topInset
|
|
}
|
|
|
|
/// Recursively walk child view controllers (NOT presentedViewController)
|
|
/// and set additionalSafeAreaInsets.top on every UINavigationController.
|
|
private static func apply(inset: CGFloat, in vc: UIViewController) {
|
|
if let nav = vc as? UINavigationController,
|
|
nav.additionalSafeAreaInsets.top != inset {
|
|
nav.additionalSafeAreaInsets.top = inset
|
|
}
|
|
for child in vc.children {
|
|
apply(inset: inset, in: child)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Push NavigationStack's nav bar down by `inset` points.
|
|
/// Uses UIKit `additionalSafeAreaInsets` — the only reliable mechanism.
|
|
func callBarSafeAreaInset(_ inset: CGFloat) -> some View {
|
|
background(
|
|
CallBarSafeAreaBridge(topInset: inset)
|
|
.frame(width: 0, height: 0)
|
|
.allowsHitTesting(false)
|
|
)
|
|
}
|
|
}
|