14

I’ve got a custom NSLayoutManager subclass I’m using to draw pill-shaped tokens. I draw these tokens for substrings with a custom attribute (TokenAttribute). I can draw no problem.

However, I need to add a little bit of “padding” around the ranges with my TokenAttribute (so that the round rectangle background of the token won’t intersect with the text).

enter image description here

In the above image, I’m drawing my token’s background with an orange colour, but I want extra padding around 469 so the background isn’t right up against the text.

I’m not really sure how to do this. I tried overriding -boundingRectForGlyphRange:inTextContainer: to return a bounding rect with more horizontal padding, but it appears the layout of glyphs isn’t actually affected by this.

How do I give more spacing around certain glyphs / ranges of glyphs?


Here’s the code I use to draw the background, in my layout manager subclass:

- (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin {

    NSTextStorage *textStorage = self.textStorage;
    NSRange glyphRange = glyphsToShow;

    while (glyphRange.length > 0) {

        NSRange characterRange = [self characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
        NSRange attributeCharacterRange;
        NSRange attributeGlyphRange;

        id attribute = [textStorage attribute:LAYScrubbableParameterAttributeName 
                                      atIndex:characterRange.location 
                        longestEffectiveRange:&attributeCharacterRange 
                                      inRange:characterRange];

        attributeGlyphRange = [self glyphRangeForCharacterRange:attributeCharacterRange 
                                           actualCharacterRange:NULL];
        attributeGlyphRange = NSIntersectionRange(attributeGlyphRange, glyphRange);

        if (attribute != nil) {
            CGContextRef context = UIGraphicsGetCurrentContext();
            CGContextSaveGState(context);

            UIColor *backgroundColor = [UIColor orangeColor];
            NSTextContainer *textContainer = self.textContainers[0];
            CGRect boundingRect = [self boundingRectForGlyphRange:attributeGlyphRange inTextContainer:textContainer];

            // Offset this bounding rect by the `origin` passed in above
            // `origin` is the origin of the text container!
            // if we don't do this, then bounding rect is incorrectly placed (too high, in my case).
            boundingRect.origin.x += origin.x;
            boundingRect.origin.y += origin.y;

            [backgroundColor setFill];
            UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:boundingRect cornerRadius:boundingRect.size.height / 2.0];
            [path fill];

            [super drawGlyphsForGlyphRange:attributeGlyphRange atPoint:origin];
            CGContextRestoreGState(context);

        } else {
            [super drawGlyphsForGlyphRange:glyphsToShow atPoint:origin];
        }

        glyphRange.length = NSMaxRange(glyphRange) - NSMaxRange(attributeGlyphRange);
        glyphRange.location = NSMaxRange(attributeGlyphRange);
    }
}
jbrennan
  • 11,943
  • 14
  • 73
  • 115
  • I'm no guru but I've had my head down in TextKit for a little bit. What are you using to draw that background? Can you show a little code? – Moshe Dec 29 '15 at 03:44
  • Why can't you modify `boundingRect` and make it a little larger? The rect shouldn't get clipped, so if you add a little more padding it should work. – Moshe Dec 29 '15 at 04:19
  • @Moshe I can modify `boundingRect` and it draws bigger as expected, but the layout of the text is unaffected, so the tokens can bump in to other text, too :\ which is something I want to avoid. – jbrennan Dec 29 '15 at 04:21
  • 1
    See if overriding `lineFragmentRectForProposedRect:atIndex:writingDirection:remainingRect:` in a custom text container will do the trick. It looks like your layout manager consults the text container for that boundingRect, and that might be the right place to modify it. – Moshe Dec 29 '15 at 04:35
  • 2
    @Moshe amazing! This is exactly what I've been looking for! Trying to do this in NSLayoutManager is super difficult. NSTextContainer is great for this. – Sam Soffes Feb 03 '16 at 00:34
  • Wait, that actually worked? – Moshe Feb 03 '16 at 00:36
  • How would we override the method in NSTextContainer to do this? – Matthew Nov 17 '16 at 02:09
  • @jbrennan were you able to figure out a solution? I have played around with `boundingRect` and run up against the same problems as you. I've also toyed with `func setLocation(CGPoint, forStartOfGlyphRange: NSRange)`, and while that shows promise, I am unable to find a reliable way to have padding become cumulative (e.g. if I have multiple substrings with `TokenAttribute`). Part of me wonders if this would require diving into the typesetting engine. – Cory Juhlin May 06 '19 at 19:56
  • @CoryJuhlin were you able to figure this out? I'm having a similar problem. – Sam Corder May 12 '19 at 20:31
  • @SamCorder unfortunately no :( had to table the issue and move on because of deadlines, but I hope to revisit it soon! – Cory Juhlin May 13 '19 at 17:07
  • @SamCorder Have you been able to solve the problem? The way I currently do it is by defining a custom-drawn NSTextAttachment, but that produces an image which is visually fine but not ideal because it cannot be selected. – Gene S May 17 '19 at 12:10
  • @GeneS That is what I ended up doing. I needed to render text on top of various shapes and ended up drawing text on top of the image and attaching that to the attributed string. – Sam Corder May 17 '19 at 19:44
  • Did anyone make any progress on implementing something like this? Overriding `lineFragmentRectForProposedRect:atIndex:writingDirection:remainingRect` allows you to modify the line rectangle, but you only get callbacks for the rect representing the entire line, not the text that you want. Using `setLocation` on a custom typesetter works, but that offsets the relevant text ranger such that it might overlap with other text. Custom NSTextAttachment isn't an option in my case, since I need the user to be able to edit the characters of the token text individually. – Noah Gilmore Sep 26 '19 at 15:46

2 Answers2

5

There are methods defined in NSLayoutManagerDelegate, that serve as glyph-based customisation points.

Use

func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>, properties props: UnsafePointer<NSLayoutManager.GlyphProperty>, characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont, forGlyphRange glyphRange: NSRange) -> Int

to identify the glyphs associated with the whitespace surrounding your range-of-interest and mark those by altering their value in the props array to NSLayoutManager.GlyphProperty.controlCharacter. Then pass this altered array to

NSLayoutManager.setGlyphs(_:properties:characterIndexes:font:forGlyphRange:)

Afterwards, you may implement

func layoutManager(_ layoutManager: NSLayoutManager, shouldUse action: NSLayoutManager.ControlCharacterAction, forControlCharacterAt charIndex: Int) -> NSLayoutManager.ControlCharacterAction

to again identify the glyphs of interest and return the predefined action:

NSLayoutManager.ControlCharacterAction.whitespace

This, at the end, lets you implement

func layoutManager(_ layoutManager: NSLayoutManager, boundingBoxForControlGlyphAt glyphIndex: Int, for textContainer: NSTextContainer, proposedLineFragment proposedRect: NSRect, glyphPosition: NSPoint, characterIndex charIndex: Int) -> NSRect

to alter the bounding box used for the glyphs. Simply return the appropriated dimensions. This will have effect on the following layout-mechanism.

Good luck!

  • This is great! I didn't realize `layoutManager:boundingBoxForControlGlyphAt:...` is only called for `.whitespace` control characters. Thanks! – Sam Soffes May 26 '20 at 22:19
  • +1 for an excellent working solution, with two additions: (1) since the values passed to `layoutManager(_:shouldGenerateGlyphs:...)` are _immutable_, you'll need to copy those buffers to make changes, and (2) keep in mind that setting a glyph property of `.whitespace` for an index will _replace_ that glyph with whitespace; if you want to _insert_ whitespace around a range, you'll need to insert a glyph (value doesn't matter, will be replaced), a contiguous index (can be a copy of the previous), and a property at the location of the insertion. – Itai Ferber Dec 10 '21 at 19:32
  • As for the value to return from `layoutManager(_:boundingBoxForControlGlyphAt:...)`: the `proposedRect` will have the correct origin and height (if you want to draw in-place), but you'll need to decide on a width, and unfortunately it might not be easy to determine the width of a space glyph from within the call. In my specific case, I only need to handle one font size with a specific padding here, but in the general case, you may need to lay out a single space somewhere with the same font as the character at `charIndex` and determine its width. – Itai Ferber Dec 10 '21 at 19:36
  • @ItaiFerber > _keep in mind that setting a glyph property of .whitespace for an index will replace that glyph with whitespace_ Would you consider posting your modified version as a separate answer? _Replacing_ a character with whitespace isn't what OP asked for, as far as I can tell, and isn't what I'm looking for! – cubuspl42 Aug 24 '23 at 10:54
  • @cubuspl42 I've gone ahead and [added an answer](https://stackoverflow.com/a/76972937/169394) with some additional detail — but primarily, heavily-annotated sample code you can look at/adapt. I highly recommend reading through the source code for some possibly-overwhelming levels of detail. – Itai Ferber Aug 24 '23 at 21:08
1

Sample Code

This is a supplementary answer to @PeerBenderson's answer, intending to clarify/reify some of the details required to pull this off. I've put together some heavily-annoted sample code, showing how to create a control that looks like this based on a specific real-world use-case:

A screenshot of a custom text view containing sample text, displaying like a label with a border and background fill. Some of the words inside of the text view are highlighted in a contrasting yellow color with padding and rounded corners rather than straight, tight-fitting rectangles.

The gist of making this work:

Caveats

  • If your tokens can be placed at the very beginning or very end of a line, the approach as described here works best for centered text, rather than text that is flush left or right. This is because glyph generation comes before bounding boxes can be calculated for lines of text, which means that at the point where we have the opportunity to insert whitespace, we can't yet know where line breaks will be. At best, we can insert whitespace at the start and end of a highlighted range, but for ranges that span more than one line, the start of each new line won't have an extra control glyph inserted. Because of this, text is drawn flush to the edges of the text container, which means that backgrounds can only be drawn by bleeding into the margin. This may not be a problem for some use-cases, but for giving the appearance of a token being completely inline with text, this can somewhat break the illusion. Centering the text is more likely to pull it away from the edges of the text container, which alleviates this effect slightly
  • Iterating over lines in fillBackgroundRectArray(...) and filling them in individually assumes that there will be sufficient line spacing between lines for backgrounds to not overlap. For different visual styles (e.g., merging large "blobs" together), a different approach will be necessary in that method
Itai Ferber
  • 28,308
  • 5
  • 77
  • 83