开发者

How do you stroke the _outside_ of an NSAttributedString?

I've been using NSStrokeWidthAttributeName on NSAttributedString objects to put an outline around text as it's drawn. The problem is that the stroke is inside the fill area of the text. When the text is small (e.g. 1 pixel thick), the stroking makes the text hard to read. What I really want is a stroke on the outside. Is there any way to do that?

开发者_如何学Go

I've tried an NSShadow with no offset and a blur, but it's too blurry and hard to see. If there was a way to increase the size of the shadow without any blur, that would work too.


While there may be other ways, one way to accomplish this is to first draw the string with only a stroke, then draw the string with only a fill, directly overtop of what was previously drawn. (Adobe InDesign actually has this built-in, where it will appear to only apply the stroke to the outside of letter, which helps with readability).

This is just an example view that shows how to accomplish this (inspired by http://developer.apple.com/library/mac/#qa/qa2008/qa1531.html):

First set up the attributes:

@implementation MDInDesignTextView

static NSMutableDictionary *regularAttributes = nil;
static NSMutableDictionary *indesignBackgroundAttributes = nil;
static NSMutableDictionary *indesignForegroundAttributes = nil;

- (void)drawRect:(NSRect)frame {
    NSString *string = @"Got stroke?";
    if (regularAttributes == nil) {
        regularAttributes = [[NSMutableDictionary
    dictionaryWithObjectsAndKeys:
        [NSFont systemFontOfSize:64.0],NSFontAttributeName,
        [NSColor whiteColor],NSForegroundColorAttributeName,
        [NSNumber numberWithFloat:-5.0],NSStrokeWidthAttributeName,
        [NSColor blackColor],NSStrokeColorAttributeName, nil] retain];
    }

    if (indesignBackgroundAttributes == nil) {
        indesignBackgroundAttributes = [[NSMutableDictionary
        dictionaryWithObjectsAndKeys:
        [NSFont systemFontOfSize:64.0],NSFontAttributeName,
        [NSNumber numberWithFloat:-5.0],NSStrokeWidthAttributeName,
        [NSColor blackColor],NSStrokeColorAttributeName, nil] retain];
    }

    if (indesignForegroundAttributes == nil) {
        indesignForegroundAttributes = [[NSMutableDictionary
        dictionaryWithObjectsAndKeys:
        [NSFont systemFontOfSize:64.0],NSFontAttributeName,
        [NSColor whiteColor],NSForegroundColorAttributeName, nil] retain];
    }

    [[NSColor grayColor] set];
    [NSBezierPath fillRect:frame];

    // draw top string
    [string drawAtPoint:
        NSMakePoint(frame.origin.x + 200.0, frame.origin.y + 200.0)
        withAttributes:regularAttributes];

    // draw bottom string in two passes
    [string drawAtPoint:
        NSMakePoint(frame.origin.x + 200.0, frame.origin.y + 140.0)
        withAttributes:indesignBackgroundAttributes];
    [string drawAtPoint:
        NSMakePoint(frame.origin.x + 200.0, frame.origin.y + 140.0)
        withAttributes:indesignForegroundAttributes];
}

@end

This produces the following output:

How do you stroke the _outside_ of an NSAttributedString?

How do you stroke the _outside_ of an NSAttributedString?

Now, it's not perfect, since the glyphs will sometimes fall on fractional boundaries, but, it certainly looks better than the default.

If performance is an issue, you could always look into dropping down to a slightly lower level, such as CoreGraphics or CoreText.


Just leave here my solution based on answer of @NSGod, result is pretty the same just having proper positioning inside UILabel

It is also useful when having bugs on iOS 14 when stroking letters with default system font (refer also this question)

Bug:

How do you stroke the _outside_ of an NSAttributedString?

@interface StrokedTextLabel : UILabel
@end

/**
 * https://stackoverflow.com/a/4468880/3004003
 */
@implementation StrokedTextLabel

- (void)drawTextInRect:(CGRect)rect
{
    if (!self.attributedText) {
        [super drawTextInRect:rect];
        return;
    }

    NSMutableAttributedString *attributedText = self.attributedText.mutableCopy;
    [attributedText enumerateAttributesInRange:NSMakeRange(0, attributedText.length) options:0 usingBlock:^(NSDictionary<NSAttributedStringKey, id> *attrs, NSRange range, BOOL *stop) {
        if (attrs[NSStrokeWidthAttributeName]) {
            // 1. draw underlying stroked string
            // use doubled stroke width to simulate outer border, because border is being stroked
            // in both outer & inner directions with half width
            CGFloat strokeWidth = [attrs[NSStrokeWidthAttributeName] floatValue] * 2;
            [attributedText addAttributes:@{NSStrokeWidthAttributeName : @(strokeWidth)} range:range];
            self.attributedText = attributedText;
            // perform default drawing
            [super drawTextInRect:rect];

            // 2. draw unstroked string above
            NSMutableParagraphStyle *style = [NSMutableParagraphStyle new];
            style.alignment = self.textAlignment;

            [attributedText addAttributes:@{
                NSStrokeWidthAttributeName : @(0),
                NSForegroundColorAttributeName : self.textColor,
                NSFontAttributeName : self.font,
                NSParagraphStyleAttributeName : style
            } range:range];

            // we use here custom bounding rect detection method instead of
            // [attributedText boundingRectWithSize:...] because the latter gives incorrect result
            // in this case
            CGRect textRect = [self boundingRectWithAttributedString:attributedText forCharacterRange:NSMakeRange(0, attributedText.length)];
            [attributedText boundingRectWithSize:rect.size options:NSStringDrawingUsesLineFragmentOrigin
                                         context:nil];
            // adjust vertical position because returned bounding rect has zero origin
            textRect.origin.y = (rect.size.height - textRect.size.height) / 2;
            [attributedText drawInRect:textRect];
        }
    }];
}

/**
 * https://stackoverflow.com/a/20633388/3004003
 */
- (CGRect)boundingRectWithAttributedString:(NSAttributedString *)attributedString
                         forCharacterRange:(NSRange)range
{
    NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString];
    NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
    [textStorage addLayoutManager:layoutManager];
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:[self bounds].size];
    textContainer.lineFragmentPadding = 0;
    [layoutManager addTextContainer:textContainer];

    NSRange glyphRange;

    // Convert the range for glyphs.
    [layoutManager characterRangeForGlyphRange:range actualGlyphRange:&glyphRange];

    return [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer];
}

@end

Swift version

import Foundation
import UIKit

/// https://stackoverflow.com/a/4468880/3004003
@objc(MUIStrokedTextLabel)
public class StrokedTextLabel : UILabel {

    override public func drawText(in rect: CGRect) {

        guard let attributedText = attributedText?.mutableCopy() as? NSMutableAttributedString else {
            super.drawText(in: rect)
            return
        }

        attributedText.enumerateAttributes(in: NSRange(location: 0, length: attributedText.length), options: [], using: { attrs, range, stop in
            guard let strokeWidth = attrs[NSAttributedString.Key.strokeWidth] as? CGFloat else {
                return
            }

            // 1. draw underlying stroked string
            // use doubled stroke width to simulate outer border, because border is being stroked
            // in both outer & inner directions with half width
            attributedText.addAttributes([
                NSAttributedString.Key.strokeWidth: strokeWidth * 2
            ], range: range)
            self.attributedText = attributedText
            // perform default drawing
            super.drawText(in: rect)

            // 2. draw unstroked string above
            let style = NSMutableParagraphStyle()
            style.alignment = textAlignment

            let attributes = [
                NSAttributedString.Key.strokeWidth: NSNumber(value: 0),
                NSAttributedString.Key.foregroundColor: textColor ?? UIColor.black,
                NSAttributedString.Key.font: font ?? UIFont.systemFont(ofSize: 17),
                NSAttributedString.Key.paragraphStyle: style
            ]

            attributedText.addAttributes(attributes, range: range)

            // we use here custom bounding rect detection method instead of
            // [attributedText boundingRectWithSize:...] because the latter gives incorrect result
            // in this case
            var textRect = boundingRect(with: attributedText, forCharacterRange: NSRange(location: 0, length: attributedText.length))
            attributedText.boundingRect(
                    with: rect.size,
                    options: .usesLineFragmentOrigin,
                    context: nil)
            // adjust vertical position because returned bounding rect has zero origin
            textRect.origin.y = (rect.size.height - textRect.size.height) / 2
            attributedText.draw(in: textRect)
        })
    }

    /// https://stackoverflow.com/a/20633388/3004003
    private func boundingRect(
            with attributedString: NSAttributedString?,
            forCharacterRange range: NSRange
    ) -> CGRect {
        guard let attributedString = attributedString else {
            return .zero
        }
        let textStorage = NSTextStorage(attributedString: attributedString)
        let layoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        let textContainer = NSTextContainer(size: bounds.size)
        textContainer.lineFragmentPadding = 0
        layoutManager.addTextContainer(textContainer)

        var glyphRange = NSRange()

        // Convert the range for glyphs.
        layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)

        return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    }

}
0

上一篇:

下一篇:

精彩评论

暂无评论...
验证码 换一张
取 消

最新问答

问答排行榜