Replicating Safari's Link Preview Animation

In iOS 13, Apple replaced the Peek and Pop force touch system with new context menus and previews[1]. These new previews have a fancy animation for when the appear, in which they expand out of the content and onto the screen. Back when I first replaced the previews in Tusker with the new context menus (over a year ago, shortly after WWDC19), I wanted to replicate the behavior in Safari for links and mentions in post bodies. At the time, there was pretty much zero official documentation about the new context menu APIs, so I decided to wait for either Apple to publish docs or for someone else to figure it out first. Now that WWDC20 has come and gone, and I’ve been working on it a little bit at a time for over a year, I finally have a fully working implementation.

Here’s what the Safari behavior looks like with animations slowed down, both with a single-line link and one that spans multiple lines:

They both look pretty much like you'd expect. In the single-line case, the text of the link appears inside a sort of bubble which animates into the link preview. For the multi-line link, it's pretty similar, but there are two bubbles each of which contains the part of the link that's on their respective lines. If the text fragments overlapped horizontally at all, then the two bubbles would be merged together. By default, if you don't provide a custom preview, UIKit will simply display the entire view during the animation. This doesn't look great, particularly for long pieces of text, of which the link may only be a small portion. Only highlighting the link text looks much better. So, let's see what it takes to reimplement the same behavior as Safari.

First, a little bit about how custom previews work: From the contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:) method, you provide a custom UITargetedPreview describing the configuration of the preview itself. It takes the view to be displayed as the preview, a preview parameters object, and a preview target object. The preview parameters describe how the preview should be displayed and the preview target defines what view the preview view should be within and where inside that view it should be anchored. So, in the previewForHighlightingMenuWithConfiguration method, we need to construct all of these and then assemble them into a targeted preview.

The most obvious starting place is the UIPreviewParameters(textLineRects:) initializer, since it directly deals with the behavior we’re trying to replicate. It takes an array of CGRects (wrapped in NSValues) representing the rectangles occupied by the text (one rect per line the text is on) in the coordinate space of the preview view.

Because this is a UITextView subclass, the CoreText stack is already setup and we can directly access it. NSLayoutManager has a handy method called enumerateEnclosingRects(forGlyphRange:withinSelectedGlyphRange:in:) which takes the range of some text and gives us access to the rectangles that the text occupies.

But to use that method, we need to get the link range from somewhere. Since we have the context menu interaction in the previewForHighlightingMenuWithConfiguration method, we could ask it for its location within ourself and then find the link and range at that point, but that would be duplicating work done almost immediately before in the contextMenuInteraction(_:configurationForMenuAtLocation:) method. Instead, we’ll store the link range on a private property of the text view subclass from the configurationForMenuAtLocation method, and then retrieve it in previewForHighlightingMenuWithConfiguration. (The code below assumes that there already exists a method called getLinkRangeAtPoint which takes a point inside the text view and returns the range that the link spans, if there is one.)

private var currentPreviewedLinkRange: NSRange?

func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration {
  guard let range = self.getLinkRangeAtPoint(location) else {
      return nil
  }
  self.currentPreviewedLinkRange
  return // ...
}

Then, in the contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:) method, we can grab the stored range and get to creating the preview. Returning nil from this method will simply use the default preview configuration, showing the entire text view in the preview animation, which is a reasonable fallback if for some reason we don’t have the link range.

func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
    guard let linkRange = currentPreviewedLinkRange else {
        return nil
    }
    currentPreviewedLinkRange = nil
}

With the link range, we can use the enumerateEnclosingRects method on the text view’s layout manager and then use those rectangles to construct the preview parameters object.

let notFoundRange = NSRange(location: NSNotFound, length: 0)
var textLineRects = [CGRect]()
self.layoutManager.enumerateEnclosingRects(forGlyphRange: linkRange,
                                           withinSelectedGlyphRange: notFoundRange,
                                           in: self.textContainer) { (rect, stop) in
    textLineRects.append(rect)
}

let parameters = UIPreviewParameters(textLineRects: textLineRects as [NSValue])

Now that we’ve finally got the text line rects and the preview parameters, we can move on to the next piece of the puzzle: the view that’s going to be shown in the preview animation. You might think that we could use self as the preview view, but that wouldn’t work. While the animation is running, the preview view is removed from the regular view hierarchy, meaning the rest of the text would disappear while the animation is running (what’s more, since later we’ll use self as the target for the preview, the preview itself wouldn’t even appear). We could try to duplicate ourself, and copy over all the layout-impacting attributes, but that’s just asking for slight layout differences.[2] Instead, to ensure we get a view that appears exactly the same as our text view, we can use a snapshot view.

guard let snapshot = self.snapshotView(afterScreenUpdates: false) else {
  	return nil
}

Next, we need to create a preview target. Reading the documentation, you might notice the UITargetedPreview(view:parameters:) initializer and wonder why this is even necessary. Well, if you try to use that initializer with a snapshot view, your app will crash because the snapshot view hasn’t been added to the view hierarchy, and therefore, because there’s no target, UIKit doesn’t know where to put it. The UIPreviewTarget describes exactly that. It needs to know the container view that the preview will placed in (simply self, since we’re in the text view) as well as where inside the target container the center of the preview should be anchored. We want to anchor the center point of the preview view such that the text of the link appears to be in the exact same place. With the text line rects, we can determine the overall bounds of the link’s text fragment. From there, since the preview will have the same bounding rectangle as the link text, we can just use the center of the rect enclosing the text.

