iosuiscrollviewios13uicontextmenuinteractionuicontextmenuconfiguration

How do I prevent UIScrollView from zooming in a ton when activating an iOS 13 context menu?


If you have a UIScrollView that you can zoom into, and you add an iOS 13 context menu interaction to the view inside the scroll view (eg: a UIImageView), when you perform the interaction it weirdly zooms into the image momentarily, then zooms it out so show the context menu, then upon exiting this context menu it leaves the image zoomed in really far. It seems to be going off of the UIImageView's bounds.

StackOverflow doesn't seem to support embedding videos/GIFs, so here's a video of it on Imgur showing what I mean: https://i.sstatic.net/ALn8u.jpg

Is there a way to prevent this behavior? In WKWebView (a UIScrollView subclass) for instance, long pressing on an image doesn't exhibit this behavior.

Here's the simple code to show a sample of it if you wanted to test it in a simple new Xcode project:

import UIKit

class RootViewController: UIViewController, UIScrollViewDelegate, UIContextMenuInteractionDelegate {
    let scrollView = UIScrollView()
    let imageView = UIImageView(image: UIImage(named: "cat.jpg")!)

    override func viewDidLoad() {
        super.viewDidLoad()

        [view, scrollView].forEach { $0.backgroundColor = .black }

        scrollView.delegate = self
        scrollView.frame = view.bounds
        scrollView.addSubview(imageView)
        scrollView.contentSize = imageView.frame.size
        view.addSubview(scrollView)

        // Set zoom scale
        let scaleToFit = min(scrollView.bounds.width / imageView.bounds.width, scrollView.bounds.height / imageView.bounds.height)
        scrollView.maximumZoomScale = max(1.0, scaleToFit)
        scrollView.minimumZoomScale = scaleToFit < 1.0 ? scaleToFit : 1.0
        scrollView.zoomScale = scaleToFit

        // Add context menu support
        imageView.isUserInteractionEnabled = true
        imageView.addInteraction(UIContextMenuInteraction(delegate: self))
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        scrollView.frame = view.bounds
    }

    // MARK: - UIScrollView

    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }

    // MARK: - Context Menus

    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
        return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in
            return nil
        }) { (suggestedElements) -> UIMenu? in
            var children: [UIAction] = []

            children.append(UIAction(title: "Upvote", image: UIImage(systemName: "arrow.up")) { (action) in
            })

            children.append(UIAction(title: "Downvote", image: UIImage(systemName: "arrow.down")) { (action) in
            })

            return UIMenu(title: "", image: nil, identifier: nil, options: [], children: children)
        }
    }
}

And here's cat.jpg if you'd like it as well: https://i.sstatic.net/2j4sl.jpg


Solution

  • Think I solved it. The gist of the solution is to not add the interaction to the image view itself as you would intuitively think to, but add it to an outer view and then focus the context menu preview onto the rect of the image view using the UITargetPreview APIs. This way you all together avoid touching the image view that bugs out, and go to its parent instead and just "crop in" to the subview, which keeps the subview happy. :)

    Here's the code I ended up with:

    import UIKit
    
    class RootViewController: UIViewController, UIScrollViewDelegate, UIContextMenuInteractionDelegate {
        let wrapperView = UIView()
        let scrollView = UIScrollView()
        let imageView = UIImageView(image: UIImage(named: "cat.jpg")!)
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            wrapperView.frame = view.bounds
            view.addSubview(wrapperView)
    
            [view, wrapperView, scrollView].forEach { $0.backgroundColor = .black }
    
            scrollView.delegate = self
            scrollView.frame = view.bounds
            scrollView.addSubview(imageView)
            scrollView.contentSize = imageView.frame.size
            wrapperView.addSubview(scrollView)
    
            // Set zoom scale
            let scaleToFit = min(scrollView.bounds.width / imageView.bounds.width, scrollView.bounds.height / imageView.bounds.height)
            scrollView.maximumZoomScale = max(1.0, scaleToFit)
            scrollView.minimumZoomScale = scaleToFit < 1.0 ? scaleToFit : 1.0
            scrollView.zoomScale = scaleToFit
    
            // Add context menu support
            wrapperView.addInteraction(UIContextMenuInteraction(delegate: self))
        }
    
        // MARK: - UIScrollView
    
        func viewForZooming(in scrollView: UIScrollView) -> UIView? {
            return imageView
        }
    
        // MARK: - Context Menus
    
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
            scrollView.zoomScale = scrollView.minimumZoomScale
    
            return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in
                return nil
            }) { (suggestedElements) -> UIMenu? in
                var children: [UIAction] = []
    
                children.append(UIAction(title: "Upvote", image: UIImage(systemName: "arrow.up")) { (action) in
                })
    
                children.append(UIAction(title: "Downvote", image: UIImage(systemName: "arrow.down")) { (action) in
                })
    
                return UIMenu(title: "", image: nil, identifier: nil, options: [], children: children)
            }
        }
    
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
            let parameters = UIPreviewParameters()
    
            let rect = imageView.convert(imageView.bounds, to: wrapperView)
            parameters.visiblePath = UIBezierPath(roundedRect: rect, cornerRadius: 13.0)
    
            return UITargetedPreview(view: wrapperView, parameters: parameters)
        }
    
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
            let parameters = UIPreviewParameters()
    
            let rect = imageView.convert(imageView.bounds, to: wrapperView)
            parameters.visiblePath = UIBezierPath(roundedRect: rect, cornerRadius: 0.0)
    
            return UITargetedPreview(view: wrapperView, parameters: parameters)
        }
    }
    

    Some notes: