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

7.3 KiB

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 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.

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:

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.

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.

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.

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:

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:

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 NSTextLocations, 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.