How can we performantly scroll to a target location using TextKit 2?

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:

  1. Open the example project.
  2. 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.
  3. Press the "Move to End of Document" toolbar item.
  4. 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:

https://github.com/simonbs/apple-developer-forums/tree/main/how-can-we-performantly-scroll-to-a-target-location-using-textkit-2

Any advice or guidance on how to achieve this with TextKit 2 would be greatly appreciated.

Thanks in advance!

Best regards,

Simon

Answered by DTS Engineer in 799653022

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:

  1. Figure out the text range that should be displayed on the screen after scrolling.
  2. Ensure the layout of the text range.
  3. Retrieve the position of the text layout fragment of the text range.
  4. 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.

A hacky way:

Tested on a MacBook Mid 2014; Scrolling takes approximately 0.001 seconds.

var counter = 0

override func moveToEndOfDocument(_ sender: Any?) {

	let measureStartDate = Date()
	resetScrollSettings()
	scrollTextView()
	print("⏰ scrolling took \(Date().timeIntervalSince(measureStartDate))s")

}

func resetScrollSettings() {
	NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(scrollTextView), object: nil)
	counter = 0
}

@objc func scrollTextView() {

	/// Change this number to ensure that all necessary recursions are executed
	let magicNumber = 50

	if counter > magicNumber {
		return
	} else {
		counter += 1
	}

	let range = NSTextRange(location: textLayoutManager.documentRange.endLocation)
	textLayoutManager.ensureLayout(for: range)

	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)

	perform(#selector(scrollTextView), with: nil, afterDelay: 0.001)
}

The approach presented by hassan313 is similar to one I'm experimenting with using the code below. Here's a brief overview of what the code does:

  1. Calls ensureLayout(_:) but with an empty text range containing only the target location, i.e., the end of the document. This is near instant.
  2. Determines the frame of the target text line fragment.
  3. Scrolls the NSScrollView to make the frame of the target text line fragment visible.
  4. Recursively performs the same steps from step 1 until the frame of the target text line fragment remains unchanged, at which point we're no longer getting an estimated frame.

The code can be generalized to scroll to any location in the document, but for the purpose of this example, it scrolls to the end of the document only.

While this works, it feels hacky to keep scrolling until the frame of the text line fragment stabilizes. I'm thinking there must be a better approach, and I would appreciate if someone from Apple can shed some light on how this is done in NSTextView.

override func moveToEndOfDocument(_ sender: Any?) {
    moveToEndOfDocument(previousTextLineFragmentFrame: .null)
}

private func moveToEndOfDocument(previousTextLineFragmentFrame: CGRect) {
    let targetLocation = textLayoutManager.documentRange.endLocation
    let beforeTargetLocation = textLayoutManager.location(targetLocation, offsetBy: -1)!
    let textRange = NSTextRange(location: targetLocation, end: targetLocation)!
    textLayoutManager.ensureLayout(for: textRange)
    textLayoutManager.textViewportLayoutController.layoutViewport()
    guard let textLayoutFragment = textLayoutManager.textLayoutFragment(for: beforeTargetLocation) else {
        return
    }
     guard let textLineFragment = textLayoutFragment.textLineFragment(for: targetLocation, isUpstreamAffinity: true) else {
        return
    }
    let textLayoutFragmentFrame = textLayoutFragment.layoutFragmentFrame
    let textLineFragmentFrame = textLineFragment.typographicBounds.offsetBy(dx: 0, dy: textLayoutFragmentFrame.minY)
    guard textLineFragmentFrame != previousTextLineFragmentFrame else {
        // Scrolling did not affect the layout of the text line fragment, so we do not need to scroll again.
        return
    }
    scrollToVisible(textLineFragmentFrame)
    moveToEndOfDocument(previousTextLineFragmentFrame: textLineFragmentFrame)
}

This new approach will simplify your original code, allowing you to move to a specific end location.

Note: Efficiency depends on how far the target end location is from the start of the document.

    override func moveToEndOfDocument(_ sender: Any?) {
        moveToEndLocation(textLayoutManager.documentRange.endLocation)
    }
    
    func moveToEndLocation(_ targetEndLocation: NSTextLocation) {
        var lineFragmentFrame: CGRect = .null
        textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.location, options: [.ensuresLayout]) { layoutFragment in
            if layoutFragment.rangeInElement.endLocation.compare(targetEndLocation) != .orderedAscending {
                lineFragmentFrame = layoutFragment.layoutFragmentFrame
                return false
            }
            return true
        }
        
        textLayoutManager.textViewportLayoutController.layoutViewport()
        self.scrollToVisible(lineFragmentFrame)
    }

