TextKit custom truncation

TextKit has the option to truncate the last line of text if there’s more text than will fit in the container. It does this by displaying an ellipsis. But what if you wanted to display something else to indicate truncation? (In my case, the string “See More”, which will function like a button.)

I tried and discounted a bunch of different approaches before coming up with something that worked. Let me take you on the journey.

Candidate 1: modify the NSAttributedString

This approach is described in this StackOverflow answer. To summarise:

  1. Make sure a first pass of TextKit layout has been done

  2. Find the last line fragment

  3. Find the pixel width of the desired string (the “See More”). Use this combined with the glyph positioning information available from NSLayoutManager to find which character we have to cut off after while still having room to display our string.

  4. In our NSTextStorage, replace everything from that character index onwards with our truncation string.

(The StackOverflow answers seemed to derive the cut off point by repeatedly re-rendering the entire text and seeing when it line wrapped, i.e. the desired height increased. This can be greatly optimised by looking at glyph positions and doing maths.)

Why didn’t I use this one? Simply because I’m writing a framework that so far assumes the entire NSTextStorage maps directly to my data model. I wanted the truncation to be a display-time thing, which doesn’t affect the NSTextStorage at all. Other people may not have an architecture that mandates that, so this approach may work for them.

Candidate 2: glyph substitution

Adapted from this Apple article, this approach essentially requires:

  1. Override the layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:) method from NSLayoutManagerDelegate.

  2. Find the glyph index of the truncation point, possibly by using a similar method as above, working out the width necessary for the replacement and finding the glyph at that index in the correct line fragment.

  3. Replace that glyph with glyph(s) of your own. (Note that they’ll still map to whatever character was next in your original backing string.)

  4. Replace the next glyph after that one with a control glyph that is whitespace. Make the whitespace glyph expand to fill up the rest of the line. (This is in case some small glyph tries to sneak in after your truncation text.)

  5. If the whitespace glyph did cause a glyph to overflow from the end, that glyph will have formed a new line. Move that new line out of the text container to hide it.

I truly thought this approach would work for me, but it had one big flaw: the glyph(s) you put in are mapped to characters in your original string. There’s no danger that there won’t be enough characters, since if there weren’t then you wouldn’t need truncation. But the problem is the attributes used to draw your glyphs are pulled from the attributes present at that location in your original string. I wanted to make my See More text bold, and that’s not possible with this approach.

Candidate 3: line fragment rect modification in NSLayoutManagerDelegate

I tried to use the shouldSetLineFragmentRect method in NSLayoutManagerDelegate to reduce the size of the last line fragment rect. Then I would draw the truncation string using string drawing.

This approach looked really promising, apart from one thing: although shouldSetLineFragmentRect’s documentation seems to say you can modify the line fragment rect, this appears not to be true for changing its size. I was not able to shrink the rect. It was my hope that shrinking the rect would re-run the glyph layout to see which glyphs could fit into the rect, but this appears to not be the case: the glyphs still drew outside my newly shrunken rect.

Final approach: subclass NSTextContainer

I ended up using a slightly modified version of candidate 3. Here I’ll talk you through it.

Cache the vertical position of the last line fragment rect

I was already doing an initial layout pass (with a larger height) during the height calculation code for my view. My view had a target height, and then it would find the last line that would fit completely into that target height, and set the view’s actual height to the bottom edge of that line. (This ensured no half-cut-off lines.)

I just added caching of the position of that last line, so that I could refer to it during TextKit layout.

public class MyCustomTextKitView: UIView {

  ...

