Last active
January 28, 2025 16:05
-
-
Save Archetapp/d79fab80899af3b91aff483525122733 to your computer and use it in GitHub Desktop.
Fullscreen Sheet
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // JaredUI.Sheet.swift | |
| // JaredUI | |
| // | |
| // Created by Jared Davidson on 1/28/25. | |
| import SwiftUI | |
| import UIKit | |
| public enum JaredUI { } | |
| extension JaredUI { | |
| public struct SheetConfiguration: Sendable { | |
| public var backgroundColor: Color | |
| public var overlayColor: Color | |
| public var dragIndicatorColor: Color | |
| public var cornerRadius: CGFloat | |
| public var dismissThresholdFraction: CGFloat | |
| public var animation: Animation | |
| public var backgroundScale: CGFloat | |
| public static let `default` = SheetConfiguration( | |
| backgroundColor: .white, | |
| overlayColor: Color.black.opacity(0.4), | |
| dragIndicatorColor: Color.gray.opacity(0.5), | |
| cornerRadius: 44, | |
| dismissThresholdFraction: 0.25, | |
| animation: .spring(response: 0.5, dampingFraction: 1.0), | |
| backgroundScale: 0.93 | |
| ) | |
| } | |
| } | |
| extension JaredUI { | |
| @MainActor | |
| private class SheetWindowManager { | |
| static let shared = SheetWindowManager() | |
| private var sheetWindow: UIWindow? | |
| private var mainWindow: UIWindow? | |
| private var isPresenting = false | |
| private var systemCornerRadius: CGFloat { | |
| if let windowScene = UIApplication.shared.connectedScenes | |
| .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { | |
| return windowScene.windows.first?.safeAreaInsets.bottom == 0 ? 0 : 44 | |
| } | |
| return 20 | |
| } | |
| func present<Content: View>( | |
| isPresented: Binding<Bool>, | |
| configuration: SheetConfiguration, | |
| content: @escaping () -> Content | |
| ) { | |
| guard !isPresenting else { return } | |
| isPresenting = true | |
| guard let windowScene = UIApplication.shared.connectedScenes | |
| .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, | |
| let mainWindow = windowScene.windows.first else { return } | |
| self.mainWindow = mainWindow | |
| mainWindow.rootViewController?.view.transform = .identity | |
| mainWindow.rootViewController?.view.layer.cornerRadius = 0 | |
| mainWindow.rootViewController?.view.clipsToBounds = true | |
| let window = UIWindow(windowScene: windowScene) | |
| window.backgroundColor = .clear | |
| window.windowLevel = .alert + 1 | |
| let hostingController = UIHostingController( | |
| rootView: SheetRootView( | |
| isPresented: isPresented, | |
| configuration: configuration, | |
| content: content, | |
| onDismiss: { [weak self] in self?.dismissSheet() } | |
| ) | |
| ) | |
| hostingController.view.backgroundColor = .clear | |
| window.rootViewController = hostingController | |
| sheetWindow = window | |
| window.makeKeyAndVisible() | |
| } | |
| private func dismissSheet() { | |
| guard let mainWindow = mainWindow else { return } | |
| UIView.animate( | |
| withDuration: 0.35, | |
| delay: 0, | |
| usingSpringWithDamping: 1.0, | |
| initialSpringVelocity: 0, | |
| options: [.curveEaseOut], | |
| animations: { | |
| mainWindow.rootViewController?.view.transform = .identity | |
| mainWindow.rootViewController?.view.layer.cornerRadius = 0 | |
| mainWindow.rootViewController?.view.clipsToBounds = true | |
| } | |
| ) { _ in | |
| self.sheetWindow?.isHidden = true | |
| self.sheetWindow = nil | |
| self.mainWindow = nil | |
| self.isPresenting = false | |
| } | |
| } | |
| func updateMainWindowScale(sheetYOffset: CGFloat, screenHeight: CGFloat, backgroundScale: CGFloat) { | |
| guard let mainWindow = mainWindow else { return } | |
| let progress = min(1, max(0, sheetYOffset / screenHeight)) | |
| let scale = backgroundScale + ((1 - backgroundScale) * progress) | |
| let cornerRadius = systemCornerRadius | |
| UIView.animate( | |
| withDuration: 0.35, | |
| delay: 0, | |
| usingSpringWithDamping: 1.0, | |
| initialSpringVelocity: 0, | |
| options: [.curveEaseOut], | |
| animations: { | |
| mainWindow.rootViewController?.view.transform = CGAffineTransform(scaleX: scale, y: scale) | |
| mainWindow.rootViewController?.view.layer.cornerRadius = cornerRadius | |
| mainWindow.rootViewController?.view.clipsToBounds = true | |
| } | |
| ) | |
| } | |
| } | |
| private struct SheetRootView<Content: View>: View { | |
| @Binding var isPresented: Bool | |
| let configuration: SheetConfiguration | |
| let content: () -> Content | |
| let onDismiss: () -> Void | |
| @State private var dragOffset: CGFloat = 0 | |
| @State private var appearance: CGFloat = 0 | |
| @State private var shouldDismiss = false | |
| private func getTotalOffset(in geometry: GeometryProxy) -> CGFloat { | |
| let appearanceOffset = (1.0 - appearance) * geometry.size.height | |
| return max(0, dragOffset + appearanceOffset) | |
| } | |
| var body: some View { | |
| GeometryReader { geometry in | |
| ZStack { | |
| configuration.overlayColor | |
| .opacity(max(0, min(0.4, 0.4 * appearance - Double(dragOffset) / 1000.0))) | |
| .ignoresSafeArea() | |
| .onTapGesture { dismiss() } | |
| VStack(spacing: 0) { | |
| VStack { | |
| Color.clear | |
| .frame(height: 60) | |
| RoundedRectangle(cornerRadius: 3) | |
| .fill(configuration.dragIndicatorColor) | |
| .frame(width: 36, height: 5) | |
| .padding(.top, 8) | |
| content() | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| } | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| .background(configuration.backgroundColor) | |
| .cornerRadius(configuration.cornerRadius) | |
| .offset(y: getTotalOffset(in: geometry)) | |
| .ignoresSafeArea() | |
| } | |
| .animation(configuration.animation, value: appearance) | |
| .animation(configuration.animation, value: dragOffset) | |
| .onChange(of: dragOffset) { _, _ in | |
| SheetWindowManager.shared.updateMainWindowScale( | |
| sheetYOffset: getTotalOffset(in: geometry), | |
| screenHeight: geometry.size.height, | |
| backgroundScale: configuration.backgroundScale | |
| ) | |
| } | |
| .onChange(of: appearance) { _, _ in | |
| SheetWindowManager.shared.updateMainWindowScale( | |
| sheetYOffset: getTotalOffset(in: geometry), | |
| screenHeight: geometry.size.height, | |
| backgroundScale: configuration.backgroundScale | |
| ) | |
| } | |
| .gesture( | |
| DragGesture() | |
| .onChanged { value in | |
| dragOffset = max(0, value.translation.height) | |
| } | |
| .onEnded { value in | |
| let dismissThreshold = geometry.size.height * configuration.dismissThresholdFraction | |
| if value.translation.height > dismissThreshold || | |
| value.predictedEndLocation.y - value.location.y > dismissThreshold { | |
| dismiss() | |
| } else { | |
| withAnimation(configuration.animation) { | |
| dragOffset = 0 | |
| } | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| .onAppear { | |
| withAnimation(configuration.animation) { | |
| appearance = 1.0 | |
| } | |
| } | |
| .onChange(of: shouldDismiss) { _, newValue in | |
| if newValue { | |
| withAnimation(configuration.animation) { | |
| appearance = 0 | |
| dragOffset = UIScreen.main.bounds.height | |
| } completion: { | |
| isPresented = false | |
| onDismiss() | |
| shouldDismiss = false | |
| dragOffset = 0 | |
| } | |
| } | |
| } | |
| } | |
| private func dismiss() { | |
| shouldDismiss = true | |
| } | |
| } | |
| struct SheetModifier<SheetContent: View>: ViewModifier { | |
| @Binding var isPresented: Bool | |
| let configuration: SheetConfiguration | |
| let content: () -> SheetContent | |
| func body(content: Content) -> some View { | |
| content | |
| .onChange(of: isPresented) { _, newValue in | |
| if newValue { | |
| SheetWindowManager.shared.present( | |
| isPresented: $isPresented, | |
| configuration: configuration, | |
| content: self.content | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| extension View { | |
| public func fullscreenSheet<Content: View>( | |
| isPresented: Binding<Bool>, | |
| configuration: JaredUI.SheetConfiguration = .default, | |
| @ViewBuilder content: @escaping () -> Content | |
| ) -> some View { | |
| return modifier(JaredUI.SheetModifier(isPresented: isPresented, configuration: configuration, content: content)) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment