This is an improved version of @kou-ariga's code. It also fixed some issues, like:
- Sometimes popover not showing (when show-hide multiple popups one by one).
- In some cases, a sheet is opened instead of the popover, and the app crashes.
- After 1st time, the popup shows the wrong height.
Usage:
@State private var showInfo: Bool = false
// ...
Button {
showInfo = true
} label: {
Image(systemName: "info")
}
.alwaysPopover(isPresented: $showInfo) {
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam")
.font(.subheadline)
.multilineTextAlignment(.center)
.padding()
.frame(width: UIDevice.current.userInterfaceIdiom == .phone ? (UIScreen.screenWidth - 16 * 2) : 350)
.foregroundColor(Color.white)
.background(Color(.systemGray))
}
Component:
// MARK: - Extension
extension View {
func alwaysPopover<Content>(
isPresented: Binding<Bool>,
permittedArrowDirections: UIPopoverArrowDirection = [.up],
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View where Content: View {
self.modifier(AlwaysPopoverViewModifier(
isPresented: isPresented,
permittedArrowDirections: permittedArrowDirections,
onDismiss: onDismiss,
content: content
))
}
}
// MARK: - Modifier
struct AlwaysPopoverViewModifier<PopoverContent>: ViewModifier where PopoverContent: View {
@Binding var isPresented: Bool
let permittedArrowDirections: UIPopoverArrowDirection
let onDismiss: (() -> Void)?
let content: () -> PopoverContent
init(
isPresented: Binding<Bool>,
permittedArrowDirections: UIPopoverArrowDirection,
onDismiss: (() -> Void)? = nil,
content: @escaping () -> PopoverContent
) {
self._isPresented = isPresented
self.permittedArrowDirections = permittedArrowDirections
self.onDismiss = onDismiss
self.content = content
}
func body(content: Content) -> some View {
content
.background(
AlwaysPopover(
isPresented: self.$isPresented,
permittedArrowDirections: self.permittedArrowDirections,
onDismiss: self.onDismiss,
content: self.content
)
)
}
}
// MARK: - UIViewController
struct AlwaysPopover<Content: View>: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let permittedArrowDirections: UIPopoverArrowDirection
let onDismiss: (() -> Void)?
@ViewBuilder let content: () -> Content
init(
isPresented: Binding<Bool>,
permittedArrowDirections: UIPopoverArrowDirection,
onDismiss: (() -> Void)?,
content: @escaping () -> Content
) {
self._isPresented = isPresented
self.permittedArrowDirections = permittedArrowDirections
self.onDismiss = onDismiss
self.content = content
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self, content: self.content())
}
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
context.coordinator.host.rootView = self.content()
guard context.coordinator.lastIsPresentedValue != self.isPresented else { return }
context.coordinator.lastIsPresentedValue = self.isPresented
if self.isPresented {
let host = context.coordinator.host
if context.coordinator.viewSize == .zero {
context.coordinator.viewSize = host.sizeThatFits(in: UIView.layoutFittingExpandedSize)
}
host.preferredContentSize = context.coordinator.viewSize
host.modalPresentationStyle = .popover
host.popoverPresentationController?.delegate = context.coordinator
host.popoverPresentationController?.sourceView = uiViewController.view
host.popoverPresentationController?.sourceRect = uiViewController.view.bounds
host.popoverPresentationController?.permittedArrowDirections = self.permittedArrowDirections
if let presentedVC = uiViewController.presentedViewController {
presentedVC.dismiss(animated: true) {
uiViewController.present(host, animated: true, completion: nil)
}
} else {
uiViewController.present(host, animated: true, completion: nil)
}
}
}
class Coordinator: NSObject, UIPopoverPresentationControllerDelegate {
let host: UIHostingController<Content>
private let parent: AlwaysPopover
var lastIsPresentedValue: Bool = false
/// Content view size.
var viewSize: CGSize = .zero
init(parent: AlwaysPopover, content: Content) {
self.parent = parent
self.host = AlwaysPopoverUIHostingController(
rootView: content,
isPresented: self.parent.$isPresented,
onDismiss: self.parent.onDismiss
)
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.parent.isPresented = false
if let onDismiss = self.parent.onDismiss {
onDismiss()
}
}
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
}
}
}
// MARK: - UIHostingController
class AlwaysPopoverUIHostingController<Content: View>: UIHostingController<Content> {
@Binding private var isPresented: Bool
private let onDismiss: (() -> Void)?
init(rootView: Content, isPresented: Binding<Bool>, onDismiss: (() -> Void)?) {
self._isPresented = isPresented
self.onDismiss = onDismiss
super.init(rootView: rootView)
}
@available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidDisappear(_ animated: Bool) {
self.isPresented = false
if let onDismiss = self.onDismiss {
onDismiss()
}
}
}