diff --git a/Tusker/Extensions/UIBezierPath+Helpers.swift b/Tusker/Extensions/UIBezierPath+Helpers.swift index cf7023122d..a471fbf297 100644 --- a/Tusker/Extensions/UIBezierPath+Helpers.swift +++ b/Tusker/Extensions/UIBezierPath+Helpers.swift @@ -16,6 +16,12 @@ extension UIBezierPath { /// and draws a line around the outer borders of the combined shape. convenience init(wrappingAround rects: [CGRect]) { precondition(rects.count > 0) + + if rects.count == 1 { + self.init(rect: rects.first!) + return + } + let rects = rects.sorted { $0.minY < $1.minY } self.init() diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 23bef986ac..81a405fde3 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -22,6 +22,11 @@ class ContentTextView: LinkTextView { var defaultFont: UIFont = .systemFont(ofSize: 17) var defaultColor: UIColor = .label + // The link range currently being previewed + private var currentPreviewedLinkRange: NSRange? + // The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing. + private weak var currentTargetedPreview: UITargetedPreview? + override func awakeFromNib() { super.awakeFromNib() @@ -256,11 +261,8 @@ 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) - } + // Store the previewed link range for use in the previewForHighlighting method + currentPreviewedLinkRange = range let preview: UIContextMenuContentPreviewProvider = { self.getViewController(forLink: link, inRange: range) @@ -278,66 +280,53 @@ extension ContentTextView: UIContextMenuInteractionDelegate { return UIMenu(title: "", image: nil, identifier: nil, options: [], children: 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 + return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions) } else { + currentPreviewedLinkRange = nil 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 { + // If there isn't a link range, use the default system-generated preview. + guard let range = currentPreviewedLinkRange else { + return nil + } + currentPreviewedLinkRange = nil + + // 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) + } + + // Try to create a snapshot view of this view to disply as the preview. + // If a snapshot view cannot be created, we bail and use the system-provided preview. + guard let snapshot = self.snapshotView(afterScreenUpdates: false) else { return nil } + // Mask the snapshot layer to only show the text of the link, and nothing else. + // By default, the system-applied mask is too wide and other content may seep in. + let path = UIBezierPath(wrappingAround: rects) + let maskLayer = CAShapeLayer() + maskLayer.path = path.cgPath + snapshot.layer.mask = maskLayer + + // The preview parameters describe how the preview view is shown inside the preview. + let parameters = UIPreviewParameters(textLineRects: rects as [NSValue]) + // 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 + var minX: CGFloat = .greatestFiniteMagnitude, maxX: CGFloat = -.greatestFiniteMagnitude, minY: CGFloat = .greatestFiniteMagnitude, maxY: CGFloat = -.greatestFiniteMagnitude 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]) - - // Mask the snapshot layer to only show the text of the link, and nothing else. - // By default, the system-applied mask is too wide and other content may seep in. - let path = UIBezierPath(wrappingAround: rectsInCoordinateSpaceOfEnclosingRect) - let maskLayer = CAShapeLayer() - maskLayer.path = path.cgPath - snapshot.layer.mask = maskLayer - // 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) + let rectsCenter = CGPoint(x: (minX + maxX) / 2, y: (minY + maxY) / 2) // The preview target describes how the preview is positioned. let target = UIPreviewTarget(container: self, center: rectsCenter) @@ -347,7 +336,14 @@ extension ContentTextView: UIContextMenuInteractionDelegate { let snapshotContainer = UIView(frame: snapshot.bounds) snapshotContainer.addSubview(snapshot) - return UITargetedPreview(view: snapshotContainer, parameters: parameters, target: target) + let preview = UITargetedPreview(view: snapshotContainer, parameters: parameters, target: target) + currentTargetedPreview = preview + return preview + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + // Use the same preview for dismissing as was used for highlighting, so that the link animates back to the original position. + return currentTargetedPreview } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { @@ -358,10 +354,4 @@ 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]? - } }