2

I'm trying to set a certain size for a popover or to make it adapt its content

I tried to change the frame for the view from popover, but it does not seem to work

Button("Popover") {
        self.popover7.toggle()
}.popover(isPresented: self.$popover7, arrowEdge: .bottom) {
         PopoverView().frame(width: 100, height: 100, alignment: .center)
}

I'd like to achieve this behaviour I found in Calendar app in iPad

enter image description here

Sorin Lica
  • 6,894
  • 10
  • 35
  • 68

4 Answers4

10

The solution by @ccwasden works very well. I extended his work by making it more "natural" in terms of SwiftUI. Also, this version utilizes sizeThatFits method, so you don't have to specify the size of the popover content.

struct PopoverViewModifier<PopoverContent>: ViewModifier where PopoverContent: View {
    @Binding var isPresented: Bool
    let onDismiss: (() -> Void)?
    let content: () -> PopoverContent

    func body(content: Content) -> some View {
        content
            .background(
                Popover(
                    isPresented: self.$isPresented,
                    onDismiss: self.onDismiss,
                    content: self.content
                )
            )
    }
}

extension View {
    func popover<Content>(
        isPresented: Binding<Bool>,
        onDismiss: (() -> Void)? = nil,
        content: @escaping () -> Content
    ) -> some View where Content: View {
        ModifiedContent(
            content: self,
            modifier: PopoverViewModifier(
                isPresented: isPresented,
                onDismiss: onDismiss,
                content: content
            )
        )
    }
}

struct Popover<Content: View> : UIViewControllerRepresentable {
    @Binding var isPresented: Bool
    let onDismiss: (() -> Void)?
    @ViewBuilder let 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()
        if self.isPresented, uiViewController.presentedViewController == nil {
            let host = context.coordinator.host
            host.preferredContentSize = host.sizeThatFits(in: CGSize(width: Int.max, height: Int.max))
            host.modalPresentationStyle = UIModalPresentationStyle.popover
            host.popoverPresentationController?.delegate = context.coordinator
            host.popoverPresentationController?.sourceView = uiViewController.view
            host.popoverPresentationController?.sourceRect = uiViewController.view.bounds
            uiViewController.present(host, animated: true, completion: nil)
        }
    }

    class Coordinator: NSObject, UIPopoverPresentationControllerDelegate {
        let host: UIHostingController<Content>
        private let parent: Popover

        init(parent: Popover, content: Content) {
            self.parent = parent
            self.host = UIHostingController(rootView: content)
        }

        func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
            self.parent.isPresented = false
            if let onDismiss = self.parent.onDismiss {
                onDismiss()
            }
        }

        func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
            return .none
        }
    }
}
Kousuke Ariga
  • 691
  • 9
  • 10
  • 1
    Excellent improvement. Was about to do the same myself but then I saw your reply further down :) – Jensie Oct 27 '21 at 19:13
  • 1
    Thank you, @Jensie! I made a minor change to the updateUIViewController method. The previous version had an internal state inconsistency issue when multiple popovers are used in a single view. I hope it helps. – Kousuke Ariga Oct 28 '21 at 04:08
  • 1
    Thanks @ccwasden and @kou_ariga! A few minor modifications for my needs... On an iPad it was creating a view that filled the entire width of the screen. So I updated the body() of PopoverViewModifier to check `UIDevice.current.userInterfaceIdiom == .phone`, if true, it applies the custom modifier. If not, it then uses the "native" SwiftUI version of the popover. I also had to rename the View function extension to avoid a naming clash at runtime. – Jason Armstrong Jul 26 '22 at 00:13
7

I got it to work on iOS with a custom UIViewRepresentable. Here is what the usage looks like:

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")
                    }
                }
        })
    }
}

And here is a gist with the source for WithPopover

chasew
  • 8,438
  • 7
  • 41
  • 48
  • Doesn't work for me unfortunately... and it fails in preview: `reference to generic type 'WithPopover' requires arguments in <...>`. Also I get this: `Presenting view controllers on detached view controllers is discouraged` – krjw Mar 17 '20 at 14:20
  • Works fine for me in simulator, without failure. Took the whole content from the Gist and used it according to the snippet above. Preview does not work properly, just shows an empty screen when tapping the button. But no errors shown. Xcode Version 11.4 beta 3 (11N132i). – Hardy Mar 22 '20 at 09:31
  • @ccwasden forgot to include the adapativepresentationstyle func in the gist func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { return .none // this is what forces popovers on iPhone } – Prasanth Jun 02 '20 at 11:31
  • This doesn't work. iOS15. simulator iPhone 12. Always takes a full screen – lopes710 Mar 14 '22 at 14:09
1

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()
        }
    }
}

Mahmudul Hasan Shohag
  • 2,203
  • 1
  • 22
  • 30
0

macOS-only

Here is how to change frame of popover dynamically... for simplicity it is w/o animation, it is up to you.

struct TestCustomSizePopover: View {
    @State var popover7 = false
    var body: some View {
        VStack {
            Button("Popover") {
                    self.popover7.toggle()
            }.popover(isPresented: self.$popover7, arrowEdge: .bottom) {
                     PopoverView()
            }
        }.frame(width: 800, height: 600)
    }
}

struct PopoverView: View {
    @State var adaptableHeight = CGFloat(100)
    var body: some View {
        VStack {
                Text("Popover").padding()
                Button(action: {
                    self.adaptableHeight = 300
                }) {
                    Text("Button")
                }
            }
            .frame(width: 100, height: adaptableHeight)
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 1
    Did you try the code? It's not working, the popover size does not change – Sorin Lica Nov 13 '19 at 13:52
  • Sure it is copy/paste from the test project. Xcode 11.2.1. Ahh.. pardon, I did it for MacOS target. I'll review for iOS a bit later. – Asperi Nov 13 '19 at 14:01
  • Yeah, setting the frame for a popover in MacOS works well, but on iOS the `frame` modifier has no effect on popovers – Sorin Lica Nov 13 '19 at 14:24
  • By my finding for now content tracks .frame correctly and has changed, but system popover window does not track content changes (on iOS). – Asperi Nov 13 '19 at 18:00
  • Does not seem like it’s currently possible to change the frame of a popover. I created bug report FB7465491, I suggest doing the same. – abellao Nov 25 '19 at 00:22