102 lines
3.0 KiB
Swift
102 lines
3.0 KiB
Swift
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<Content: View>(isPresented: Binding<Bool>,
|
|
@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<Host: View, Content: View>: 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
|
|
}
|
|
}
|
|
}
|
|
}
|