How can we performantly scroll to a target location using TextKit 2?
Hi everyone,
I'm building a custom text editor using TextKit 2 and would like to scroll to a target location efficiently. For instance, I would like to move to the end of a document seamlessly, similar to how users can do in standard text editors by using CMD + Down.
Background:
NSTextView and TextEdit on macOS can navigate to the end of large documents in milliseconds. However, after reading the documentation and experimenting with various ideas using TextKit 2's APIs, it's not clear how third-party developers are supposed to achieve this.
My Code:
Here's the code I use to move the selection to the end of the document and scroll the viewport to reveal the selection.
override func moveToEndOfDocument(_ sender: Any?) {
textLayoutManager.ensureLayout(for: textLayoutManager.documentRange)
let targetLocation = textLayoutManager.documentRange.endLocation
let beforeTargetLocation = textLayoutManager.location(targetLocation, offsetBy: -1)!
textLayoutManager.textViewportLayoutController.layoutViewport()
guard let textLayoutFragment = textLayoutManager.textLayoutFragment(for: beforeTargetLocation) else {
return
}
guard let textLineFragment = textLayoutFragment.textLineFragment(for: targetLocation, isUpstreamAffinity: true) else {
return
}
let lineFrame = textLayoutFragment.layoutFragmentFrame
let lineFragmentFrame = textLineFragment.typographicBounds.offsetBy(dx: 0, dy: lineFrame.minY)
scrollToVisible(lineFragmentFrame)
}
While this code works as intended, it is very inefficient because ensureLayout(_:)
is incredibly expensive and can take seconds for large documents.
Issues Encountered:
In my attempts, I have come across the following two issues.
- Estimated Frames: The frames of NSTextLayoutFragment and NSTextLineFragment are approximate and not precise enough for scrolling unless the text layout fragment has been fully laid out.
- Laying out all text is expensive: The frames become accurate once NSTextLayoutManager's
ensureLayout(for:)
method has been called with a range covering the entire document. However,ensureLayout(for:)
is resource-intensive and can take seconds for large documents. NSTextView, on the other hand, accomplishes the same scrolling to the end of a document in milliseconds.
I've tried using NSTextViewportLayoutController's relocateViewport(to:)
without success. It's unclear to me whether this function is intended for a use case like mine. If it is, I would appreciate some guidance on its proper usage.
Configuration:
I'm testing on macOS Sonoma 14.5 (23F79), Swift (AppKit), Xcode 15.4 (15F31d).
I'm working on a multi-platform project written in AppKit and UIKit, so I'm looking for either a single solution that works in both AppKit and UIKit or two solutions, one for each UI framework.
Question:
How can third-party developers scroll to a target location, specifically the end of a document, performantly using TextKit 2?
Steps to Reproduce:
The issue can be reproduced using the example project (download from link below) by following these steps:
- Open the example project.
- Run the example app on a Mac. The example app shows an uneditable text view in a scroll view. The text view displays a long text.
- Press the "Move to End of Document" toolbar item.
- Notice that the text view has scrolled to the bottom, but this took several seconds (~3 seconds on my MacBook Pro 16-inch, 2021). The duration will be shown in Xcode's log.
You can open the ExampleTextView.swift file and find the implementation of moveToEndOfDocument(_:).
Comment out line 84 where the ensureLayout(_:)
is called, rerun the app, and then select "Move to End of Document" again. This time, you will notice that the text view moves fast but does not end up at the bottom of the document.
You may also open the large-file.json in the project, the same file that the example app displays, in TextEdit, and press CMD+Down to move to the end of the document. Notice that TextEdit does this in mere milliseconds.
Example Project:
The example project is located on GitHub:
Any advice or guidance on how to achieve this with TextKit 2 would be greatly appreciated.
Thanks in advance!
Best regards,
Simon
It is as-designed that the position of a text layout fragment (NSTextLayoutFragment
) is dynamic before the text is fully laid out – That is the viewport-based layout in TextKit2 all about.
To scroll to a certain text range in a TextKit2 view, I'd consider the following flow:
- Figure out the text range that should be displayed on the screen after scrolling.
- Ensure the layout of the text range.
- Retrieve the position of the text layout fragment of the text range.
- Align the viewport to the position.
Here, step 2 only lays out the target text range, and the position retrieved at step 3 is still an estimated value, but because the target text range has been laid out (step 2), and the viewport is aligned to the target text (step 4), the view should display the target text correctly.
Concretely in your case, to move the text to the end of a document, I tried the following way:
a. Retrieve the current estimated position of the end location of the document, then scroll to the position, and trigger a viewport laying out:
override func moveToEndOfDocument(_ sender: Any?) {
var lastLayoutFragment: NSTextLayoutFragment!
textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.endLocation,
options: [.reverse, .ensuresLayout]) { layoutFragment in
lastLayoutFragment = layoutFragment
return false
}
let lastLineMaxY = lastLayoutFragment.layoutFragmentFrame.maxY
scroll(CGPoint(x: bounds.minX, y: lastLineMaxY))
textLayoutManager.textViewportLayoutController.layoutViewport()
}
b. Adjust the viewport position after the viewport is laid out:
func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
updateContentSizeIfNeeded()
alignViewportToDocumentEndLocationIfNeeded()
}
func updateContentSizeIfNeeded() {
let currentHeight = bounds.height
var height: CGFloat = 0
textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.endLocation,
options: [.reverse, .ensuresLayout]) { layoutFragment in
height = layoutFragment.layoutFragmentFrame.maxY
return false
}
height = max(height, enclosingScrollView?.contentSize.height ?? 0)
if abs(currentHeight - height) > 1e-10 {
let contentSize = NSSize(width: bounds.width, height: height)
setFrameSize(contentSize)
}
}
// Align the view port if needed when scrolling to the bottom.
func alignViewportToDocumentEndLocationIfNeeded() {
guard let scrollView = enclosingScrollView, let documentView = scrollView.documentView else {
return
}
// If the text view size alreay euqals to the document size, no adjustment is needed.
guard abs(scrollView.contentView.bounds.maxY - documentView.bounds.maxY) < 1 else {
return
}
// If the the end of document is already in the view port, no adjustment is needed.
let viewportLayoutController = textLayoutManager.textViewportLayoutController
let viewportEndTextLocation = viewportLayoutController.viewportRange!.endLocation
let documentEndTextLocation = textLayoutManager.documentRange.endLocation
guard let textRange = NSTextRange(location: viewportEndTextLocation, end: documentEndTextLocation),
!textRange.isEmpty else {
return
}
// Ensure the delta text range is laid out.
textLayoutManager.ensureLayout(for: textRange)
var lastLineMaxY = viewportLayoutController.viewportBounds.maxY
textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.endLocation,
options: [.reverse, .ensuresLayout]) { layoutFragment in
lastLineMaxY = layoutFragment.layoutFragmentFrame.maxY
return false //Stop.
}
let offset = lastLineMaxY - visibleRect.maxY
if offset > 1 {
viewportLayoutController.adjustViewport(byVerticalOffset: 0.0 - offset)
}
}
Above, the frame size of the view is updated, which ensures that view height is consistent with the current estimated document height every time the viewport is laid out.
You can give it a try and let me know if that works on your side as well.
The way to align the viewport to the document's end location is pretty much the same as how the following sample adjusts the viewport to the document's beginning location and please look into the sample for more details:
Best,
——
Ziqiao Chen
Worldwide Developer Relations.