Skip to content

Instantly share code, notes, and snippets.

@joshbirnholz
Created April 4, 2024 18:56
Show Gist options
  • Save joshbirnholz/7dbf0a4b4cf5ad227e2399abc4a0aa67 to your computer and use it in GitHub Desktop.
Save joshbirnholz/7dbf0a4b4cf5ad227e2399abc4a0aa67 to your computer and use it in GitHub Desktop.
SwiftUI Bottom Sheet
import SwiftUI
// MARK: BottomSheet
/// The configuration to display a BottomSheet.
///
/// The sheet can be presented from SwiftUI using one of the following view modifiers:
/// - `bottomSheet(isPresented:sheet:)`
/// - `bottomSheet(item:sheet:)`
///
/// The sheet can also be presented from a `UIViewController` with the following method:
/// - `presentBottomSheet(_:animated:completion:)`
public struct BottomSheet<Content: View> {
let heading: String?
let subheading: String?
let footer: String?
let onDisappear: (() -> Void)?
let content: () -> Content
/// Initializes a new `BottomSheet`.
/// - Parameters:
/// - heading: Displayed with Neutral 700, heading font.
/// - subheading: Displayed with Neutral 700, body font.
/// - footer: Displayed with Neutral 500, body2 font.
/// - onDismiss: A closure to display when the sheet disappears.
/// - content: A closure returning the main content of the sheet.
public init(heading: String? = nil, subheading: String? = nil, footer: String? = nil, onDisappear: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) {
self.heading = heading
self.subheading = subheading
self.footer = footer
self.onDisappear = onDisappear
self.content = content
}
/// Creates and returns a view controller configured to display the `BottomSheet`.
public func makeViewController(isFullScreen: Bool = false) -> UIViewController {
var contentHeight: CGFloat = 0
var vcSheetPresentationController: Any?
let sheet = BottomSheetView<Content>(content: self)
.onHeightChange { newValue in
contentHeight = newValue
if #available(iOS 16.0, *) {
(vcSheetPresentationController as? UISheetPresentationController)?.invalidateDetents()
}
}
let vc = UIHostingController(rootView: sheet)
vc.modalPresentationStyle = .pageSheet
if #available(iOS 15.0, *) {
vcSheetPresentationController = vc.sheetPresentationController
/// The properties of a sheet presentation controller are not respected if you modify
/// them on a hosting controller. So, we need to use this view controller as a wrapper,
/// and add the hosting controller as a child to show the sheet content.
if #available(iOS 16.0, *) {
if isFullScreen {
vc.sheetPresentationController?.detents = [.large()]
} else {
vc.sheetPresentationController?.detents = [.custom { context in
return contentHeight
}]
}
} else {
vc.sheetPresentationController?.detents = [.large()]
}
vc.sheetPresentationController?.preferredCornerRadius = BottomSheetConstants.cornerRadius
}
return vc
}
}
private struct BottomSheetConstants {
static let cornerRadius: CGFloat = 24
}
private struct BottomSheetView<Content: View>: View {
@Environment(\.presentationMode) var presentationMode
let sheet: BottomSheet<Content>
private var customDismiss: (() -> Void)?
private var onHeightChange: ((CGFloat) -> Void)?
@State private var scrollViewHeight: CGFloat = 0 {
didSet {
notifyUpdateHeight()
}
}
@State private var headerHeight: CGFloat = 0 {
didSet {
notifyUpdateHeight()
}
}
private func notifyUpdateHeight() {
onHeightChange?(scrollViewHeight + headerHeight + 40)
}
init(content: BottomSheet<Content>) {
self.sheet = content
}
/// This should be used in this file only to configure the close behavior for different presentation methods.
func customDismiss(_ newValue: @escaping () -> Void) -> BottomSheetView<Content> {
var sheet = self
sheet.customDismiss = newValue
return sheet
}
func onHeightChange(perform closure: @escaping (CGFloat) -> Void) -> BottomSheetView<Content> {
var sheet = self
sheet.onHeightChange = closure
return sheet
}
struct Grabber: View {
var body: some View {
VStack(spacing: 32) {
RoundedRectangle(cornerRadius: 100)
.foregroundColor(ColorManager.Neutral.fiveHundred.asColor.opacity(0.4))
.frame(width: 32, height: 4, alignment: .center)
}
}
}
struct CloseButton: View {
let action: () -> Void
var body: some View {
VStack {
HStack {
Spacer()
SwiftUI.Button(action: action) {
Circle()
.frame(width: 36, height: 36)
.foregroundColor(ColorManager.Neutral.seventyFive!.asColor)
.overlay(
Image(uiImage: Asset.Navigation.iconClose.image)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(ColorManager.Neutral.fiveHundred.asColor)
.padding(9)
)
}
.offset(x: -20, y: 21)
}
Spacer()
}
}
}
var body: some View {
ZStack {
CloseButton {
if let customDismiss {
customDismiss()
} else {
presentationMode.wrappedValue.dismiss()
}
}
VStack(spacing: 20) {
VStack(spacing: 16) {
Grabber()
.padding(.bottom, 16)
if let heading = sheet.heading {
Text(heading)
.font(FontManager.shared.montserrat(.heading))
.foregroundColor(ColorManager.Neutral.sevenHundred.asColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 16)
}
}
.overlay(
GeometryReader { proxy in
Color.clear.onAppear {
headerHeight = proxy.size.height
}
}
)
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let subheading = sheet.subheading {
Text(subheading)
.font(FontManager.shared.montserrat(.body))
.foregroundColor(ColorManager.Neutral.sevenHundred.asColor)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 20)
}
sheet.content()
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, 16)
if let footer = sheet.footer {
Text(footer)
.font(FontManager.shared.montserrat(.body2))
.foregroundColor(ColorManager.Neutral.fiveHundred.asColor)
.frame(maxWidth: .infinity, alignment: .leading)
}
if (UIApplication.shared.windows[0].safeAreaInsets.bottom == 0) {
Spacer().frame(height: 16)
}
}
.frame(maxWidth: .infinity)
.padding(.top, sheet.heading == nil ? 8 : 0)
.padding(.horizontal, 16)
.overlay(
GeometryReader { proxy in
Color.clear.onAppear {
scrollViewHeight = proxy.size.height
}
}
)
}
.scrollBounceBasedOnSizeIfAvailable()
}
.padding(.top, 16)
}
.onDisappear {
customDismiss?()
sheet.onDisappear?()
}
}
}
// MARK: UIKit extensions
public extension UIViewController {
/// Presents and returns a view controller hosting a `BottomSheet`.
/// - Parameters:
/// - sheet: The `BottomSheet` to display over the current view controller’s content.
/// - animated: Pass true to animate the presentation; otherwise, pass false.
/// - isFullScreen: Whether or not the bottom sheet should take up the max available height,
/// rather than size to fit the sheet's content. The default value is `false`.
/// - completion:The block to execute after the presentation finishes.
func presentBottomSheet<Content: View>( _ sheet: BottomSheet<Content>, animated: Bool = true, isFullScreen: Bool = false, completion: (() -> Void)? = nil) {
let vc = sheet.makeViewController(isFullScreen: isFullScreen)
present(vc, animated: animated, completion: completion)
}
}
// MARK: SwiftUI extensions
fileprivate struct BottomSheetPresenter<Content: View, Item: Identifiable>: UIViewRepresentable {
let sheetView: (Item) -> BottomSheetView<Content>
let isFullScreen: Bool
@Binding var item: Item?
class Coordinator {
var presentedViewController: UIViewController?
}
private class BottomSheetWrapperController: UIViewController {
var contentHeight: CGFloat = .zero {
didSet {
if #available(iOS 16.0, *) {
sheetPresentationController?.animateChanges {
sheetPresentationController?.invalidateDetents()
}
}
}
}
var isFullScreen = false
init() {
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 15.0, *) {
/// The properties of a sheet presentation controller are not respected if you modify
/// them on a hosting controller. So, we need to use this view controller as a wrapper,
/// and add the hosting controller as a child to show the sheet content.
if #available(iOS 16.0, *) {
if isFullScreen {
sheetPresentationController?.detents = [.large()]
} else {
sheetPresentationController?.detents = [.custom { [weak self] context in
return self?.contentHeight ?? .zero
}]
}
} else {
sheetPresentationController?.detents = [.large()]
}
sheetPresentationController?.preferredCornerRadius = BottomSheetConstants.cornerRadius
}
}
}
init(isFullScreen: Bool, item: Binding<Item?>, @ViewBuilder sheetView: @escaping (Item) -> BottomSheetView<Content>) {
self.isFullScreen = isFullScreen
self._item = item
self.sheetView = sheetView
}
func makeUIView(context: UIViewRepresentableContext<BottomSheetPresenter>) -> UIView {
return UIView()
}
private func present(fromParentOf view: UIView, with item: Item, coordinator: Coordinator) {
guard coordinator.presentedViewController == nil else { return }
let wrapperViewController = BottomSheetWrapperController()
wrapperViewController.isFullScreen = isFullScreen
let sheet = sheetView(item).onHeightChange { height in
wrapperViewController.contentHeight = height
}
let hostingController = UIHostingController(rootView: sheet)
wrapperViewController.addChild(hostingController)
wrapperViewController.view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: wrapperViewController.view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: wrapperViewController.view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: wrapperViewController.view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: wrapperViewController.view.bottomAnchor)
])
hostingController.didMove(toParent: wrapperViewController)
presenter(for: view)?.present(wrapperViewController, animated: true)
coordinator.presentedViewController = wrapperViewController
}
private func presenter(for view: UIView) -> UIViewController? {
var parentResponder: UIResponder? = view.next
while parentResponder != nil {
if let viewController = parentResponder as? UIViewController {
return viewController
}
parentResponder = parentResponder?.next
}
return view.window?.rootViewController
}
func updateUIView(_ uiView: UIView, context: Context) {
if let item = item {
present(fromParentOf: uiView, with: item, coordinator: context.coordinator)
} else {
context.coordinator.presentedViewController?.dismiss(animated: true)
context.coordinator.presentedViewController = nil
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
private struct BottomSheetModifier<SheetContent: View, Item: Identifiable>: ViewModifier {
private let item: Binding<Item?>
private let sheet: (Item) -> BottomSheet<SheetContent>
private let isFullScreen: Bool
@State var height: CGFloat = .zero
init(isFullScreen: Bool, item: Binding<Item?>, sheet: @escaping (Item) -> BottomSheet<SheetContent>) {
self.isFullScreen = isFullScreen
self.item = item
self.sheet = sheet
}
func body(content: Content) -> some View {
VStack(spacing: 0) {
content
// This view will not be visible, and just needs to be present in the hierarchy
// so that we can have a UIView to present the sheet from with a custom controller.
BottomSheetPresenter(isFullScreen: isFullScreen, item: item) { item in
BottomSheetView(content: sheet(item))
.customDismiss {
self.item.wrappedValue = nil
}
}
.frame(width: 0, height: 0)
}
}
}
extension Bool: Identifiable {
public var id: String {
return String(self)
}
}
public extension View {
/// Presents a `BottomSheet` when a binding to a Boolean value that you provide is true.
/// - Parameters:
/// - isFullScreen: Whether or not the bottom sheet should take up the max available height,
/// rather than size to fit the sheet's content. The default value is `false`.
/// - isPresented: A binding to a Boolean value that determines whether to present the sheet.
/// - sheet: A `BottomSheet` to present.
func bottomSheet<Content: View>(isFullScreen: Bool = false, isPresented: Binding<Bool>, sheet: BottomSheet<Content>) -> some View {
let binding = Binding<Bool?>.init {
isPresented.wrappedValue ? true : nil
} set: { newValue in
isPresented.wrappedValue = newValue == true
}
return modifier(BottomSheetModifier(isFullScreen: isFullScreen, item: binding, sheet: { _ in
sheet
}))
}
/// Presents a `BottomSheet` using the given item as a data source for the sheet’s content.
/// - Parameters:
/// - isFullScreen: Whether or not the bottom sheet should take up the max available height,
/// rather than size to fit the sheet's content. The default value is `false`.
/// - item: A binding to an optional item that should be used to present a `BottomSheet` when the item is non-nil.
/// - sheet: A closure returning the `BottomSheet` to present with the given item.
func bottomSheet<Item: Identifiable, Content: View>(isFullScreen: Bool = false, item: Binding<Item?>, sheet: @escaping (Item) -> BottomSheet<Content>) -> some View {
modifier(BottomSheetModifier(isFullScreen: isFullScreen, item: item, sheet: sheet))
}
}
private extension ScrollView {
@ViewBuilder
func scrollBounceBasedOnSizeIfAvailable() -> some View {
if #available(iOS 16.4, *) {
self.scrollBounceBehavior(.basedOnSize)
}
else {
self
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment