Skip to content

Instantly share code, notes, and snippets.

@ccwasden
Created February 5, 2020 13:39
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save ccwasden/02cbe25b94eb6e844b43442427127e09 to your computer and use it in GitHub Desktop.
Save ccwasden/02cbe25b94eb6e844b43442427127e09 to your computer and use it in GitHub Desktop.
Custom size popover in iOS SwiftUI
// -- Usage
struct Content: View {
@State var open = false
@State var popoverSize = CGSize(width: 300, height: 300)
var body: some View {
WithPopover(
showPopover: $open,
popoverSize: popoverSize,
content: {
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
let content: () -> Content
let popoverContent: () -> PopoverContent
var body: some View {
content()
.background(
Wrapper(showPopover: $showPopover, popoverSize: popoverSize, popoverContent: popoverContent)
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
struct Wrapper<PopoverContent: View> : UIViewControllerRepresentable {
@Binding var showPopover: Bool
let popoverSize: CGSize?
let popoverContent: () -> PopoverContent
func makeUIViewController(context: UIViewControllerRepresentableContext<Wrapper<PopoverContent>>) -> WrapperViewController<PopoverContent> {
return WrapperViewController(
popoverSize: popoverSize,
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 popoverContent: () -> PopoverContent
let onDismiss: () -> Void
var popoverVC: UIViewController?
required init?(coder: NSCoder) { fatalError("") }
init(popoverSize: CGSize?,
popoverContent: @escaping () -> PopoverContent,
onDismiss: @escaping() -> Void) {
self.popoverSize = popoverSize
self.popoverContent = popoverContent
self.onDismiss = onDismiss
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
}
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.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
}
}
}
}
@wassupdoc
Copy link

if you add the following func you'll get the popover on iPhone as well

func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
 return .none // this is what forces popovers on iPhone 
}

@sorin360
Copy link

@wassupdoc Where should that func to be added ?

@dmallass
Copy link

@ccwasden If the popovercontent is getting modiifed while using it, it doesn't reflect in the view

@dmallass
Copy link

Ignore my above comment. I figured that out :)

@csmac3144
Copy link

Brilliant mate -- a real help to SwiftUI folks :-)

@outerstorm
Copy link

@dmallass - I'm having the same issue where the view isn't reflecting the underlying state changes. happen to remember what was causing that issue for you?

@tunkul
Copy link

tunkul commented Mar 14, 2022

Yes, this didn't work on iPhone simulators on 15.2, but adding the function above (by @wassupdoc ) is what did it. Add it to the class, WrapperViewController.

@cloxnu
Copy link

cloxnu commented May 2, 2022

@dmallass - I'm having the same issue where the view isn't reflecting the underlying state changes. happen to remember what was causing that issue for you?

I have solved this issue by adding the following code to func updateUIViewController after the if else pair:

if let hostingController = uiViewController.popoverVC as? UIHostingController<PopoverContent> {
    hostingController.rootView = popoverContent()
}

@lakshith-403
Copy link

@sorin360 You should add that inside the UIPopoverPresentationControllerDelegate you want it to act on. In this case, WrapperViewController

@lakshith-403
Copy link

lakshith-403 commented Nov 18, 2022

I encountered a weird bug which sometime caused the popover to reopen itself as a sheet (iOS). While I don't know the cause for it i suspected may be it's due to something happening between the controllerWillDismiss and controllerDidDismiss. Therefore i moved the content in presentationControllerWillDismiss to presentationControllerDidDismiss. It fixed the issue

@jp-hoehmann
Copy link

When I use this within a SwiftUI List, opening the popover will display a warning, that “presenting view controller [UIHostingController] from detached view controller [WrapperViewController] is discouraged” (when used outside of List, it's fine). I have spent some time trying to figure out what might be causing this, but I can't find a reason why WrapperViewController would be detached. Does anyone know what might be causing this?

@wassupdoc
Copy link

wassupdoc commented Mar 3, 2023

With iOS 16.4 - haven’t had a chance to test it myself but the new presentationCompactAdaptation(.none) modifier may work - Apples documentation - Apply the new .presentationCompactAdaptation(_:) modifier to the content of a modal presentation to control how it adapts to compact size classes on iPad and iPhone.
For example, the popover modifier presents a popover on iPad. By default, a popover adapts to the narrow horizontal size class on iPhone by showing as a sheet. In the example below, the .presentationCompactAdaptation(.none) modifier asks SwiftUI to show this as a popover on iPhone as well.
struct PopoverExample: View {
@State private var isShowingPopover = false

  var body: some View {
      Button("Show Popover") {
          self.isShowingPopover = true
      }
      .popover(isPresented: $isShowingPopover) {
          Text("Popover Content")
              .padding()
              .presentationCompactAdaptation(.none)
      }
  }

}
Use .presentationCompactAdaptation(horizontal:vertical:) to adapt differently in horizontally and vertically compact size classes. (103257577)

@Frank-Peter
Copy link

@INuvanda: Yes, there is this warning all the time and its getting worse with Xcode 15 beta: Here it's stated that this will be an error in future releases. I used these two nice extensions

extension UIWindow {
    static var key: UIWindow? {                                     // replacement of keyWindow
        if #available(iOS 13, *) {
                // from: https://stackoverflow.com/questions/57134259/how-to-resolve-keywindow-was-deprecated-in-ios-13-0
            return UIApplication
                .shared
                .connectedScenes
                .compactMap { $0 as? UIWindowScene }
                .flatMap { $0.windows }
                .last { $0.isKeyWindow }
        } else {
            return UIApplication.shared.keyWindow
        }
    }
}

extension UIViewController {
        // from https://stackoverflow.com/questions/26667009/get-top-most-uiviewcontroller
    static var topMostViewController: UIViewController? {
        var topMostViewController = UIWindow.key?.rootViewController    // the starting vc
        while let presentedViewController = topMostViewController?.presentedViewController {
            topMostViewController = presentedViewController         // loop over all presented vcs
        }
        return topMostViewController                                // and return the last/topmost one
    }
}

to modify the function showPopover():

        func showPopover() {
            guard popoverVC == nil,
            let topMostViewController = Self.topMostViewController 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.delegate = self
            }
            popoverVC = vc
            topMostViewController.present(vc, animated: true, completion: nil)    // present from topmost VC
        }

and the warning is gone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment