Using UIPreviewParameters with textLineRects on a UITextView

What is the proper way to use UIPreviewParameters(textLineRects:) for context menu configurations on a text view? I'm trying to replicate the behavior in Safari, where long-pressing/3d-touching on a link shows the preview during the context menu animation only for the text of the link being pressed. I've gotten so far as calculating the text rects for a link itself, and passing them to the preview parameters, but the actual preview displayed doesn't look quite correct.

I'm doing the following things in the context menu interaction delegate callbacks of my UITextView subclass.

Firstly, in the contextMenuInteraction(_:configurationForMenuAtLocation:) method, I call NSLayoutManager.enumerateEnclosingRects(forGlyphRange:withinSelectedGlyphRange:in:using:) to get the actual rects occupied by the pressed link (these are in the coordinate space of the UITextView, if I understand correctly). I store these rects along with the range of the link in an instance of my custom subclass of UIContextMenuConfiguration I return from this method.

Then, in the contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:) method, I pull the aforementioned information out of the context menu configuration. I create a UIPreviewParameters object using the rects retrieved above. Then I create a UIPreviewTarget targeting self with the center as the calculated center of the text line rects. For the UITargetedPreview, I create a new UITextView whose frame is my text view's bounds. The new text view's attributed text is a copy of my text view's attributed text with the color of everything outside the link range set to clear.

This results in a context menu preview that seems to be the correct shape and size, but the duplicated text view used for the preview itself doesn't have the the text in the same position as the original one. In most instances, the text is correctly horizontally aligned, it's just positioned in the copy about 10pts below where it is in the original. Occasionally, though, the text position changes entirely (e.g., if the link starts at the very end of a line originally, it might start on the beginning of the next line in the copied text view).

Code Block
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
if let (link, range) = getLinkAtPoint(location) {
var rects = [CGRect]()
layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { (rect, stop) in
rects.append(rect)
}
let configuration = CustomContextMenuConfiguration(identifier: nil, previewProvider: /* omitted */, actionProvider: /* omitted */)
configuration.textLineRects = rects
configuration.linkRange = range
return configuration
} else {
return nil
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
if let configuration = configuration as? MyConfiguration,
let rects = configuration.textLineRects,
let linkRange = configuration.linkRange {
var minX: CGFloat = .greatestFiniteMagnitude, maxX: CGFloat = .leastNonzeroMagnitude, minY: CGFloat = .greatestFiniteMagnitude, maxY: CGFloat = .leastNonzeroMagnitude
for rect in rects {
minX = min(rect.minX, minX)
maxX = max(rect.maxX, maxX)
minY = min(rect.minY, minY)
maxY = max(rect.maxY, maxY)
}
let rectsCenter = CGPoint(x: (minX + maxX) / 2 - frame.minX, y: (minY + maxY) / 2)
let copy = UITextView(frame: self.bounds)
let mut = NSMutableAttributedString(attributedString: self.attributedText)
mut.setAttributes([.foregroundColor: UIColor.clear, .font: self.defaultfont], range: NSRange(location: 0, length: linkRange.location))
mut.setAttributes([.foregroundColor: UIColor.clear, .font: self.defaultFont], range: NSRange(location: linkRange.upperBound, length: mut.length - linkRange.upperBound))
copy.attributedText = mut
let parameters = UIPreviewParameters(textLineRects: rects as [NSValue])
let target = UIPreviewTarget(container: self, center: rectsCenter)
return UITargetedPreview(view: copy, parameters: parameters, target: target)
} else {
return nil
}
}

Answered by Frameworks Engineer in 615527022
Double check that other attributes of the duplicated text view that may affect text layout (such as its textContainerInset) match the original text view. That being said, you should consider taking a snapshot of the text view and passing that as the preview's view rather than duplicating it (which seems somewhat heavy). You could even limit the snapshot to an area that encompasses your text rects instead of capturing the entire view.
Accepted Answer
Double check that other attributes of the duplicated text view that may affect text layout (such as its textContainerInset) match the original text view. That being said, you should consider taking a snapshot of the text view and passing that as the preview's view rather than duplicating it (which seems somewhat heavy). You could even limit the snapshot to an area that encompasses your text rects instead of capturing the entire view.
As far as I can tell, the only different properties between the two text views are the text view's own font and the text container's lineFragmentPadding. Setting both of those on the duplicated text view to their values from the original one does not affect the layout of the text in the preview animation.

Using a snapshot of the original text view as the preview's view mostly works, however in some cases when the link text wraps across multiple lines, the text from right before the link is still partially visible in the preview animation, presumably because such a wide margin is added around the textLineRects (I would post an image of this, but...).
The workaround I came up with for this is taking the text line rects, generating a UIBezierPath that exactly wraps around all of them while containing nothing extra. I then use this path in a CAShapeLayer that I assign to the snapshot view's layer.mask property.

Unfortunately, this doesn't quite work as it seems using UIPreviewParameters(textLineRects:) causes the preview view's mask to be silently ignored. So, I embed the snapshot view into a separate container and pass that as the view parameter to UITargetedPreview. See FB7832297 for more information about this issue.
Using UIPreviewParameters with textLineRects on a UITextView
 
 
Q