TextKit 2 calls NSTextLayoutFragment's draw method too often

The following is verbatim of a feedback report (FB19809442) I submitted, shared here as someone else might be interested to see it (I hate the fact that we can't see each other's feedbacks).


On iOS 16, TextKit 2 calls NSTextLayoutFragment's draw(at:in:) method once for the first paragraph, but for every other paragraph, it calls it continuously on every scroll step in the UITextView. (The first paragraph is not cached; its draw is called again when it is about to be displayed again, but then it is again called only once per its lifecycle.)

On iOS 17, the behavior is similar; the draw method gets called once for the 1st and 2nd paragraph, and for every other paragraph it again gets called continuously as a user scrolls a UITextView.

On iOS 18 (and iOS 26 beta 4), TextKit 2 calls the layout fragment's draw(at:in:) on every scroll step in the UITextView, for all paragraphs. This results in terrible performance.

TextKit 2 is promised to bring many performance benefits by utilizing the viewport - a new concept that represents the visible area of a text view, along with a small overscroll. However, having the draw method being constantly called almost negates all the performance benefits that viewport brings. Imagine what could happen if someone needs to add just a bit of logic to that draw method. FPS drops significantly and UX is terribly degraded.

I tried optimizing this by only rendering those text line fragments which are in the viewport, by using NSTextViewportLayoutController.viewportBounds and converting NSTextLineFragment.typographicBounds to the viewport-relative coordinate space (i.e. the coordinate space of the UITextView itself). However, this patch only works on iOS 18 where the draw method is called too many times, as the viewport changes. (I may have some other problems in my implementation, but I gave up on improving those, as this can't work reliably on all OS versions since the underlying framework isn't calling the method consistently.)

Is this expected? What are our options for improving performance in these areas?

Answered by DTS Engineer in 855446022

The behavior should be that only the fragments that fall in the view port + estimated overdraw region are re-drawn, and that should be true for all system versions.

Thanks for creating the demo project for me. I've tried your project with my Xcode 26 Beta 6 + iOS 26 Beta 7 + iPhone 16 Plus, and don't see that the system re-draws all the paragraph. Here is what I do:

a. Use the following print instead to better observe the fragments that are re-drawn:

print("draw called in \(rangeInElement)")

b. Add more paragraphs to textContents so the content is longer.

With that, here is what I see:

  1. Launch the app. Xcode shows the following log, indicating that only the first fragment is drawn.
draw called in 0...1531
(Happens one time)
  1. Scroll a bit. Xcode shows the following log, indicating that only the first and second fragments are drawn:
draw called in 0...1531
draw called in 1532...3237
... (Repeats many times.)
  1. Scroll down to the bottom, clear the log, and then scroll a bit. Xcode shows the following log, indicating that only the last and second last fragments are drawn:
draw called in 11362...12656
draw called in 12657...13951
... (Repeats many times.)

So it doesn't seem that the system redraws all the paragraphs in the above cases.

The log repeats a lot of times in step 2 and 3, meaning the relevant fragments are drawn a lot of times, but that's part of the drawing process of updating the whole view port.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

On iOS 18 (and iOS 26 beta 4), TextKit 2 calls the layout fragment's draw(at:in:) on every scroll step in the UITextView, for all paragraphs. This results in terrible performance. ... Is this expected?

No, this is definitely not expected. I am curious how you confirmed the behavior though. Do you have a test project that unveils the issue to share?

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Hello @DTS Engineer,

No, this is definitely not expected

Since you answer was specifically quoting iOS 18 and iOS 26 beta 4, what about the older iOS versions? If the behavior expected on those versions?

I am curious how you confirmed the behavior though.

I have confirmed this behavior by returning a custom NSTextLayoutFragment subclass in NSTextLayoutManager's delegate method textLayoutManager(_:textLayoutFragmentFor:in:). The layout fragment subclass then executes a print statement in its draw(at:in:) method override.

Do you have a test project that unveils the issue to share?

I just created a demo project for you. I've attached it to my feedback report, but I've also uploaded it to GitHub: https://github.com/galijot/NSTextLayoutFragment-PerformanceIssues

I've faced slightly different results today on iOS 17 in this demo project, where just the 2nd paragraph's draw(at:in:) would be called on every scroll step, and other paragraphs would be rendered once per its appearance in the viewport. iOS 18 still calls it on each scroll step, on all paragraphs.

