Using a UISheetPresentationController in SwiftUI

Using a UISheetPresentationController in SwiftUI

This article explores how you can create a layered and customized sheet experience in SwiftUI.

During WWDC 2021 Apple has announced a new way to create a layered and customized sheet experience in UIKit. This is possible using a new UIPresentationController subclass called UISheetPresentationController.

Some system apps (Notes, Maps, Newsroom, Mail) make use of the UISheetPresentationController.

If you want to know more about it check out the following session from WWDC 2021 which talks about how we can build a non-modal experience in our app to allow interaction with content both in a sheet and behind the sheet at the same time.

Customize and resize sheets in UIKit - WWDC21 - Videos - Apple Developer
Discover how you can create a layered and customized sheet experience in UIKit. We’ll explore how you can build a non-modal experience in...

UIKit now supports medium/large detents, which allows you to create a vertically resizable sheet that only covers half the screen. This is why it is often referred to as half-modal or full-modal. if you want to know more about detens have a look at Apple’s documentation.

Apple Developer Documentation

SwiftUI currently provides only one way to achieve a modal experience. sheet(isPresented:onDismiss:content:) is a view modifier that presents a sheet with a large detent only when a given condition is true. What we would like instead is to use the UIKit exclusive medium detent in our SwiftUI apps.

Bringing the medium detent to SwiftUI

Fortunately, the UIKit/SwiftUI integration can be easily achieved. SwiftUI can be integrated into a UIKit app using UIContainerView and UIHostingController, while UIKit can be integrated into a SwiftUI app using UIViewRepresentable.

Check out our tutorials Using a SwiftUI View in a UIKit App and Using a SwiftUI View in a UIKit App as an individual component if you want to dig deeper.

Using all of this, we have created a view modifier that allows the use of sheets with detents, so you can use the new UIKit feature focusing on using only SwiftUI in your apps.

Thanks to our code snippets you can get something like the animated image below in a few minutes. To get access to this new half-modal experience you can just copy and paste the code snippets below and use your brand new modifier sheetWithDetents(isPresented:detents:onDismiss:content:)

An example of what can be achieved by using this article’s snippets.

Let's explore together the 3 code snippets we have created to make your life as a frontend developer easier.

1. SheetPresentationForSwiftUI is a struct conforming to the UIViewRepresentable protocol. This is needed to create and manage the UIKit UISheetPresentationController in SwiftUI.

// 1 - Create a UISheetPresentationController that can be used in a SwiftUI interface
struct SheetPresentationForSwiftUI<Content>: UIViewRepresentable where Content: View {
    
    @Binding var isPresented: Bool
    let onDismiss: (() -> Void)?
    let detents: [UISheetPresentationController.Detent]
    let content: Content
    
    
    init(
        _ isPresented: Binding<Bool>,
        onDismiss: (() -> Void)? = nil,
        detents: [UISheetPresentationController.Detent] = [.medium()],
        @ViewBuilder content: () -> Content
    ) {
        self._isPresented = isPresented
        self.onDismiss = onDismiss
        self.detents = detents
        self.content = content()
    }
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        
        // Create the UIViewController that will be presented by the UIButton
        let viewController = UIViewController()
        
        // Create the UIHostingController that will embed the SwiftUI View
        let hostingController = UIHostingController(rootView: content)
        
        // Add the UIHostingController to the UIViewController
        viewController.addChild(hostingController)
        viewController.view.addSubview(hostingController.view)
        
        // Set constraints
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        hostingController.view.leftAnchor.constraint(equalTo: viewController.view.leftAnchor).isActive = true
        hostingController.view.topAnchor.constraint(equalTo: viewController.view.topAnchor).isActive = true
        hostingController.view.rightAnchor.constraint(equalTo: viewController.view.rightAnchor).isActive = true
        hostingController.view.bottomAnchor.constraint(equalTo: viewController.view.bottomAnchor).isActive = true
        hostingController.didMove(toParent: viewController)
        
        // Set the presentationController as a UISheetPresentationController
        if let sheetController = viewController.presentationController as? UISheetPresentationController {
            sheetController.detents = detents
            sheetController.prefersGrabberVisible = true
            sheetController.prefersScrollingExpandsWhenScrolledToEdge = false
            sheetController.largestUndimmedDetentIdentifier = .medium
        }
        