  @objc public func setTextContainerSize(forWidth width: CGFloat, targetHeight: CGFloat) {
    // 1. set text container to the size with maximum height
    textContainer.size = CGSize(width: width - textContainerInsets.left - textContainerInsets.right, height: 1000000)

    // 2. get line fragment that contains the target height
    var previousLineFragmentRect: CGRect = CGRect.zero
    let targetTextContainerHeight = targetHeight - textContainerInsets.top - textContainerInsets.bottom

    layoutManager.ensureLayout(for: textContainer)
    layoutManager.enumerateLineFragments(forGlyphRange: layoutManager.glyphRange(for: textContainer)) { currentLineFragmentRect, usedRect, inTextContainer, glyphRange, stopPointer in
      // Check if target height was inside this line
      if currentLineFragmentRect.maxY > targetHeight {
        stopPointer.initialize(to: true)
        return
      }
      previousLineFragmentRect = currentLineFragmentRect
    }

    let prevLineFragmentMaxY = previousLineFragmentRect.maxY
    var targetTextContainerSize = CGSize.zero

    // 3. set text container size and cache the height of last line fragment rect
    targetTextContainerSize = CGSize(width: width - textContainerInsets.left - textContainerInsets.right, height: prevLineFragmentMaxY + textContainerInsets.top + textContainerInsets.bottom)

    textContainer.size = targetTextContainerSize
    layoutManager.activeTruncationMode = .truncateLine(previousLineFragmentRect) // this variable is in my custom subclass of NSLayoutManager
  }
}

Subclass NSTextContainer to modify the size of the last line fragment rect

public class TextContainer: NSTextContainer {

  override public func lineFragmentRect(
    forProposedRect proposedRect: CGRect,
    at characterIndex: Int,
    writingDirection baseWritingDirection: NSWritingDirection,
    remaining remainingRect: UnsafeMutablePointer<CGRect>?
  ) -> CGRect {
    var lineFragmentRect = super.lineFragmentRect(forProposedRect: proposedRect,
                                      at: characterIndex,
                                      writingDirection: baseWritingDirection,
                                      remaining: remainingRect)

    guard let layoutManager = layoutManager as? LayoutManager,
          case let .truncateLine(desiredTruncationLine) = layoutManager.activeTruncationMode,
          let truncationString = layoutManager.customTruncationString
    else {
      return lineFragmentRect
    }

    // check if we're looking at the last line
    guard lineFragmentRect.minX == desiredTruncationLine.minX else {
      return lineFragmentRect
    }

    // we have a match, and should truncate. Shrink the line by enough room to display our truncation string.
    let truncationAttributes = layoutManager.editor?.getTheme().truncationIndicatorAttributes ?? [:]
    let truncationAttributedString = NSAttributedString(string: truncationString, attributes: truncationAttributes)

    // assuming we don't make the line fragment rect bigger in order to fit the truncation string
    let requiredRect = truncationAttributedString.boundingRect(with: lineFragmentRect.size, options: .usesLineFragmentOrigin, context: nil)

    let spacing = 6.0 // TODO: derive this somehow

    // make the change
    lineFragmentRect.size.width = min(lineFragmentRect.width, size.width - (requiredRect.width + spacing))

    return lineFragmentRect
  }

}

Calculate the location to draw the string in the NSLayoutManagerDelegate

It turned out I had to use both the NSTextContainer method and this one. In the NSTextContainer method above, we shortened the line fragment rect for the last line, to the largest possible size it could be while still accommodating our custom truncation string. But we don’t yet know how much of that line has been used. For example, if it’s the last line of a paragraph, and only one word is on the line, then only a small amount of this possible horizontal width will have been used. (But we’d still need truncation because there may be subsequent paragraphs.)

Enter NSLayoutManagerDelegate. Here we find a method that gives us the data we need. So in this method, we position where we are going to draw our string, and cache the calculated value, ready to draw it later.

class LayoutManagerDelegate: NSObject, NSLayoutManagerDelegate {

