SwiftUIで吹き出し(UIPopoverPresentationController)

iOS17からTipKitが提供されるようなのでこの手のUIコンポーネントは短命になるかと思いますが、iOS17が広まるまでは今しばらく時間があるかと思うので記載しておきます。

@available(iOS 15, *)
extension View {
    @ViewBuilder
    func popover<Content: View>(isPresented: Binding<Bool>,_ arrowDirection: UIPopoverArrowDirection = .any, @ViewBuilder content: @escaping () -> Content) -> some View {
        self.background {
            PopoverController(isPresented: isPresented, arrowDirection: arrowDirection, content: content())
        }
    }
}
class CustomHostingView<Content: View>: UIHostingController<Content>{
    override func viewDidLoad() {
        super.viewDidLoad()
        preferredContentSize = view.intrinsicContentSize
    }
}
@available(iOS 15, *)
struct PopoverController<Content: View>: UIViewControllerRepresentable {
    @Binding var isPresented: Bool
    var arrowDirection: UIPopoverArrowDirection = .down
    var content: Content
    @State var isShown: Bool = false
    class Coordinator: NSObject, UIPopoverPresentationControllerDelegate {
        var parent: PopoverController
        init(parent: PopoverController) {
            self.parent = parent
        }
        func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
            return .none
        }
        func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
            self.parent.isPresented = false
        }
        func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
            DispatchQueue.main.async {
                self.parent.isShown = true
            }
        }
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    func makeUIViewController(context: Context) -> some UIViewController {
        let viewController = UIViewController()
        viewController.view.backgroundColor = .clear
        return viewController
    }
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        if self.isShown {
            if !self.isPresented {
                uiViewController.dismiss(animated: true) {
                    self.isShown = false
                }
            }
        } else {
            if self.isPresented {
                let customView = CustomHostingView(rootView: content)
                customView.view.backgroundColor = .systemBackground
                customView.modalPresentationStyle = .popover
                customView.popoverPresentationController?.permittedArrowDirections = arrowDirection
                customView.popoverPresentationController?.delegate = context.coordinator
                customView.popoverPresentationController?.sourceView = uiViewController.view
                uiViewController.present(customView, animated: true)
            }
        }
    }
}

呼び出し側は以下の通り。

import SwiftUI
struct ContentView: View {
    @State var isPresented: Bool = false
    var body: some View {
        VStack {
            Button("Show popover", action: {
                self.isPresented.toggle()
            }).popover(isPresented: $isPresented, content: {
                Text("Hello, World!")
                    .padding(4)
            }).buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

吹き出しを表示したいUIコンポーネントに拡張した .popover を指定して、isPresented を toggle() で変更させることで表示させる利用方法。.popover の中にはあらかじめ表示させておきたい View を指定しておく。