The behavior should be that only the fragments that fall in the view port + estimated overdraw region are re-drawn, and that should be true for all system versions.

Thanks for creating the demo project for me. I've tried your project with my Xcode 26 Beta 6 + iOS 26 Beta 7 + iPhone 16 Plus, and don't see that the system re-draws all the paragraph. Here is what I do:

a. Use the following print instead to better observe the fragments that are re-drawn:

print("draw called in \(rangeInElement)")

b. Add more paragraphs to textContents so the content is longer.

With that, here is what I see:

  1. Launch the app. Xcode shows the following log, indicating that only the first fragment is drawn.
draw called in 0...1531
(Happens one time)
  1. Scroll a bit. Xcode shows the following log, indicating that only the first and second fragments are drawn:
draw called in 0...1531
draw called in 1532...3237
... (Repeats many times.)
  1. Scroll down to the bottom, clear the log, and then scroll a bit. Xcode shows the following log, indicating that only the last and second last fragments are drawn:
draw called in 11362...12656
draw called in 12657...13951
... (Repeats many times.)

So it doesn't seem that the system redraws all the paragraphs in the above cases.

The log repeats a lot of times in step 2 and 3, meaning the relevant fragments are drawn a lot of times, but that's part of the drawing process of updating the whole view port.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

@DTS Engineer thank you for confirming the behavior on other system versions.

Upon reading your response, I realized how my original post could be misinterpreted.

I haven't meant that draw(at:in:) gets called for all paragraphs that exist in the document at all times during scrolling. It is correct that TextKit 2 only renders those paragraphs that are in the viewport. However, for those paragraphs that are in the viewport, the draw method gets called many times ("on every scroll step in the UITextView").

That is what you indicated with:

... (Repeats many times.)

in your logs above.

This seems incorrect because the behavior is different between last couple iOS versions, and between the paragraphs in the same text (or even between texts, it seems).

For example, when I test this demo app on iOS 17 (iPhone 13 Pro), the draw method was called only once for every paragraph as they were entering the viewport, and the draw method for those was called next time when the paragraph would leave and then enter the viewport again. Only for the 2nd paragraph was the draw method called on every scroll step while it is in the viewport.

Interestingly, if I edit textContents (from my demo app) to only have a single paragraph whose length is ~9k characters, I have a single paragraph whose draw is called exactly once. I can scroll through the text as much as I want, and draw is never called again.

This makes me thing that draw could be called only once (since text is rendered without any issues), while on the other hand you say:

relevant fragments are drawn a lot of times, but that's part of the drawing process of updating the whole view port.

So I'm not sure what's correct. Though based on CPU spikes I'm facing, I must say this seems strange.

Thanks for your clarification. I do observe that the draw method of the text layout fragments in the view port are called many times when scrolling the text.

I am unclear how much that slows down the performance though. If you observe a performance regression in your real-world app, it will be something worth an investigation. To avoid misunderstanding, I'd suggest that you add comments in your feedback report to emphasize the performance regression in your app, and also to be clear that it is the fragments in the view port being re-drawn many times, and not that the ones outside of the view port being re-drawn unnecessarily.

The view re-draw process is driven by the UI framework, and so there is nothing you can do at the TextKit level.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thank you for clarifying @DTS Engineer. I have submitted more information and clarified some misunderstandings in my feedback report.

This info is especially useful to know:

The view re-draw process is driven by the UI framework, and so there is nothing you can do at the TextKit level.

Though, since I'm in the default UITextView zone, I'm not sure I can do much to optimize drawing here. I'm hoping to get some more info from Apple that could help me avoid redundant drawing, as I'd rather avoid building custom text views.

As for performance regressions in my app: I have already analyzed the CPU usage and optimized it quite a bit. This process was performed in several stages, last of which was to try and optimize by utilizing the viewport, and while working on that I have figured the draw is called many times. (Just for reference, the initial implementation I had was resulting in about 10-15 FPS while scrolling, and the code I used wasn't that bad; it was just called way too often.)

The logic I have in the draw(at:in:) method override is very optimized now, but as you can imagine, even the most optimized variant could run slowly when called many times, especially for bigger paragraphs.

TextKit 2 calls NSTextLayoutFragment's draw method too often
 
 
Q