From 31429e6923a1ebb343c49a5ffde85dbed9f25b35 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sat, 3 Oct 2026 23:42:46 +0800 Subject: [PATCH] Present sheets as a full bottom drawer --- apps/ios/Koins/Views/AccountsView.swift | 9 +- apps/ios/Koins/Views/AddressQRSheet.swift | 51 +++++------ apps/ios/Koins/Views/BottomDrawer.swift | 91 +++++++++++++++++++ apps/ios/Koins/Views/DashboardViews.swift | 13 ++- apps/ios/Koins/Views/RootView.swift | 24 +++-- apps/ios/Koins/Views/SettingsView.swift | 5 +- .../Koins/Views/TransactionDetailView.swift | 5 +- 7 files changed, 148 insertions(+), 50 deletions(-) create mode 100644 apps/ios/Koins/Views/BottomDrawer.swift diff --git a/apps/ios/Koins/Views/AccountsView.swift b/apps/ios/Koins/Views/AccountsView.swift index 499d716..685dd4b 100644 --- a/apps/ios/Koins/Views/AccountsView.swift +++ b/apps/ios/Koins/Views/AccountsView.swift @@ -37,8 +37,13 @@ struct AccountsView: View { } } .task(id: model.moneroReady) { await model.loadAccounts() } - .sheet(item: $qrAddress) { item in - AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } + .bottomDrawer(isPresented: Binding( + 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) { TextField("e.g. Savings", text: $pendingLabel) diff --git a/apps/ios/Koins/Views/AddressQRSheet.swift b/apps/ios/Koins/Views/AddressQRSheet.swift index 6e6c1bd..450e60d 100644 --- a/apps/ios/Koins/Views/AddressQRSheet.swift +++ b/apps/ios/Koins/Views/AddressQRSheet.swift @@ -6,8 +6,7 @@ struct AddressQR: Identifiable { let address: String } -/// Drawer that shows one address as a scannable QR, presented from the bottom -/// like the transaction detail sheet. +/// One address as a scannable QR, shown inside the shared bottom drawer. struct AddressQRSheet: View { var title: String var address: String @@ -16,38 +15,32 @@ struct AddressQRSheet: View { @State private var copied = false var body: some View { - ZStack { - Theme.background.ignoresSafeArea() - VStack(spacing: 16) { - Text(title) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Theme.text) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 16) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Theme.text) + .frame(maxWidth: .infinity, alignment: .leading) - QRCodeView(text: address) - .frame(maxWidth: 220) + QRCodeView(text: address) + .frame(maxWidth: 220) - Text(address) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(Theme.muted) - .multilineTextAlignment(.center) - .lineLimit(4) - .truncationMode(.middle) - .textSelection(.enabled) + Text(address) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Theme.muted) + .multilineTextAlignment(.center) + .lineLimit(4) + .truncationMode(.middle) + .textSelection(.enabled) - SecondaryButton(title: copied ? "Copied" : "Copy address") { - UIPasteboard.general.string = address - copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } - } - - PrimaryButton(title: "Done", action: onDone) + SecondaryButton(title: copied ? "Copied" : "Copy address") { + UIPasteboard.general.string = address + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } } - .padding(22) - .frame(maxHeight: .infinity, alignment: .top) + + PrimaryButton(title: "Done", action: onDone) } + .padding(.horizontal, 22) .preferredColorScheme(.dark) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) } } \ No newline at end of file diff --git a/apps/ios/Koins/Views/BottomDrawer.swift b/apps/ios/Koins/Views/BottomDrawer.swift new file mode 100644 index 0000000..8fc355f --- /dev/null +++ b/apps/ios/Koins/Views/BottomDrawer.swift @@ -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(isPresented: Binding, + @ViewBuilder content: @escaping () -> Content) -> some View { + BottomDrawerHost(host: self, isPresented: isPresented, content: content) + } +} + +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, 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 + } + } + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/DashboardViews.swift b/apps/ios/Koins/Views/DashboardViews.swift index 3839baf..d215171 100644 --- a/apps/ios/Koins/Views/DashboardViews.swift +++ b/apps/ios/Koins/Views/DashboardViews.swift @@ -108,10 +108,15 @@ struct DashboardView: View { MessageBanner(message: seedError, isError: true) } } - .sheet(item: $selectedTransaction) { record in - TransactionDetailView(record: record, - symbol: model.symbol, - chain: model.chain) { selectedTransaction = nil } + .bottomDrawer(isPresented: Binding( + get: { selectedTransaction != nil }, + set: { if !$0 { selectedTransaction = nil } } + )) { + if let record = selectedTransaction { + TransactionDetailView(record: record, + symbol: model.symbol, + chain: model.chain) { selectedTransaction = nil } + } } .sheet(isPresented: $showSeed) { if let revealedSeed { diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 979cfd7..c4d586b 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -112,15 +112,25 @@ struct HomeView: View { MessageBanner(message: message, isError: false) } } - .sheet(item: $selectedTransaction) { record in - TransactionDetailView(record: record, - symbol: model.symbol, - chain: model.chain) { selectedTransaction = nil } + .bottomDrawer(isPresented: Binding( + get: { selectedTransaction != nil }, + set: { if !$0 { selectedTransaction = nil } } + )) { + if let record = selectedTransaction { + TransactionDetailView(record: record, + symbol: model.symbol, + chain: model.chain) { selectedTransaction = nil } + } } - .sheet(item: $qrAddress) { item in - AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } + .bottomDrawer(isPresented: Binding( + 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() .environmentObject(model) } diff --git a/apps/ios/Koins/Views/SettingsView.swift b/apps/ios/Koins/Views/SettingsView.swift index e35e1e2..b6dbd2e 100644 --- a/apps/ios/Koins/Views/SettingsView.swift +++ b/apps/ios/Koins/Views/SettingsView.swift @@ -7,8 +7,7 @@ struct SettingsView: View { private let currencies = ["USD", "MYR", "JPY"] var body: some View { - ZStack { - Theme.background.ignoresSafeArea() + ScrollView { VStack(alignment: .leading, spacing: 18) { ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() }) @@ -70,7 +69,5 @@ struct SettingsView: View { .padding(.top, 12) } .preferredColorScheme(.dark) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) } } \ No newline at end of file diff --git a/apps/ios/Koins/Views/TransactionDetailView.swift b/apps/ios/Koins/Views/TransactionDetailView.swift index dcdcf1d..72b7a2d 100644 --- a/apps/ios/Koins/Views/TransactionDetailView.swift +++ b/apps/ios/Koins/Views/TransactionDetailView.swift @@ -41,9 +41,7 @@ struct TransactionDetailView: View { } var body: some View { - ZStack { - Theme.background.ignoresSafeArea() - ScrollView { + ScrollView { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -114,7 +112,6 @@ struct TransactionDetailView: View { PrimaryButton(title: "Done", action: onDone) } .padding(20) - } } .preferredColorScheme(.dark) .task {