diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index f313c1ce..01079327 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -27,6 +27,9 @@ class ContentTextView: LinkTextView { delegate = self + // Disable layer masking, otherwise the context menu opening animation + // may be clipped if it's at an edge of the text view + layer.masksToBounds = false addInteraction(UIContextMenuInteraction(delegate: self)) textDragInteraction?.isEnabled = false @@ -253,6 +256,12 @@ extension ContentTextView: MenuPreviewProvider { extension ContentTextView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { if let (link, range) = getLinkAtPoint(location) { + // Determine the line rects that the link takes up in the coordinate space of this view + var rects = [CGRect]() + layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { (rect, stop) in + rects.append(rect) + } + let preview: UIContextMenuContentPreviewProvider = { self.getViewController(forLink: link, inRange: range) } @@ -268,11 +277,68 @@ extension ContentTextView: UIContextMenuInteractionDelegate { } return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) } - return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions) + + // Use a custom UIContentMenuConfiguration subclass to pass the text line rect information + // to the `contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:)` method. + let configuration = ContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions) + configuration.textLineRects = rects + return configuration } else { return nil } } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + // If there isn't custom text line rect data, use the default system-generated preview. + guard let config = configuration as? ContextMenuConfiguration, + let rects = config.textLineRects, + rects.count > 0 else { + return nil + } + + // Calculate the smallest rect enclosing all of the text line rects, in the coordinate space of this view. + 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 rectEnclosingTextLineRects = CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + + // Try to create a snapshot view of this view that only shows the minimum + // rectangle necessary to fully display the link text (reduces the likelihood that + // other text will be displayed alongside it). + // If a snapshot view cannot be created, we bail and use the system-provided preview. + guard let snapshot = self.resizableSnapshotView(from: rectEnclosingTextLineRects, afterScreenUpdates: false, withCapInsets: .zero) else { + return nil + } + + // Convert the textLineRects from the context menu configuration to be in the + // coordinate space of the snapshot view. The snapshot view is created from + // rectEnclosingTextLineRects, which means that, while its size is the same as the + // enclosing rect, its coordinate space is relative to this text views by rectEnclosingTextLineRects.origin. + // Since the text line rects passed to UIPreviewParameters need to be in the coordinate space of + // the preview view, we subtract the origin position from each rect to convert to the snapshot view's + // coordinate space. + let rectsInCoordinateSpaceOfEnclosingRect = rects.map { + $0.offsetBy(dx: -rectEnclosingTextLineRects.minX, dy: -rectEnclosingTextLineRects.minY) + } + + // The preview parameters describe how the preview view is shown inside the prev. + let parameters = UIPreviewParameters(textLineRects: rectsInCoordinateSpaceOfEnclosingRect as [NSValue]) + // todo: parameters.visiblePath around text + + // The center point of the the minimum enclosing rect in our coordinate space is the point where the + // center of the preview should be, since that's also in this view's coordinate space. + let rectsCenter = CGPoint(x: rectEnclosingTextLineRects.midX, y: rectEnclosingTextLineRects.midY) + + // The preview target describes how the preview is positioned. + let target = UIPreviewTarget(container: self, center: rectsCenter) + + return UITargetedPreview(view: snapshot, parameters: parameters, target: target) + } + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let viewController = animator.previewViewController { animator.preferredCommitStyle = .pop @@ -281,4 +347,10 @@ extension ContentTextView: UIContextMenuInteractionDelegate { } } } + + /// Used to pass text line rect data between `contextMenuInteraction(_:configurationForMenuAtLocation:)` and `contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:)` + fileprivate class ContextMenuConfiguration: UIContextMenuConfiguration { + /// The line rects of the source of this context menu configuration in the coordinate space of the preview target view. + var textLineRects: [CGRect]? + } }