Present sheets as a full bottom drawer

This commit is contained in:
2026-10-03 23:42:46 +08:00
parent 782143e87b
commit 31429e6923
7 changed files with 148 additions and 50 deletions
+7 -2
View File
@@ -37,8 +37,13 @@ struct AccountsView: View {
} }
} }
.task(id: model.moneroReady) { await model.loadAccounts() } .task(id: model.moneroReady) { await model.loadAccounts() }
.sheet(item: $qrAddress) { item in .bottomDrawer(isPresented: Binding(
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } get: { qrAddress != nil },
set: { if !$0 { qrAddress = nil } }
)) {
if let item = qrAddress {
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
}
} }
.alert("Account name", isPresented: $showAccountPrompt) { .alert("Account name", isPresented: $showAccountPrompt) {
TextField("e.g. Savings", text: $pendingLabel) TextField("e.g. Savings", text: $pendingLabel)
+22 -29
View File
@@ -6,8 +6,7 @@ struct AddressQR: Identifiable {
let address: String let address: String
} }
/// Drawer that shows one address as a scannable QR, presented from the bottom /// One address as a scannable QR, shown inside the shared bottom drawer.
/// like the transaction detail sheet.
struct AddressQRSheet: View { struct AddressQRSheet: View {
var title: String var title: String
var address: String var address: String
@@ -16,38 +15,32 @@ struct AddressQRSheet: View {
@State private var copied = false @State private var copied = false
var body: some View { var body: some View {
ZStack { VStack(spacing: 16) {
Theme.background.ignoresSafeArea() Text(title)
VStack(spacing: 16) { .font(.system(size: 16, weight: .semibold))
Text(title) .foregroundStyle(Theme.text)
.font(.system(size: 16, weight: .semibold)) .frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
QRCodeView(text: address) QRCodeView(text: address)
.frame(maxWidth: 220) .frame(maxWidth: 220)
Text(address) Text(address)
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundStyle(Theme.muted) .foregroundStyle(Theme.muted)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(4) .lineLimit(4)
.truncationMode(.middle) .truncationMode(.middle)
.textSelection(.enabled) .textSelection(.enabled)
SecondaryButton(title: copied ? "Copied" : "Copy address") { SecondaryButton(title: copied ? "Copied" : "Copy address") {
UIPasteboard.general.string = address UIPasteboard.general.string = address
copied = true copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
}
PrimaryButton(title: "Done", action: onDone)
} }
.padding(22)
.frame(maxHeight: .infinity, alignment: .top) PrimaryButton(title: "Done", action: onDone)
} }
.padding(.horizontal, 22)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} }
} }
+91
View File
@@ -0,0 +1,91 @@
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)
}
}
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, bottomInset + 16)
}
.frame(maxWidth: .infinity, alignment: .top)
.frame(maxHeight: maxHeight)
.background(Theme.surface)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous))
.offset(y: dragOffset)
.gesture(dismissDrag)
.transition(.move(edge: .bottom))
}
}
.ignoresSafeArea()
.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
}
}
}
}
+9 -4
View File
@@ -108,10 +108,15 @@ struct DashboardView: View {
MessageBanner(message: seedError, isError: true) MessageBanner(message: seedError, isError: true)
} }
} }
.sheet(item: $selectedTransaction) { record in .bottomDrawer(isPresented: Binding(
TransactionDetailView(record: record, get: { selectedTransaction != nil },
symbol: model.symbol, set: { if !$0 { selectedTransaction = nil } }
chain: model.chain) { selectedTransaction = nil } )) {
if let record = selectedTransaction {
TransactionDetailView(record: record,
symbol: model.symbol,
chain: model.chain) { selectedTransaction = nil }
}
} }
.sheet(isPresented: $showSeed) { .sheet(isPresented: $showSeed) {
if let revealedSeed { if let revealedSeed {
+17 -7
View File
@@ -112,15 +112,25 @@ struct HomeView: View {
MessageBanner(message: message, isError: false) MessageBanner(message: message, isError: false)
} }
} }
.sheet(item: $selectedTransaction) { record in .bottomDrawer(isPresented: Binding(
TransactionDetailView(record: record, get: { selectedTransaction != nil },
symbol: model.symbol, set: { if !$0 { selectedTransaction = nil } }
chain: model.chain) { selectedTransaction = nil } )) {
if let record = selectedTransaction {
TransactionDetailView(record: record,
symbol: model.symbol,
chain: model.chain) { selectedTransaction = nil }
}
} }
.sheet(item: $qrAddress) { item in .bottomDrawer(isPresented: Binding(
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } get: { qrAddress != nil },
set: { if !$0 { qrAddress = nil } }
)) {
if let item = qrAddress {
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
}
} }
.sheet(isPresented: $showSettings) { .bottomDrawer(isPresented: $showSettings) {
SettingsView() SettingsView()
.environmentObject(model) .environmentObject(model)
} }
+1 -4
View File
@@ -7,8 +7,7 @@ struct SettingsView: View {
private let currencies = ["USD", "MYR", "JPY"] private let currencies = ["USD", "MYR", "JPY"]
var body: some View { var body: some View {
ZStack { ScrollView {
Theme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() }) ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() })
@@ -70,7 +69,5 @@ struct SettingsView: View {
.padding(.top, 12) .padding(.top, 12)
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} }
} }
@@ -41,9 +41,7 @@ struct TransactionDetailView: View {
} }
var body: some View { var body: some View {
ZStack { ScrollView {
Theme.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -114,7 +112,6 @@ struct TransactionDetailView: View {
PrimaryButton(title: "Done", action: onDone) PrimaryButton(title: "Done", action: onDone)
} }
.padding(20) .padding(20)
}
} }
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.task { .task {