Using UISheetPresentationController in SwiftUI

Published on: June 30, 2021

With iOS 15, Apple introduced the ability to easily implement a bottom sheet with UISheetPresentationController in UIKit. Unfortunately, Apple didn't extend this functionality to SwiftUI just yet (I'm hoping one of the iOS 15 betas adds this...) but luckily we can make use of UIHostingController and UIViewRepresentable to work around this limitation and use a bottom sheet on SwiftUI.

In this post, I will show you a very simple implementation that might not have everything you need. After I tweeted about this hacky little workaround, someone suggested this very nice GitHub repository from Adam Foot that works roughly the same but with a much nicer interface. This post's goal is not to show you the best possible implementation of this idea, the repository I linked does a good job of that. Instead, I'd like to explain the underlying ideas and principles that make this work.

The underlying idea

When I realized it wasn't possible to present a bottom sheet in SwiftUI with the new UISheetPresentationController I started wondering if there was some way around this. I know that there are some issues with presenting a CloudKit sharing controller from SwiftUI as well, and a popular workaround is to have a UIButton in your view that presents the sharing controller.

While not strictly needed to make the bottom sheet work (as shown by the repository linked in the intro), I figured I would follow a similar pattern. That way I would be able to create a UIViewController and present it on top of the view that the button is presented in. The nice thing about that over how Adam Foot implemented his bottom sheet is that we can use the button's window to present the popover. Doing this will ensure that our view is always presented in the correct window if your app supports multiple windows. The cost is that, unfortunately, our API will not feel very at home in SwiftUI.

I figured that's ok for this writeup. If you want to see an implementation with a nicer API, look at what Adam Foot did in his implementation. The purpose of this post is mostly to explain how and why this works rather than providing you with the absolute best drop-in version of a bottom sheet for SwiftUI.

Implementing the BottomSheetPresenter

As I mentioned, a useful method to present a UICloudSharingController in SwiftUI is to present a UIButton that will in turn present the sharing controller. The reason this is needed is because, for some reason, presenting the sharing controller directly does not work. I don't fully understand why, but that's way beyond the scope of this post (and maybe a good topic for another post once I figure it out).

We'll follow this pattern for the proof of concept we're building in this post because it'll allow me to present the bottom sheet on the current window rather than any window. The components involved will be a BottomSheetPresenter which is a UIViewRepresentable that shows my button, and a BottomSheetWrapperController that puts a SwiftUI view in a view controller that I'll present.

Let's implement the presenter first. I'll use the following skeleton:

struct BottomSheetPresenter<Content>: UIViewRepresentable where Content: View{
    let label: String
    let content: Content
    let detents: [UISheetPresentationController.Detent]

    init(_ label: String, detents: [UISheetPresentationController.Detent], @ViewBuilder content: () -> Content) {
        self.label = label
        self.content = content()
        self.detents = detents
    }

    func makeUIView(context: UIViewRepresentableContext<BottomSheetPresenter>) -> UIButton {
        let button = UIButton(type: .system)

        // configure button

        return button
    }

    func updateUIView(_ uiView: UIButton, context: Context) {
        // no updates
    }

    func makeCoordinator() -> Void {
        return ()
    }
}

The bottom sheet presenter initializer takes three arguments, a label for the button, the detents (steps) that we want to use in our UISheetPresentationController, and the content that should be shown in the presented view controller.

Note that I had to make my BottomSheetPresenter generic over Content so it can take a @ViewBuilder that generates a View for the presented view controller. We can't use View as the return type for the @ViewBuilder because View has a Self requirement which means it can only be used as a generic constraint.

Tip:
To learn more about generics, associated types, and generic constraints take a look at this post. For an introduction to generics you might want to read this post first.

The BottomSheetPresenter is a UIViewRepresentable struct which means that it can be used to present a UIKit view in a SwiftUI context.

