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

1k Views Asked by At

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.stack.imgur.com/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.stack.imgur.com/2j4sl.jpg

1

There are 1 best solutions below

0
On BEST ANSWER

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:

  • This unfortunately doesn't work well (without one change I made) when the view is zoomed in. For whatever reason iOS still tries to mess with the scroll view and this time zooms it out, but doesn't render the area around it, causing there to be large white areas around an incomplete image view. Sigh. At this point I'm kind of done with this, you could probably try to fight it internally with some UIScrollView subclass that attempts to rebuke the iOS level changes, but yeah I've spent about as much time on this as I'd like to, so I'm just resetting the scrollView's zoomScale to be completely zoomed out once it asks for the context menu (note you have to do it here, in the willPresent context menu APIs it's too late). It's not that bad and solves it completely, just resets the user's zoom level somewhat annoyingly. But if I get a support email I'll just link them to this post.
  • Corner radius of 13.0 matches the iOS default one. Only catch is that this doesn't animate the corner radius from 0 to the rounded corner radius like the iOS one does, it kinda jumps, but it's barely noticeable. I'm sure there's a way to fix this, the headers for the context menu APIs mention animations in some capacity, but the documentation is really lacking and I don't want to spend a ton of time trying to figure out how.
  • In this example I use wrapperView inside the view controllers view. This is probably specific to my use case and might not be necessary in yours. Essentially you could attach it to the scrollView itself, but mine has some custom insetting to keep it always centered within notched iPhones with regard to safe area insets, and if I use the scroll view for the interaction/targeted preview it jumps it around a bit, which doesn't look great. You also don't want to use the view controller's view directly as the interaction, as it masks it off when doing the animation, so the black background of the media viewer/scroll view disappears completely, which doesn't look great. So a wrapper view at the top level prevents both of these nicely.