Skip to content

Instantly share code, notes, and snippets.

@Archetapp
Last active January 28, 2025 16:05
Show Gist options
  • Select an option

  • Save Archetapp/d79fab80899af3b91aff483525122733 to your computer and use it in GitHub Desktop.

Select an option

Save Archetapp/d79fab80899af3b91aff483525122733 to your computer and use it in GitHub Desktop.
Fullscreen Sheet
// 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