var minX = CGFloat.greatestFiniteMagnitude, maxX = -CGFloat.greatestFiniteMagnitude,
    minY = CGFloat.greatestFiniteMagnitude, maxY = -CGFloat.greatestFiniteMagnitude
for rect in textLineRects {
    minX = min(rect.minX, minX)
    maxX = max(rect.maxX, maxX)
    minY = min(rect.minY, minY)
    maxY = max(rect.maxY, maxY)
}
let textLineRectsCenter = CGPoint(x: (minX + maxX) / 2, y: (minX + maxX) / 2)
let target = UIPreviewTarget(container: self, center: textLineRectsCenter)

Then, we can finally construct and return the targeted preview:

return UITargetedPreview(view: snapshot, parameters: parameters, target: target)

If we run this, the preview animation will be limited to the text of the link, and it looks pretty good:

Unfortunately, there’s still a pretty big problem: if the link is near enough other text, and particularly if it spans multiple lines, the text that’s visually near the link will be partly visible in the preview. This happens because UIKit takes the text line rects passed into UIPreviewParameters, does some stuff to expand them and round the corners and merge them, creating the bubble shape, and then masks the preview view to the resulting path. Unfortunately, it doesn’t mask the text beforehand; it masks everything in one pass. So, what we need to do ourselves before giving UIKit the preview view is mask to directly around the text, preventing anything else from seeping in.

To do this, we have to do something similar to what UIKit is doing. We need to generate a path which contains the entirety of all of the text line rects, and nothing more. (Note: this is not the convex hull problem, since we don’t need a path that contains the points of all the rectangles, we need the smallest path that encloses them.) Implementing this isn’t particularly interesting, and is left as an exercise to the reader[3]. Assuming there’s a custom initializer UIBezierPath(wrappingAroundRects:) which produces a path from an array of CGRects, the obvious thing to do is mask the view to that path using a layer mask with a CAShapeLayer:

let path = UIBezierPath(wrappingAroundRects: textLineRects)
let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath
snapshot.layer.mask = maskLayer

Running this, however, doesn’t quite work. Everything looks exactly the same as before, with the nearby text appearing inside the preview during the animation. You might check the documentation and think to try the visiblePath attribute on UIPreviewParameters. Unfortunately, that entirely overrides the mask generated by the textLineRects initializer, the exact opposite of the current problem.

It seems that, when using the UIPreviewParameters(textLineRects:) initializer, UIKit will silently discard any existing layer mask on the view provided as the preview view (FB7832297). This is also true for UIView masks. This caused a great deal of hair-pulling for me, until I disabled the preview parameters stuff and the mask suddenly started working. The simple workaround for this is to just apply the mask to the snapshot view, embed the snapshot inside an additional container view of the same size, and then use that container as the view for the UITargetedPreview:

let snapshotContainer = UIView(frame: snapshot.bounds)
snapshotContainer.addSubview(snapshot)
return UITargetedPreview(view: snapshotContainer, parameters: parameters, target: target)

And with that, only the link text is visible in the preview animation and it expands nicely into the full preview:

But, there’s still one small detail as keen-eyed readers may have noticed. In Safari, when dismissing the full preview, it animates back into the preview view and springs back to the original position. With our implementation, however, it doesn’t. The preview view controller does animate back into the preview view, however, instead of returning to the original position, it disappears off into the middle of the screen. This is because there’s still one UIContextMenuInteractionDelegate method we need to implement: contextMenuInteraction(_:previewForDismissingMenuWithConfiguration:). Similar to the previewForHighlighting method, this method takes the interaction and the context menu configuration, creating a UITargetedPreview that should be used during the dismissal animation. Since we want the preview to go back to the same location while dismissing as it came from while expanding, we can cache the targeted preview we’ve already constructed for the highlight method and return it from the dismissal method.

private weak var activeTargetedPreview: UITargetedPreview?

func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
    // ...
    let preview = UITargetedPreview(view: snapshotContainer, parameters: parameters, target: target)
    self.activeTargetedPreview = preview
    return preview
}

func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
    return self.activeTargetedPreview
}

Now, when dismissing the preview, it animates back into the link text where it originally came from:


  1. I still miss popping into view controllers. RIP. ↩︎

  2. Indeed, when I attempted exactly this, there was some attribute I couldn’t find (even through diffing the internal descriptions of each text view) that was altering the layout of the preview copy. ↩︎

  3. Or, if you really wanted, you could look at my fairly primitive solution. In a nutshell: it constructs a path by starting at the top left corner of the top-most rect, walks down the left side of the rects stair-stepping as necessary when the left bound changes, accross the bottom, back up the right side, and then finally accross the top. ↩︎

Comments

Comments powered by ActivityPub. To respond to this post or to another comment, copy its URL into the search interface of your client for Mastodon, Pleroma, or other compatible software. Learn more.