shadowfacts.net/site/posts/2022-07-31-textkit-2.md

114 lines
7.3 KiB
Markdown
Raw Permalink Normal View History

2022-07-31 19:18:01 +00:00
```
metadata.title = "Adopting TextKit 2"
metadata.tags = ["swift"]
metadata.date = "2022-07-31 15:31:42 -0700"
metadata.shortDesc = ""
metadata.slug = "textkit-2"
```
With iOS 16, Apple switched on TextKit 2 for UITextViews. But, if you access any of the TextKit 1 objects on the text view, it will automatically fall back to a compatibility mode. [All of the work I did](/2020/uipreviewparameters-textlinerects/) to mimic Safari's link context menu animation was, of course, using the TextKit 1 APIs, so it was blocking me from fully adopting TextKit 2. So, here's how to update that code.
<!-- excerpt-end -->
The first part of my implementation that needed to change is how I get which link is being tapped. I have a function called `getLinkAtPoint(_:)` that takes a CGPoint in the coordinate space of the view and tries to find the link at that point. To update it, almost the entire old body of the function is wrapped in an if statement that checks if TextKit 2 is available:
```swift
func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? {
let pointInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
if #available(iOS 16.0, *),
let textLayoutManager = self.textLayoutManager {
// ...
} else {
// ...
}
}
```
Note that I fall back to the TextKit 1 path if the app's not running on iOS 16 _or_ the `NSTextLayoutManager` is not available. Even on iOS 16, a text view may still fall back to TextKit 1 if the old API is used—in which case, the TextKit 2 stack may be swapped out for TextKit 1. In my testing this can happen occasionally even if you're never using the TextKit 1 API yourself, meaning something in the framework is accessing it (though this may simply be a beta bug).
When TextKit 2 is available, there are several steps we need to go through to get the attributes at a point.
First, we get the text layout fragment that the layout manager has for the point in the coordinate space of the text container. The documentation is sparse, but in my testing, layout fragments correspond to paragraphs.
```swift
guard let fragment = textLayoutManager.textLayoutFragment(for: pointInTextContainer) else {
return nil
}
```
From there, we get the line fragment (corresponding to a visual line of text) that contains our point. To get the line fragment, there is no builtin helper method, so we just go through the layout fragment's `textLineFragments` array until we find the one that matches. For each line fragment, we check if its typographic bounds contain the target point converted to the layout fragment's coordinate space.
```swift
let pointInLayoutFragment = CGPoint(x: pointInTextContainer.x - fragment.layoutFragmentFrame.minX, y: pointInTextContainer.y - fragment.layoutFragmentFrame.minY)
guard let let lineFragment = fragment.textLineFragments.first(where: { lineFragment in
lineFragment.typographicBounds.contains(pointInLayoutFragment)
}) else {
return nil
}
```
If there's no matching layout or line fragment, that means the given location is on a piece of text and therefore there's no link, so the method returns `nil`.
After that, we can get the tapped character index by using the `characterIndex(for:)` method with the target point converted to the line fragment's coordinate space.
```swift
let pointInLineFragment = CGPoint(x: pointInLayoutFragment.x - lineFragment.typographicBounds.minX, y: pointInLayoutFragment.y - lineFragment.typographicBounds.minY)
let charIndex = lineFragment.characterIndex(for: pointInLineFragment)
```
And then we can use the line fragment's attributed string to lookup the attribute:
```swift
var range = NSRange()
guard let link = lineFragment.attributedString.attribute(.link, at: charIndex, longestEffectiveRange: &range, in: lineFragment.attributedString.fullRange) as? URL else {
return nil
}
let textLayoutFragmentStart = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: fragment.rangeInElement.location)
let rangeInSelf = NSRange(location: range.location + textLayoutFragmentStart, length: range.length)
return (link, rangeInSelf)
```
One important thing to note is that the line fragment's `attributedString` property is an entirely separate string from the text view's atttributed string. So the return value of `characterIndex` and the longest effective range have indices into the _substring_. The rest of my code expects the return value to be a range in the index-space of the full string, so I need to convert it by adding the offset between the beginning of the document and the beginning of the line fragment's substring.
For the legacy TextKit 1 path, I use the `characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:)` method on the layout manager to get the character index and then look up the attribute at that location. I won't go into detail in that code here, since it's more straightforward—and lots of other examples can be found online.
Next up: context menu previews. The vast majority of the code is unchanged, all that needs to be done is changing how we get the rects spanned by a range in the text.
In the `contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:)` method, rather than always using the TextKit 1 API, we again check if TextKit 2 is available, and if so, use that:
```swift
var textLineRects = [CGRect]()
if #available(iOS 16.0),
let textLayoutManager = self.textLayoutManager {
let contentManager = textLayoutManager.contentManager!
guard let startLoc = contentManager.location(contentManager.documentRange.location, offsetBy: range.location),
let endLoc = contentManager.location(startLoc, offsetBy: range.length),
let textRange = NSTextRange(location: startLoc, end: endLoc) else {
return nil
}
textLayoutManager.enumerateTextSegments(in: textRange, type: .standard, options: .rangeNotRequired) { _, rect, _, _ in
textLineRects.append(rect)
return true
}
} else {
let notFoundRange = NSRange(location: NSNotFound, length: 0)
self.layoutManager.enumerateEnclosingRects(forGlyphRange: linkRange,
withinSelectedGlyphRange: notFoundRange,
in: self.textContainer) { (rect, stop) in
textLineRects.append(rect)
}
}
```
In the TextKit 2 path, we get the `NSTextContentManager` and force-unwrap it (as far as I can tell, this is never `nil` and is only optional because it's weak).
We use the content manager to convert the `NSRange` of the link that's being previewed into an `NSTextRange` (a range of `NSTextLocation`s, which are opaque objects that represent locations in the document however the content manager sees fit). I'm not sure in what circumstances these calls could fail, but if any of them do, we return `nil` and let the framework use the default preview.
With that, we can call `enumerateTextSegments` to get the bounding rectangles of the text segments. Since these are in the coordinate space of the text layout manager, there's nothing further we need to do, so we can just add them to the `textLineRects` array. And from the block, we return true to continue enumerating. One minor thing to note is that we can pass the `.rangeNotRequired` option to tell the framework to skip calculating text ranges for every segment since we don't need them.
From there, the code is exactly the same as last time.
And with those changes in place, I can use my app without any warnings about text views falling back to TextKit 1 and the accompanying visual artifacts.