Created
April 4, 2024 18:56
-
-
Save joshbirnholz/7dbf0a4b4cf5ad227e2399abc4a0aa67 to your computer and use it in GitHub Desktop.
SwiftUI Bottom 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
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