  func layoutManager(
    _ layoutManager: NSLayoutManager,
    shouldSetLineFragmentRect lineFragmentRectPointer: UnsafeMutablePointer<CGRect>,
    lineFragmentUsedRect lineFragmentUsedRectPointer: UnsafeMutablePointer<CGRect>,
    baselineOffset: UnsafeMutablePointer<CGFloat>,
    in textContainer: NSTextContainer,
    forGlyphRange glyphRange: NSRange
  ) -> Bool {
    guard let layoutManager = layoutManager as? LayoutManager,
            case let .truncateLine(desiredTruncationLine) = layoutManager.activeTruncationMode,
            let truncationString = layoutManager.customTruncationString
    else {
      return false
    }

    let lineFragmentRect: CGRect = lineFragmentRectPointer.pointee
    let lineFragmentUsedRect: CGRect = lineFragmentUsedRectPointer.pointee

    // check if we're looking at the last line
    guard lineFragmentRect.minX == desiredTruncationLine.minX else {
      return false
    }

    // I should really refactor this code out, as it's used both here and in the TextContainer.
    let truncationAttributes = ...
    let truncationAttributedString = NSAttributedString(string: truncationString, attributes: truncationAttributes)
    let requiredRect = truncationAttributedString.boundingRect(with: lineFragmentRect.size, options: .usesLineFragmentOrigin, context: nil)
    let spacing = 6.0 // TODO: derive this somehow

    // Derive the rect for drawing our custom string, based on the lineFragmentUsedRect, and cache it on the layout manager.
    layoutManager.customTruncationDrawingRect = CGRect(x: lineFragmentUsedRect.width + spacing,
                                                       y: lineFragmentUsedRect.minY + (lineFragmentUsedRect.height - requiredRect.height),
                                                       width: requiredRect.width,
                                                       height: requiredRect.height)

    // we didn't change anything so always return false
    return false
  }

}

Do the drawing in our NSLayoutManager subclass

We’ve now adjusted the line fragment rect so that TextKit will leave a blank space for us. We’ve calculated the rect in which we want to draw our custom string. Now we need to actually draw it. Here’s how.

internal enum ActiveTruncationMode {
  case noTruncation
  case truncateLine(CGRect) // the rect is the pre-calculated last line fragment rect
}

public class LayoutManager: NSLayoutManager {
  public var customTruncationString: String? = "See More"
  internal var activeTruncationMode: ActiveTruncationMode = .noTruncation
  internal var customTruncationDrawingRect: CGRect?

  override public func drawGlyphs(forGlyphRange drawingGlyphRange: NSRange, at origin: CGPoint) {
    super.drawGlyphs(forGlyphRange: drawingGlyphRange, at: origin)
    drawCustomTruncationIfNeeded(forGlyphRange: drawingGlyphRange, at: origin)
  }

  private func drawCustomTruncationIfNeeded(forGlyphRange drawingGlyphRange: NSRange, at origin: CGPoint) {
    guard let customTruncationString = customTruncationString,
            let customTruncationDrawingRect = customTruncationDrawingRect,
            let attributes = ... else { return }

    let modifiedDrawingRect = customTruncationDrawingRect.offsetBy(dx: origin.x, dy: origin.y)
    let attributedString = NSAttributedString(string: customTruncationString, attributes: attributes)
    attributedString.draw(in: modifiedDrawingRect)
  }

}

And that’s that! All together, this code handles truncation in just the way I wanted.

One other possible approach: exclusion paths

I only thought of this one after building my working approach. But I might be able to sidestep the manipulation of the line fragment rect by means of setting an exclusion path on the NSTextContainer, in the bottom right corner, the size of my truncation string. I’d still have to locate the drawing of the truncation string by looking at the used rect of the last line fragment, but I could possibly skip the subclassing of NSTextStorage that way. However I’m going to stick with this approach. For my needs, there may be other things I need to do to line fragments in the future, so having this NSTextStorage subclass is already beneficial.

What’s next?

I haven’t documented detecting taps on the truncation string. (The “See More” is designed to be a button.)

Additionally, this is currently only designed to be used with a read-only custom TextKit renderer (i.e. my custom UIView subclass that owns a TextKit stack, and tells the LayoutManager to draw in the right place). That is to say, we’re not using UITextView here. It would be interesting to look at adapting this approach to editable text with UITextView.

Next
Next

A Guinea Pig Castle