SwiftUI sheet() modals with custom size on iPad

Viewed 7576

How can I control the preferred presentation size of a modal sheet on iPad with SwiftUI? I'm surprised how hard it is to find an answer on Google for this.

Also, what is the best way to know if the modal is dismissed by dragging it down (cancelled) or actually performing a custom positive action?

5 Answers

Here is my solution for showing a form sheet on an iPad in SwiftUI:

struct MyView: View {
    @State var show = false

    var body: some View {
        Button("Open Sheet") { self.show = true }
            .formSheet(isPresented: $show) {
                Text("Form Sheet Content")
            }
    }
}

Enabled by this UIViewControllerRepresentable

class FormSheetWrapper<Content: View>: UIViewController, UIPopoverPresentationControllerDelegate {

    var content: () -> Content
    var onDismiss: (() -> Void)?

    private var hostVC: UIHostingController<Content>?

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

    init(content: @escaping () -> Content) {
        self.content = content
        super.init(nibName: nil, bundle: nil)
    }

    func show() {
        guard hostVC == nil else { return }
        let vc = UIHostingController(rootView: content())

        vc.view.sizeToFit()
        vc.preferredContentSize = vc.view.bounds.size

        vc.modalPresentationStyle = .formSheet
        vc.presentationController?.delegate = self
        hostVC = vc
        self.present(vc, animated: true, completion: nil)
    }

    func hide() {
        guard let vc = self.hostVC, !vc.isBeingDismissed else { return }
        dismiss(animated: true, completion: nil)
        hostVC = nil
    }

    func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
        hostVC = nil
        self.onDismiss?()
    }
}

struct FormSheet<Content: View> : UIViewControllerRepresentable {

    @Binding var show: Bool

    let content: () -> Content

    func makeUIViewController(context: UIViewControllerRepresentableContext<FormSheet<Content>>) -> FormSheetWrapper<Content> {

        let vc = FormSheetWrapper(content: content)
        vc.onDismiss = { self.show = false }
        return vc
    }

    func updateUIViewController(_ uiViewController: FormSheetWrapper<Content>,
                                context: UIViewControllerRepresentableContext<FormSheet<Content>>) {
        if show {
            uiViewController.show()
        }
        else {
            uiViewController.hide()
        }
    }
}

extension View {
    public func formSheet<Content: View>(isPresented: Binding<Bool>,
                                          @ViewBuilder content: @escaping () -> Content) -> some View {
        self.background(FormSheet(show: isPresented,
                                  content: content))
    }
}

You should be able to modify the code in func show() according to UIKit specs in order to get the sizing the way you like (and you can even go so far as to inject parameters from the SwiftUI side if needed). This is just how I get a form sheet to work on iPad as .sheet was just too big for my use case

I just posted the same in this SO How can I make a background color with opacity on a Sheet view? but it seems to do exactly what I need it to do. It makes the background of the sheet transparent while allowing the content to be sized as needed to appear as if it's the only part of the sheet. Works great on the iPad.

Using the AWESOME answer from @Asperi that I have been trying to find all day, I have built a simple view modifier that can now be applied inside a .sheet or .fullScreenCover modal view and provides a transparent background. You can then set the frame modifier for the content as needed to fit the screen without the user having to know the modal is not custom sized.

import SwiftUI

struct ClearBackgroundView: UIViewRepresentable {
    func makeUIView(context: Context) -> some UIView {
        let view = UIView()
        DispatchQueue.main.async {
            view.superview?.superview?.backgroundColor = .clear
        }
        return view
    }
    func updateUIView(_ uiView: UIViewType, context: Context) {
    }
}

struct ClearBackgroundViewModifier: ViewModifier {
    
    func body(content: Content) -> some View {
        content
            .background(ClearBackgroundView())
    }
}

extension View {
    func clearModalBackground()->some View {
        self.modifier(ClearBackgroundViewModifier())
    }
}

Usage:

.sheet(isPresented: $isPresented) {
            ContentToDisplay()
            .frame(width: 300, height: 400)
            .clearModalBackground()
    }

In case it helps anyone else, I was able to get this working by leaning on this code to hold the view controller: https://gist.github.com/timothycosta/a43dfe25f1d8a37c71341a1ebaf82213

struct ViewControllerHolder {
    weak var value: UIViewController?
    init(_ value: UIViewController?) {
        self.value = value
    }
}

struct ViewControllerKey: EnvironmentKey {
    static var defaultValue: ViewControllerHolder? { ViewControllerHolder(UIApplication.shared.windows.first?.rootViewController) }
}

extension EnvironmentValues {
    var viewController: ViewControllerHolder? {
        get { self[ViewControllerKey.self] }
        set { self[ViewControllerKey.self] = newValue }
    }
}

extension UIViewController {
    func present<Content: View>(
        presentationStyle: UIModalPresentationStyle = .automatic,
        transitionStyle _: UIModalTransitionStyle = .coverVertical,
        animated: Bool = true,
        completion: @escaping () -> Void = { /* nothing by default*/ },
        @ViewBuilder builder: () -> Content
    ) {
        let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
        toPresent.modalPresentationStyle = presentationStyle
        toPresent.rootView = AnyView(
            builder()
                .environment(\.viewController, ViewControllerHolder(toPresent))
        )
        if presentationStyle == .overCurrentContext {
            toPresent.view.backgroundColor = .clear
        }
        present(toPresent, animated: animated, completion: completion)
    }
}

