FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

SwiftUIでContextMenuのプレビューを別のViewに変えて表示する方法

こんにちは。FLINTERSのカレンダー | Advent Calendar 2022 - Qiita 19日目を担当します、佐野です。

今回はSwiftUIで長押ししたViewとは別のViewのプレビューをContextMenuで実装する方法をGANMA!でも実際に実装したので紹介しようと思います。

ContextMenuとは

iOS13から導入された機能で長押しすると対象のViewをプレビューするとともにメニューを表示できる機能です。

SwiftUIではViewのmodifierとして提供されていて下のように簡単に書くことができます。

ZStack {
    Circle()
        .foregroundColor(.blue)
        .frame(width: 100)
    Text("target")
}
.contextMenu {
    Button {
        print("Action1 tapped")
    } label: {
        Text("Action1")
    }
    Button {
        print("Action2 tapped")
    } label: {
        Text("Action2")
    }
}

別のViewをプレビューするには?

SwiftUIのcontextMenuではメニューの部分のViewはある程度自由に記述できますが長押しされたViewをそのままプレビューとして表示してしまうので、UIKitのUIContextMenuConfigurationのように別のViewをプレビューで表示することはできません。

これを実現するためには、SwiftUIでとタイトルに書きましたがUIKitを使います。

UIKitを使うと言ってもViewはSwiftUIで書いて、内部的にはUIKitを使うように書こうと思います。

流れとしては ContextMenuの対象となるSwiftUIのViewに透明なUIViewをUIViewRepresentableでオーバーレイで表示、 このUIViewが長押しを検知しプレビューしたいSwiftUIのViewをUIHostingControllerにいれて、UIContextMenuConfigurationでContextMenuを表示する

といった流れになりますが、SwiftUIとUIKitが入り乱れてわかりづらいと思いますので順にコードを追っていきます。

インタラクションさせるViewの作成

まずはUIKitのContextMenuを表示させるために長押しを検知させる透明なUIViewを作っていきます。

makeUIViewでUIContextMenuInteractionを追加した透明なViewを返し、 UIContextMenuInteractionDelegateはこのUIViewRepresentableのCoordinatorで実装していきます。

struct CustomPreviewView<Content: View>: UIViewRepresentable {
    private let viewProvider: () -> Content
    private let actionProvider: (([UIMenuElement]) -> UIMenu?)?
    private let onTapPreview: (() -> Void)?

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
        view.addInteraction(menuInteraction)
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
}

Coordinator

Coordinatorでは先ほどの透明なUIViewから表示されるContextMenuの実装をしていきます。

UIViewが長押しされたときにUIContextMenuInteractionDelegateのcontextMenuInteraction(_: configurationForMenuAtLocation:)が呼び出されUIContextMenuConfigurationを返すことでContextMenuを表示させることができます。

previewProviderではプレビューさせたいUIViewControllerを渡します。SwiftUIのViewをUIHostingControllerに入れて最小サイズで表示するようにしています。

actionProviderではContextMenuに表示するmenuを渡します。今回はわざわざSwiftUIのViewを使うようにするほどでもないのでUIKitをそのまま使っています。

プレビューをタップするとcontextMenuInteraction(_: willPerformPreviewActionForMenuWith: animator:)が呼ばれるのでクロージャを渡しています。

class Coordinator: NSObject, UIContextMenuInteractionDelegate {
    let previewView: CustomPreviewView

    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
        return .init(
            previewProvider: { [weak self] () in
                let vc = UIHostingController(rootView: self?.previewView.viewProvider())
                let size = vc.sizeThatFits(in: CGSize(width: Int.max, height: Int.max))
                vc.preferredContentSize = size
                vc.view.backgroundColor = .clear
                return vc
            },
            actionProvider: previewView.actionProvider
        )
    }

    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
        if let completion = previewView.onTapPreview {
            animator.addCompletion(completion)
        }
    }
}

ここまででSwiftUIの自前のViewをUIKitのContextMenuで表示するためにインタラクションを受け取る用のSwiftUIの透明なViewを作成できました

SwiftUIのViewに使う

最後にSwiftUIのViewに対してCustomPreviewViewを使いやすいようにViewを拡張してあげます

extension SwiftUI.View {
    func contextMenu(
        viewProvider: @escaping () -> some View,
        actionProvider: (([UIMenuElement]) -> UIMenu)? = nil,
        onTapPreview: (() -> Void)? = nil
    ) -> some View {
        overlay(
            CustomPreviewView(viewProvider, actionProvider, onTapPreview)
        )
    }
}

これを実際に使うと

ZStack {
    Circle()
        .foregroundColor(.blue)
        .frame(width: 100)
    Text("target")
}
.contextMenu {
    ZStack {
        Circle()
            .foregroundColor(.red)
            .frame(width: 200)
        Text("Preview view")
    }
} actionProvider: { _ in
    let action1 = UIAction(title: "Action1") { _ in
        print("Action1 tapped")
    }
    let action2 = UIAction(title: "Action2") { _ in
        print("Action2 tapped")
    }
    return .init(title: "", children: [action1, action2])
} onTapPreview: {
    print("preview tapped")
}

長押しすると赤い円のプレビューが表示されるようになりましたが、

プレビューされる時や、プレビューから元のViewに戻る時に白いViewが見えてしまっていますね。

アニメーションされるときに自動的にViewが入ってしまうみたいので

CustomPreviewView(viewProvider, actionProvider, onTapPreview)
    .opacity(0.011)

こんな感じで透過させると

もともとのUIKitのContextMenuに近い動き見た目になりました。

まとめ

SwiftUIで自前のViewをプレビューするContextMenuを表示できるようにしました。リスト表示する時などはアイテムの情報を表示するにはなかなか狭いことが多いのでContextMenuのプレビューを使ってリストのアイテムを補足するなど色々使い道はあると思うので是非取り入れてみてください。