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
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:
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.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.