@NSGodの回答に基づいた私の解決策をここに残してください。結果は、内部に適切な位置を設定するだけでかなり同じですUILabel
また、デフォルトのシステム フォントで文字をストロークするときに iOS 14 にバグがある場合にも役立ちます (この質問も参照してください) 。
バグ:

@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
迅速なバージョン
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)
}
}