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:
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
How did we do? We’d love to know your thoughts on this year’s conference. Take the survey here
TextKit
RSS for tagManage text storage and perform custom layout of text-based content in your app's views using TextKit.
Posts under TextKit tag
55 Posts
Sort by:
Post
Replies
Boosts
Views
Activity
demo code :
- (void)drawRect:(CGRect)rect {
CGContextRef context = UIGraphicsGetCurrentContext();
// Flip the coordinate system
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
NSDictionary *attrs = @{NSFontAttributeName: [UIFont systemFontOfSize:20],
NSForegroundColorAttributeName: [UIColor blueColor],
NSUnderlineStyleAttributeName: @(NSUnderlineStyleThick),
};
// Make an attributed string
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:@"Hello CoreText!" attributes:attrs];
CFAttributedStringRef attributedStringRef = (__bridge CFAttributedStringRef)attributedString;
// Simple CoreText with CTFrameDraw
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString(attributedStringRef);
CGPathRef path = CGPathCreateWithRect(self.bounds,NULL);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0, 0),path,NULL);
//CTFrameDraw(frame, context);
// You can comment the line 'CTFrameDraw' and use the following lines
// draw with CTLineDraw
CFArrayRef lines = CTFrameGetLines(frame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), lineOrigins);
for (int i = 0; i < CFArrayGetCount(lines); i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CGContextSetTextPosition(context, lineOrigins[i].x, lineOrigins[i].y);
// CTLineDraw(line, context);
// You can comment the line 'CTLineDraw' and use the following lines
// draw with CTRunDraw
// use CTRunDraw will lost some attributes like NSUnderlineStyleAttributeName,
// so you need draw it by yourself
CFArrayRef runs = CTLineGetGlyphRuns(line);
for (int j = 0; j < CFArrayGetCount(runs); j++) {
CTRunRef run = CFArrayGetValueAtIndex(runs, j);
CTRunDraw(run, context, CFRangeMake(0, 0));
}
}
}
this code will use CTRunDraw to draw the content , and the underline will draw and show normally in iOS17 & Xcode 15 , But when you build it with XCode16 & iOS18 beta . the underline will be missing .
Hi Apple,
I'm implementing a custom text view by conforming to UITextInput and backing it with TextKit2.
However, I like the UITextItem feature of the default UITextView. Can I get some guidance on how to reimplement it?
Are we looking at overlaying UIMenu buttons? Or some API where I can display a UIMenu at a rect I specify? Hopefully, it is not some kind of private API?
Thanks for the help in advance.
I can render text from TextKit2 into a PDF everything is fine.
But in this case the font is embedded into the PDF.
I need the Pdf to contains only the paths / glyphs and not font.
I can't find a solution yet. I don't want to create an image or using UIViews etc.
It would be nice to get the bezier path of the text
I have done this with TextKit1 but the glyphs are gone with TextKit2
Can anyone help me ?
Thanks :)
Based on the session content, it seems that setting the TextView property writingToolsBehavior = .complete should bring up the writing tools bottom panel view. However, it does not appear to be working. Is this a feature that will be added in a future update, or is there something additional I need to do?
Test on:
XCode 16.0 beta (16A5171c),
iOS Simulator 18.0 Beta,
iPhone 11 Pro iOS 18.0 Beta
I have an NSTextLayoutManager set up with NSTextContentStorage and NSTextContainer. To work out the height of the content, I call the method updateContentSizeIfNeeded() which contains the code
textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.endLocation, options: [.reverse, .ensuresLayout]) { layoutFragment in
height = layoutFragment.layoutFragmentFrame.maxY
return false
}
The first time this is called, it returns the correct height.
Then I add a new character to the start of the NSTextContentStorage like so
textContentStorage.performEditingTransaction {
storage.replaceCharacters(in: NSRange(location:0, length: 1), with: "a")
}
textLayoutManager.ensureLayout(for: textLayoutManager.documentRange)
textLayoutManager.textViewportLayoutController.layoutViewport()
updateContentSizeIfNeeded()
This time, the height returned is ~600px too big. The state of the NSTextLayoutFragment is set to layoutAvailable
The next time I add a character to textContentStorage using the same code above, the height returned is correct again.
I can work around this by calling enumerateTextLayoutFragments from the start of the document and not in reverse, then ignoring all fragments except the last one, but I don't know if that's the correct way to do it, or if I should be doing something else
How do you get the cursor to appear programmatically in a custom UITextInput with UITextInteraction?
I have created a custom input field by conforming to UITextInput. It is setup to use UITextInteraction. Everything works very well. If the user taps on the custom field, the cursor (provided by UITextInteraction) appears. The user can type, select, move the cursor, etc.
But I'm stumped trying to get the cursor to appear automatically. With a normal UITextField or UITextView you simply call becomeFirstResponder(). But doing that with my custom UITextInput does not result in the cursor appearing. It only appears if the user taps on the custom field.
I don't know what I'm missing. I don't see any API in UITextInteraction that can be called to say "activate the cursor layer".
Does anyone know what steps are required with a custom UITextInput using UITextInteraction to activate the cursor programmatically without the user needing to tap on the custom field?
In TextKit 1 we have the method NSTextStorage.addLayoutManager(_:) that allows to show the same text in multiple text views. This method exists with NSLayoutManager but not with NsTextLayoutManager.
Is there a way to achieve the same thing with TextKit 2?
With my continued experiments with TextKit2, I'm trying to figure out what sets the properties paragraphSeparatorRange and paragraphContentRange on a NSTextParagraph. These seem to be computed properties (or at least they cannot be directly set via public API).
var paragraph = NSTextParagraph(attributedString: NSAttributedString(string: "It was the best of times.\n"))
print("attributes: \(paragraph.attributedString.attributes(at: 0, effectiveRange: nil))")
print("paragraphSeparatorRange: \(String(describing: paragraph.paragraphSeparatorRange))")
print("paragraphContentRange: \(String(describing: paragraph.paragraphContentRange))")
In the above example, both paragraphSeparatorRange and paragraphContentRange are nil.
However, when using NSTextLayoutManager/NSTextContentStorage they are set.
let layoutManager = NSTextLayoutManager()
let container = NSTextContainer(size: NSSize(width: 400, height: 400))
layoutManager.textContainer = container
let contentStorage = NSTextContentStorage()
contentStorage.textStorage = NSTextStorage(string: "It was the best of times.\n")
contentStorage.addTextLayoutManager(layoutManager)
layoutManager.enumerateTextLayoutFragments(from: contentStorage.documentRange.location, options: .ensuresLayout) { textLayoutFragment in
print("layoutFragment: \(textLayoutFragment)")
print("textElement: \(String(describing: textLayoutFragment.textElement))")
print("textElement.range: \(String(describing: textLayoutFragment.textElement?.elementRange))")
let paragraph = textLayoutFragment.textElement as! NSTextParagraph
print("paragraphContentRange: \(String(describing: paragraph.paragraphContentRange))")
print("paragraphSeparatorRange: \(String(describing: paragraph.paragraphSeparatorRange))")
return true
}
Would appreciate any ideas on how these values are computed/set. Thanks
This is a tricky one. I have a shipping product which, when compiled under Xcode 14.3.1 works as expected on Sonoma.
If the same project is recompiled with Xcode 15, the subclass of NSTextView will not display correctly (text is same color as background).
I am also using a custom NSLayoutManager (to draw invisibles).
Unfortunately, there is an intermittent aspect to this. I use this subclass in several places and it works on my setup on the main editor, but not with some customers.
Then I found a different use of the same subclass that does not work for me.
When it does not work, it is consistent for that user.
I have manually marked the textViews as using TextKit 1, with no change.
I also tried the Clips Bounds to yes, again no change.
If I change the class to NSTextView, the text displays properly, but I lose existing functionality.
There appears to be some undocumented behavior change in Xcode 15 (or when linking against Sonoma SDK) that for subclasses of NSTextView (stored in XIB files). I know that there is a push to move toward TextKit 2, but it seems TextKit 1 support was possibly changed as well.
The text is there and I can edit it, double click, copy and paste it, it is just invisible, when compiled with Xcode 15 (also 15.1).
It has to be something very subtle that the subclassed TextView from one XIB will work, but from another XIB will not.
Does anyone have any insight into the potential change with TextKit 1 implementation?
Thanks.
This function on NSTextLayoutManager has the following signature
func enumerateTextSegments(
in textRange: NSTextRange,
type: NSTextLayoutManager.SegmentType,
options: NSTextLayoutManager.SegmentOptions = [],
using block: (NSTextRange?, CGRect, CGFloat, NSTextContainer) -> Bool
)
The documentation here doesn't define what the CGRect and CGFloat passed to block are. However, looking at sample code Using TextKit2 To Interact With Text, they seem to be the frame for the textsegment and baselineposition respectively.
But, the textSegmentFrame seems to start at origin.x = 5.0 when text is empty. Is this some starting offset for text segments? I don't seem to be able to find mention of this anywhere.
Returning false from NSTextContentManagerDelegate.textContentManager(_:shouldEnumerate:options:) produces huge gaps in my layout instead of showing a continuous block of text.
Instead of omiting the layout of the hidden element, there is a blank space that shows that appears to have the same size in layout as the omitted text element.
Why is this happening and how can I prevent this?
Example:
I have an app that uses UITextView for some text editing. I have some custom operations I can do on the text that I want to be able to undo, and I'm representing those operations in a way that plugs into NSUndoManager nicely. For example, if I have a button that appends an emoji to the text, it looks something like this:
func addEmoji() {
let inserting = NSAttributedString(string: "😀")
self.textStorage.append(inserting)
let len = inserting.length
let range = NSRange(location: self.textStorage.length - len, length: len)
self.undoManager?.registerUndo(withTarget: self, handler: { view in
view.textStorage.deleteCharacters(in: range)
}
}
My goal is something like this:
Type some text
Press the emoji button to add the emoji
Trigger undo (via gesture or keyboard shortcut) and the emoji is removed
Trigger undo again and the typing from step 1 is reversed
If I just type and then trigger undo, the typing is reversed as you'd expect. And if I just add the emoji and trigger undo, the emoji is removed. But if I do the sequence above, step 3 works but step 4 doesn't. The emoji is removed but the typing isn't reversed.
Notably, if step 3 only changes attributes of the text, like applying a strikethrough to a selection, then the full undo chain works. I can type, apply strikethrough, undo strikethrough, and undo typing.
It's almost as if changing the text invalidates the undo manager's previous operations?
How do I insert my own changes into UITextView's NSUndoManager without invalidating its chain of other operations?
We are currently trying to adopt the newly introduced find bar in our app.
The app:
The app is a text editor with a main text view. However it includes nested views (for text like footnotes) that are presented as modal sheets. So you tap on the footnote within the main text, a form sheet is presented with the contents of the footnote ready to be edited. We have an existing search implementation, but are eager to move to the system-provided UI. Connecting the find bar through a custom UIFindSession with our existing implementation is working without any issues.
The Problem:
Searching for text does not only work in the main text view, but also nested text (like footnotes). Let's say I have a text containing the word "iPhone" both in the main text and the footnote. In our existing implementation, stepping from the search match to the next one would open the modal and highlight the match in the nested text. The keyboard would stay open.
With the new UIFindInteraction this is not working however. As soon as a modal form sheet is presented, the find interaction closes. By looking at the stack trace I can see a private class called UIInputWindowController that cleans up input accessory views after the modal gets presented. I believe it is causing the find panel to give up its first responder state. I noticed that opening popovers appears to be working fine.
Is there a way to alter the presentation of the nested text so that the view is either not modal or able to preserve the current find session? Or is this unsupported behavior and we should try and look for a different way?
The thing that really confuses me is that this appears to work without issue in Notes.app. There the find bar is implemented as well. There are multiple views that can be presented while the find bar is open. Move Note is one of them. The view appears as a modal sheet. It keeps the find bar open and active, though its tint color matches the deactivated one of the main Notes view. The find bar is still functional with the text field being active and the overlay updating in the background. This behavior appears to be a bug in the Notes app, but is exactly what we want for our use case.
I attached some images: Two are from the Notes app, two from a test project demonstrating the problem. Opening a modal view closes the find bar there.
In TextKit 1, I can override drawBackground(forGlyphRange:at:) in NSLayoutManager to do custom background drawing. However, I'm not too sure what the designated way of doing background drawing is in TextKit 2.
One thing I've tried is to do custom drawing in my own CALayer that's used in the configureRenderingSurface delegate callback, but I'm unsure if we are suppose to use this API and steal the textViewportLayoutController.delegate away from _UITextLayoutcanvasView?