        // Set the coordinator (delegate)
        // We need the delegate to use the presentationControllerDidDismiss function
        viewController.presentationController?.delegate = context.coordinator
        
        
        if isPresented {
            // Present the viewController
            uiView.window?.rootViewController?.present(viewController, animated: true)
        } else {
            // Dismiss the viewController
            uiView.window?.rootViewController?.dismiss(animated: true)
        }
        
    }
    
    /* Creates the custom instance that you use to communicate changes
    from your view controller to other parts of your SwiftUI interface.
     */
    func makeCoordinator() -> Coordinator {
        Coordinator(isPresented: $isPresented, onDismiss: onDismiss)
    }
    
    class Coordinator: NSObject, UISheetPresentationControllerDelegate {
        @Binding var isPresented: Bool
        let onDismiss: (() -> Void)?
        
        init(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil) {
            self._isPresented = isPresented
            self.onDismiss = onDismiss
        }
        
        func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
            isPresented = false
            if let onDismiss = onDismiss {
                onDismiss()
            }
        }
        
    }
    
}

2. sheetWithDetentsViewModifier is a structure conforming to the ViewModifier protocol. This is needed to create our custom ViewModifier that emulates the structures o d already available view modifiers.

// 2 - Create the SwiftUI modifier conforming to the ViewModifier protocol
struct sheetWithDetentsViewModifier<SwiftUIContent>: ViewModifier where SwiftUIContent: View {
    
    @Binding var isPresented: Bool
    let onDismiss: (() -> Void)?
    let detents: [UISheetPresentationController.Detent]
    let swiftUIContent: SwiftUIContent
    
    init(isPresented: Binding<Bool>, detents: [UISheetPresentationController.Detent] = [.medium()] , onDismiss: (() -> Void)? = nil, content: () -> SwiftUIContent) {
        self._isPresented = isPresented
        self.onDismiss = onDismiss
        self.swiftUIContent = content()
        self.detents = detents
    }
    
    func body(content: Content) -> some View {
        ZStack {
            SheetPresentationForSwiftUI($isPresented,onDismiss: onDismiss, detents: detents) {
                swiftUIContent
            }.fixedSize()
            content
        }
    }
}

3. Finally, when working with custom modifiers, it’s usually a smart idea to create extensions of View that make them easier to use. In the code snippet below, we wrapped the sheetWithDetentsViewModifier modifier in a Viewextension.

// 3 - Create extension on View that makes it easier to use the custom modifier
extension View {
    
    func sheetWithDetents<Content>(
        isPresented: Binding<Bool>,
        detents: [UISheetPresentationController.Detent],
        onDismiss: (() -> Void)?,
        content: @escaping () -> Content) -> some View where Content : View {
            modifier(
                sheetWithDetentsViewModifier(
                    isPresented: isPresented,
                    detents: detents,
                    onDismiss: onDismiss,
                    content: content)
            )
        }
}

Using the custom sheetWithDetents modifier

We can now use our custom sheetWithDetents(isPresented:detents:onDismiss:content:) modifier like this:

// 4 - An example of how to use the sheetWithDetents modifier
struct ContentView: View {
    @State var isPresented: Bool = false
    var body: some View {
        ZStack {
            Color.pink
                .ignoresSafeArea()
            Button {
                isPresented.toggle()
            } label: {
                Text("Tap me!")
                    .padding()
                    .background(Color.white)
                    .cornerRadius(20)
            }
        }
        .sheetWithDetents(
            isPresented: $isPresented,
            detents: [.medium(),.large()]
        ) {
            print("The sheet has been dismissed")
        } content: {
            Group {
                Text("Create")
                    .bold()
                +
                Text("with")
                +
                Text("Swift")
                    .bold()
            }
            .font(.title)
        }
    }
}

The detents property is an array of detents where the sheet may rest at. The array must have at least one element and detents must be specified in order from smallest to largest height.

In the animated image below we see what we've created, and the best part of all this is that the Xcode canvas continues to give us a preview of what we're programming in a true SwiftUI style as if there were no reference to UIKit.

The Xcode canvas previews our sheetWithDetents modifier example

Wrapping up

We explored how you can create a layered and customized sheet experience in SwiftUI, taking the best that UIKit has to offer. A few code snippets to unlock even more of SwiftUI's hidden potential.