@hassan313 Thank you for providing an alternative approach. However, based on my testing, this approach is not performant and does not guarantee scrolling to the target location.

Accepted Answer

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:

  1. Figure out the text range that should be displayed on the screen after scrolling.
  2. Ensure the layout of the text range.
  3. Retrieve the position of the text layout fragment of the text range.
  4. 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.

@DTS Engineer

thank you for your suggestion. I tried that approach now (and before) and it looks like calling enumerateTextLayoutFragments yields approximate/estimate layout positions.

Specifically, calling the suggested routine multiple times returns different values:

    var lastLayoutFragment: NSTextLayoutFragment!
    textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.endLocation,
                                                    options: [.reverse, .ensuresLayout]) { layoutFragment in
        lastLayoutFragment = layoutFragment
        return false
    }
    let lastLineMaxY = lastLayoutFragment.layoutFragmentFrame.maxY
    print(lastLineMaxY)

in my test case case result with these values:

  1. 4302376.62016702

  2. 4092451.6843738933

  3. 3979224.5583475474

  4. 4565598.13343745

  5. 4332115.927268179

  6. 4331192.884374112

  7. 4331192.884374112

  8. 4331192.884374112

  9. 4331192.884374112

  10. 4331192.884374112

as we can see, the value eventually get "stable" and that is actuall height of the document. The initial values jumps (estimate) up and down without the pattern.

That behavior is aligned with values reported by usageBoundsForTextContainer and is a root of many issues.

In the example you provided is not different, hence I'd like to ask how does it suppose to work with estimated layout values?

as we can see, the value eventually get "stable" and that is actuall height of the document.

This is correct and as-designed – The viewport-based layout in TextKit2 doesn't require that the document is fully laid out; it just needs that the part of text to be displayed on screen is laid out, and that is the way it achieves a better scrolling performance.

In the example you provided is not different, hence I'd like to ask how does it suppose to work with estimated layout values?

That difference is that I adjust the viewport position so the text at end of the document is displayed in the current visible rect (NSView.visibleRect). You can see the difference by applying the code to the example project @simonbs provided in the initial post.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

That difference is that I adjust the viewport position so the text at end of the document is displayed in the current visible rect (NSView.visibleRect).

thank you, let me summarize the approach:

  1. receive an estimated (too big or too small) content height
  2. update content size with the estimated height
  3. move viewport to the end of that height (either it is final or estimated)
  4. layout end of the document there

do you think that approach translate well to any location in the middle of the document? What about the scrollview scrollbar that depends on the total content Size and updates as layoutmanager update its size, do you have any trick for that?

  1. move viewport to the end of that height (either it is final or estimated)
  2. layout end of the document there

Step #3 and #4 should be swapped – You want to make sure that the end of the document is already laid out before moving the view port there.

do you think that approach translate well to any location in the middle of the document?

Yes.

What about the scrollview scrollbar that depends on the total content Size and updates as layoutmanager update its size, do you have any trick for that?

I am not quite clear what the real question is here, but the implementation I provided adjusts the frame size of the text view using the current estimated content size every time the viewport is laid out (textViewportLayoutControllerDidLayout), and so the scrollbar does reflect the total estimated content size and update as text layout manager lays out more text elements.

If you need to have the scrollbar reflect the total actual content size (the document size after the document is fully laid out), I don't see any way to achieve that, unless you ensure the layout of the whole document, which is what we would avoid in this case.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

@DTS Engineer hanks for your thorough answer. I'm testing the approach in my project, and while it mostly works, I've run into one issue.

Occasionally, after scrolling the text view, viewportLayoutController.viewportRange ends up being nil, which causes the app to crash since the property is force unwrapped. I know I could use guard let or if let to prevent the crash, but that would prevent me from continuing the layout process.

Do you know what might cause viewportRange of NSTextViewportLayoutController to be nil, and how I can ensure it always has a value?

EDIT: I think I've solved this. It seems to be simply due to a pending layout. Calling viewportLayoutController.layoutViewport() prior to accessing viewportRange cause it to have a value. Now, I'm unsure why there's a pending layout since alignViewportToDocumentEndLocationIfNeeded() is called from textViewportLayoutControllerDidLayout(_:) at which point a layout was just completed.

How can we performantly scroll to a target location using TextKit 2?
 
 
Q