import SwiftUI import UIKit extension View { /// Presents a bottom-anchored drawer: it fills to the screen's bottom edge /// (no gap), shows a dimmed backdrop, and can be dragged down to dismiss. func bottomDrawer(isPresented: Binding, @ViewBuilder content: @escaping () -> Content) -> some View { BottomDrawerHost(host: self, isPresented: isPresented, content: content) } } /// One place for the drawers shown above every screen. Attaching them at the /// root keeps them anchored to the window instead of to scrolled content. @MainActor final class DrawerCoordinator: ObservableObject { @Published var transaction: TransactionRecord? @Published var qr: AddressQR? @Published var settings = false } private struct BottomDrawerHost: View { let host: Host @Binding var isPresented: Bool @ViewBuilder var content: () -> Content @State private var dragOffset: CGFloat = 0 private var bottomInset: CGFloat { for scene in UIApplication.shared.connectedScenes { guard let windowScene = scene as? UIWindowScene else { continue } if let window = windowScene.keyWindow { return window.safeAreaInsets.bottom } } return 0 } private var maxHeight: CGFloat { for scene in UIApplication.shared.connectedScenes { guard let windowScene = scene as? UIWindowScene else { continue } if let window = windowScene.keyWindow { return window.bounds.height * 0.92 } } return 800 } var body: some View { ZStack(alignment: .bottom) { host if isPresented { Color.black.opacity(0.5) .ignoresSafeArea() .onTapGesture { dismiss() } .transition(.opacity) VStack(spacing: 0) { Capsule() .fill(Theme.muted.opacity(0.55)) .frame(width: 36, height: 5) .padding(.top, 8) content() .padding(.top, 14) .padding(.bottom, 16) } .frame(maxWidth: .infinity, alignment: .top) .frame(maxHeight: maxHeight) .background(Theme.surface) .clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) // Push the panel's background down to the physical screen edge without // taking the host content out of its safe area. .offset(y: dragOffset + bottomInset) .gesture(dismissDrag) .transition(.move(edge: .bottom)) } } .animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented) } private func dismiss() { isPresented = false dragOffset = 0 } private var dismissDrag: some Gesture { DragGesture() .onChanged { value in dragOffset = max(0, value.translation.height) } .onEnded { value in if value.translation.height > 120 || value.predictedEndTranslation.height > 200 { dismiss() } else { dragOffset = 0 } } } }