Coupled with a specialized view to handle common elements in the modal:

struct ModalContentView<Content>: View where Content: View {
    // Use this function to provide the content to display and to bring up the modal.
    // Currently only the 'formSheet' style has been tested but it should work with any
    // modal presentation style from UIKit.
    public static func present(_ content: Content, style: UIModalPresentationStyle = .formSheet) {
        let modal = ModalContentView(content: content)

        // Present ourselves
        modal.viewController?.present(presentationStyle: style) {
            modal.body
        }
    }

    // Grab the view controller out of the environment.
    @Environment(\.viewController) private var viewControllerHolder: ViewControllerHolder?
    private var viewController: UIViewController? {
        viewControllerHolder?.value
    }

    // The content to be displayed in the view.
    private var content: Content

    public var body: some View {
        VStack {
            /// Some specialized controls, like X button to close omitted...

            self.content
        }
    }

Finally, simply call: ModalContentView.present( MyAwesomeView() )

to display MyAwesomeView inside of a .formSheet modal.

There are some issues in @ccwasden's answer. Dismissing popover won't change $isPresented all the time, as delegate is not set and hostVC is never assigned.

Here are some modifications required. In FlexSheetWrapper:

func show() {
    guard hostVC == nil else { return }
    let vc = UIHostingController(rootView: content())

    vc.view.sizeToFit()
    vc.preferredContentSize = vc.view.bounds.size

    vc.modalPresentationStyle = .formSheet
    vc.presentationController?.delegate = self
    hostVC = vc
    self.present(vc, animated: true, completion: nil)
}

And in FormSheet:

func updateUIViewController(_ uiViewController: FlexSheetWrapper<Content>,
                            context: UIViewControllerRepresentableContext<FlexSheet<Content>>) {
    if show {
        uiViewController.show()
    }
    else {
        uiViewController.hide()
    }
}

From @ccwasden answer, I fixed the problem when you $isPresented = true at the beginning of code, the modal will not present when the view is loaded, To do so here is code View+FormSheet.swift

Result

// You can now set `test = true` at first
.formSheet(isPresented: $test) {
    Text("Hi")
}

enter image description here

View+FormSheet.swift

import SwiftUI

class ModalUIHostingController<Content>: UIHostingController<Content>, UIPopoverPresentationControllerDelegate where Content : View {
    
    var onDismiss: (() -> Void)
    
    required init?(coder: NSCoder) { fatalError("") }
    
    init(onDismiss: @escaping () -> Void, rootView: Content) {
        self.onDismiss = onDismiss
        super.init(rootView: rootView)
        view.sizeToFit()
        preferredContentSize = view.bounds.size
        modalPresentationStyle = .formSheet
        presentationController?.delegate = self
    }
    
    func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
        print("modal dismiss")
        onDismiss()
    }
}

class ModalUIViewController<Content: View>: UIViewController {
    var isPresented: Bool
    var content: () -> Content
    var onDismiss: (() -> Void)
    private var hostVC: ModalUIHostingController<Content>
    
    private var isViewDidAppear = false
    
    required init?(coder: NSCoder) { fatalError("") }
    
    init(isPresented: Bool = false, onDismiss: @escaping () -> Void, content: @escaping () -> Content) {
        self.isPresented = isPresented
        self.onDismiss = onDismiss
        self.content = content
        self.hostVC = ModalUIHostingController(onDismiss: onDismiss, rootView: content())
        super.init(nibName: nil, bundle: nil)
    }
    
    func show() {
        guard isViewDidAppear else { return }
        self.hostVC = ModalUIHostingController(onDismiss: onDismiss, rootView: content())
        present(hostVC, animated: true)
    }
    
    func hide() {
        guard !hostVC.isBeingDismissed else { return }
        dismiss(animated: true)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(true)
        isViewDidAppear = true
        if isPresented {
            show()
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        isViewDidAppear = false
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        show()
    }
}

struct FormSheet<Content: View> : UIViewControllerRepresentable {
    
    @Binding var show: Bool
    
    let content: () -> Content
    
    func makeUIViewController(context: UIViewControllerRepresentableContext<FormSheet<Content>>) -> ModalUIViewController<Content> {
    
        let onDismiss = {
            self.show = false
        }
        
        let vc = ModalUIViewController(isPresented: show, onDismiss: onDismiss, content: content)
        return vc
    }
    
    func updateUIViewController(_ uiViewController: ModalUIViewController<Content>,
                                context: UIViewControllerRepresentableContext<FormSheet<Content>>) {
        if show {
            uiViewController.show()
        }
        else {
            uiViewController.hide()
        }
    }
}

extension View {
    public func formSheet<Content: View>(isPresented: Binding<Bool>,
                                         @ViewBuilder content: @escaping () -> Content) -> some View {
        self.background(FormSheet(show: isPresented,
                                  content: content))
    }
}


Related