mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
210 lines
7.4 KiB
Swift
210 lines
7.4 KiB
Swift
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import SwiftUI
|
|
import Lucide
|
|
import Combine
|
|
|
|
private struct SizePreferenceKey: PreferenceKey {
|
|
static var defaultValue: CGSize = .zero
|
|
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
|
let next = nextValue()
|
|
if next != .zero { value = next }
|
|
}
|
|
}
|
|
|
|
public struct BottomSheet<Content>: View where Content: View {
|
|
@EnvironmentObject var host: HostWrapper
|
|
@State private var disposables: Set<AnyCancellable> = Set()
|
|
@StateObject private var toolbarManager: ToolbarManager
|
|
|
|
let hasCloseButton: Bool
|
|
let afterClosed: (() -> Void)?
|
|
let contentPrefferedHeight: CGFloat
|
|
let content: () -> Content
|
|
|
|
let cornerRadius: CGFloat = 11
|
|
let shadowRadius: CGFloat = 10
|
|
let shadowOpacity: Double = 0.4
|
|
|
|
@State private var show: Bool = false
|
|
@State private var topPadding: CGFloat = 80
|
|
@State private var contentSize: CGSize = .zero
|
|
@State private var dragOffset: CGFloat = 0
|
|
|
|
public init(
|
|
hasCloseButton: Bool,
|
|
afterClosed: (() -> Void)? = nil,
|
|
contentPrefferedHeight: CGFloat? = nil,
|
|
content: @escaping () -> Content)
|
|
{
|
|
self.hasCloseButton = hasCloseButton
|
|
_toolbarManager = StateObject(wrappedValue: ToolbarManager(hasCloseButton: hasCloseButton))
|
|
self.afterClosed = afterClosed
|
|
self.content = content
|
|
self.contentPrefferedHeight = contentPrefferedHeight ?? .infinity
|
|
}
|
|
|
|
public var body: some View {
|
|
ZStack(alignment: .bottom) {
|
|
// Background
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.opacity(backgroundOpacity)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.ignoresSafeArea()
|
|
.onTapGesture {
|
|
close()
|
|
}
|
|
|
|
if show {
|
|
VStack(spacing: Values.verySmallSpacing) {
|
|
Capsule()
|
|
.fill(themeColor: .value(.textPrimary, alpha: 0.8))
|
|
.frame(width: 35, height: 3)
|
|
|
|
ZStack(alignment: .topTrailing) {
|
|
NavigationView {
|
|
ZStack {
|
|
Rectangle()
|
|
.fill(themeColor: .backgroundPrimary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.ignoresSafeArea()
|
|
|
|
content()
|
|
.navigationTitle("")
|
|
.persistentCloseToolbar()
|
|
.environmentObject(toolbarManager)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.navigationViewStyle(.stack)
|
|
.frame(maxHeight: contentPrefferedHeight)
|
|
}
|
|
.cornerRadius(cornerRadius, corners: [.topLeft, .topRight])
|
|
.frame(maxWidth: .infinity, alignment: .topTrailing)
|
|
}
|
|
.background(
|
|
GeometryReader { proxy in
|
|
Color.clear
|
|
.preference(key: SizePreferenceKey.self, value: proxy.size)
|
|
}
|
|
)
|
|
.offset(y: max(0, dragOffset))
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.onPreferenceChange(SizePreferenceKey.self) { size in
|
|
contentSize = size
|
|
recomputeTopPadding()
|
|
}
|
|
.onAppear { recomputeTopPadding() }
|
|
.padding(.top, topPadding)
|
|
}
|
|
}
|
|
.ignoresSafeArea(edges: .bottom)
|
|
.onAppear {
|
|
toolbarManager.setCloseAction {
|
|
close()
|
|
}
|
|
withAnimation {
|
|
show.toggle()
|
|
}
|
|
}
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
maxHeight: .infinity,
|
|
alignment: .bottom
|
|
)
|
|
.gesture(
|
|
DragGesture(minimumDistance: 10, coordinateSpace: .global)
|
|
.onChanged { value in
|
|
// Only allow downward movement; clamp upward drags to 0
|
|
let translation = value.translation.height
|
|
withAnimation {
|
|
dragOffset = max(0, translation)
|
|
}
|
|
}
|
|
.onEnded { value in
|
|
let translation = max(0, value.translation.height)
|
|
let velocity = value.velocity.height
|
|
let threshold: CGFloat = max(120, contentSize.height * 0.25)
|
|
if translation > threshold || velocity > 1200 {
|
|
close()
|
|
} else {
|
|
withAnimation {
|
|
dragOffset = 0
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var backgroundOpacity: Double {
|
|
let fade = min(1.0, Double(dragOffset / 300))
|
|
return max(0.2, 1.0 - fade * 0.8)
|
|
}
|
|
|
|
// MARK: - Dismiss Logic
|
|
|
|
private func close() {
|
|
withAnimation {
|
|
show.toggle()
|
|
}
|
|
host.controller?.presentingViewController?.dismiss(animated: true)
|
|
afterClosed?()
|
|
}
|
|
|
|
// MARK: - Layout helpers
|
|
|
|
private func recomputeTopPadding() {
|
|
let screenHeight = UIScreen.main.bounds.height
|
|
let bottomSafeInset = host.controller?.view.safeAreaInsets.bottom ?? 0
|
|
|
|
let handleHeight: CGFloat = 3
|
|
let handleSpacing: CGFloat = Values.verySmallSpacing
|
|
let headerHeight: CGFloat = handleHeight + handleSpacing
|
|
let totalSheetHeight = headerHeight + contentSize.height + Values.veryLargeSpacing
|
|
|
|
let computed = screenHeight - bottomSafeInset - totalSheetHeight
|
|
topPadding = max(bottomSafeInset, computed)
|
|
}
|
|
}
|
|
|
|
// MARK: - BottomSheetIdentifiable
|
|
|
|
protocol BottomSheetIdentifiable {}
|
|
|
|
// MARK: - BottomSheetHostingViewController
|
|
|
|
open class BottomSheetHostingViewController<Content>: UIHostingController<ModifiedContent<Content, _EnvironmentKeyWritingModifier<HostWrapper?>>>, BottomSheetIdentifiable where Content: View {
|
|
public init(bottomSheet: Content) {
|
|
let container = HostWrapper()
|
|
let modified = bottomSheet.environmentObject(container) as! ModifiedContent<Content, _EnvironmentKeyWritingModifier<HostWrapper?>>
|
|
super.init(rootView: modified)
|
|
container.controller = self
|
|
self.modalTransitionStyle = .crossDissolve
|
|
self.modalPresentationStyle = .overFullScreen
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
navigationItem.backButtonTitle = ""
|
|
view.themeBackgroundColor = .clear
|
|
ThemeManager.applyNavigationStylingIfNeeded(to: self)
|
|
|
|
setNeedsStatusBarAppearanceUpdate()
|
|
}
|
|
}
|
|
|
|
private extension DragGesture.Value {
|
|
var velocity: CGSize {
|
|
let dt: CGFloat = 0.016
|
|
let dx = (predictedEndLocation.x - location.x) / dt
|
|
let dy = (predictedEndLocation.y - location.y) / dt
|
|
return CGSize(width: dx, height: dy)
|
|
}
|
|
}
|