jbrennan
jbrennan

Reputation: 12003

Control spacing around custom text attributes in NSLayoutManager

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);
    }
}

Upvotes: 16

Views: 2251

Answers (2)

Itai Ferber
Itai Ferber

Reputation: 29928

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

Upvotes: 4

Peer Benderson
Peer Benderson

Reputation: 59

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!

Upvotes: 5

Related Questions