The makeUIView method is used to create and configure our UIButton. We don't need any extra information so the makeCoordinator method returns Void, and the updateUIView method can remain empty because we're not going to update our view (we don't need to).

Let's fill in the makeUIView method:

func makeUIView(context: UIViewRepresentableContext<BottomSheetPresenter>) -> UIButton {
    let button = UIButton(type: .system)
    button.setTitle(label, for: .normal)
    button.addAction(UIAction { _ in
        let hostingController = UIHostingController(rootView: content)
        let viewController = BottomSheetWrapperController(detents: detents)

        viewController.addChild(hostingController)
        viewController.view.addSubview(hostingController.view)
        hostingController.view.pinToEdgesOf(viewController.view)
        hostingController.didMove(toParent: viewController)

        button.window?.rootViewController?.present(viewController, animated: true)
    }, for: .touchUpInside)

    return button
}

The implementation for makeUIView is pretty straightforward. We assign the button's title and add an action for touch up inside.

When the user taps this button, we create an instance of UIHostingController to present a SwiftUI view in a UIKit context, and we pass it the content that was created by the initializer's @ViewBuilder closure. After that, we create an instance of BottomSheetWrapperController. This view controller will receive the UIHostingController as its child view controller, and it's the view controller we'll present. We need this extra view controller so we can override its viewDidLoad and configure the detents for its presentationController (remember how we presented a bottom sheet in UIKit?).

The following lines of code add the hosting controller as a child of the wrapper controller, and I set up the constraints using a convenient method that I added as an extension to UIView. The pinToEdgesOf(_:) function I added in my UIView extension configures my view for autolayout and it pins all edges to the view that's passed as the argument.

Once all setup is done, I present my wrapper controller on the button's window. This will make sure that this implementation works well in applications that support multiple windows.

Lastly, I return the button so that it can be presented in my SwiftUI view.

Before we look at the SwiftUI view, let's look at the implementation for BottomSheetWrapperController.

Implementing the BottomSheetWrapperController

The implementation for the BottomSheetWrapperController class is pretty straightforward. It has a custom initializer so we can accept the array of detents from the BottomSheetPresenter, and in viewDidLoad we check if we're being presented by a UISheetPresentationController. If we are, we assign the detents and set the grabber to be visible.

Note that you might want to make the grabber's visibility configurable by making it an argument for the initializer and storing the preference as a property on the wrapper.

class BottomSheetWrapperController: UIViewController {
    let detents: [UISheetPresentationController.Detent]

    init(detents: [UISheetPresentationController.Detent]) {
        self.detents = detents
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("No Storyboards")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        if let sheetController = self.presentationController as? UISheetPresentationController {
            sheetController.detents = detents
            sheetController.prefersGrabberVisible = true
        }
    }
}

I'm not going to go into the details of how this view controller works in this post. Please refer to the UIKit version of this post if you want to know more (it's very short).

Using the BottomSheetPresenter in SwiftUI

Now that we have everything set up, let's take a look at how the BottomSheetPresenter can be used in a SwiftUI view:

struct ContentView: View {
    var body: some View {
        BottomSheetPresenter("Tap me for a bottom sheet!!", detents: [.medium(), .large()]) {
            VStack {
                Text("This is a test")
                Text("Pretty cool, right")
            }
        }
    }
}

That doesn't look bad at all, right? We create an instance of BottomSheetPresenter, we assign it a label, pass the detents we want to use and we use regular SwiftUI syntax to build the contents of our bottom sheet.

I agree, it doesn't feel very at home and it would be nicer to configure the bottom sheet with a view modifier. This is exactly what Adam Foot implemented in his version of BottomSheet. The only downside to that version is that it grabs the first window it can find to present the sheet. This means that it wouldn't work well in an application with multiple windows. Other than that, I really like his custom SwiftUI modifier, and I would recommend you take a look at the implementation if you're curious.

You'll find that it's very similar to what you learned in this post, except it has a bunch more configuration that I didn't include during my exploration to see if I could get this bottom sheet to work.

Keep in mind, this post isn't intended to show you the ultimate way of achieving this. My goal is to help you see how I got to my version of using UISheetPresentationController in SwiftUI through experimentation, and applying what I know from presenting a UICloudSharingController in SwiftUI.

Categories

Swift SwiftUI