One aspect of iOS development that always struck me as funny is how Apple’s default of Popover is different on iPad and iPhone. Basically on iPhone the standard popover is shown like an Action Sheet.
But there are certainly times where you want a popover on iPhone, like a simple delete confirmation, for example. You tap on a trash can icon, and you’d like a quick and tiny confirmation button to appear in context without the jarring experience of the entire screen being taken over by an action sheet. Or even the bottom 1/4 of the screen, especially when that button was on the navigation bar at the top right.
There are plenty of solutions to this in UIKit, but sadly none (yet) with SwiftUI. The .popover view modifier decides for you whether it will be a sheet or a popover, so basically here’s another example of SwiftUI being powerful but not yet fully filled out in terms of components available to us.
Full disclosure: I feel inclined to write a bit of a blog post about this, even though all I or anyone cares about is a solution to the problem. So here we go. I admit I didn’t write this, and so want to give a huge shout out and thank you to Chase Wasden, located here. I’ve taken his gist located here, then modified it to support being able to specify where you’d like your arrows to be, and here it is below:
//
// WithPopover.swift
// PopoverSwiftUI
//
// THANK YOU to this wonderful human: https://gist.github.com/ccwasden/02cbe25b94eb6e844b43442427127e09
import SwiftUI
// -- Usage
/*
struct Content: View {
@State var open = false
@State var popoverSize = CGSize(width: 300, height: 300)
var body: some View {
WithPopover(
showPopover: $open,
arrowDirections: [.down],
popoverSize: popoverSize,
content: {
// The view you want to anchor your popover to.
Button(action: { self.open.toggle() }) {
Text("Tap me")
}
},
popoverContent: {
VStack {
Button(action: { self.popoverSize = CGSize(width: 300, height: 600)}) {
Text("Increase size")
}
Button(action: { self.open = false}) {
Text("Close")
}
}
})
}
}
*/
// -- Source
struct WithPopover<Content: View, PopoverContent: View>: View {
@Binding var showPopover: Bool
var popoverSize: CGSize? = nil
var arrowDirections: UIPopoverArrowDirection = [.down]
let content: () -> Content
let popoverContent: () -> PopoverContent
var body: some View {
content()
.background(
Wrapper(showPopover: $showPopover, arrowDirections: arrowDirections, popoverSize: popoverSize, popoverContent: popoverContent)
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
struct Wrapper<PopoverContent: View> : UIViewControllerRepresentable {
@Binding var showPopover: Bool
var arrowDirections: UIPopoverArrowDirection
let popoverSize: CGSize?
let popoverContent: () -> PopoverContent
func makeUIViewController(context: UIViewControllerRepresentableContext<Wrapper<PopoverContent>>) -> WrapperViewController<PopoverContent> {
return WrapperViewController(
popoverSize: popoverSize,
permittedArrowDirections: arrowDirections,
popoverContent: popoverContent) {
self.showPopover = false
}
}
func updateUIViewController(_ uiViewController: WrapperViewController<PopoverContent>,
context: UIViewControllerRepresentableContext<Wrapper<PopoverContent>>) {
uiViewController.updateSize(popoverSize)
if showPopover {
uiViewController.showPopover()
}
else {
uiViewController.hidePopover()
}
}
}
class WrapperViewController<PopoverContent: View>: UIViewController, UIPopoverPresentationControllerDelegate {
var popoverSize: CGSize?
let permittedArrowDirections: UIPopoverArrowDirection
let popoverContent: () -> PopoverContent
let onDismiss: () -> Void
var popoverVC: UIViewController?
required init?(coder: NSCoder) { fatalError("") }
init(popoverSize: CGSize?,
permittedArrowDirections: UIPopoverArrowDirection,
popoverContent: @escaping () -> PopoverContent,
onDismiss: @escaping() -> Void) {
self.popoverSize = popoverSize
self.permittedArrowDirections = permittedArrowDirections
self.popoverContent = popoverContent
self.onDismiss = onDismiss
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
}
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none // this is what forces popovers on iPhone
}
func showPopover() {
guard popoverVC == nil else { return }
let vc = UIHostingController(rootView: popoverContent())
if let size = popoverSize { vc.preferredContentSize = size }
vc.modalPresentationStyle = UIModalPresentationStyle.popover
if let popover = vc.popoverPresentationController {
popover.sourceView = view
popover.permittedArrowDirections = self.permittedArrowDirections
popover.delegate = self
}
popoverVC = vc
self.present(vc, animated: true, completion: nil)
}
func hidePopover() {
guard let vc = popoverVC, !vc.isBeingDismissed else { return }
vc.dismiss(animated: true, completion: nil)
popoverVC = nil
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
popoverVC = nil
self.onDismiss()
}
func updateSize(_ size: CGSize?) {
self.popoverSize = size
if let vc = popoverVC, let size = size {
vc.preferredContentSize = size
}
}
}
}
This is also an excellent example for learning how to make UIKit and SwiftUI play with each other.