TextKit

以前,如果我们想实现如上图所示复杂的文本排版:显示不同样式的文本、图片和文字混排,你可能就需要借助于UIWebView或者深入研究一下Core Text。在iOS6中,UILabel、UITextField、UITextView增加了一个NSAttributedString属性,可以稍微解决一些排版问题,但是支持的力度还不够。现在Text Kit完全改变了这种现状。

1.NSAttributedString

下面的例子,展示如何label中显示属性化字符串:

-(void)setAttributeStringLabel{
    NSString *str = @"bold,little color,hello";
    
    //NSMutableAttributedString的初始化
    NSDictionary *attrs = @{NSFontAttributeName:[UIFont preferredFontForTextStyle:UIFontTextStyleBody]};
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc]initWithString:str attributes:attrs];
    
    //NSMutableAttributedString增加属性
    [attributedString addAttribute:NSFontAttributeName value:[UIFont boldSystemFontOfSize:36] range:[str rangeOfString:@"bold"]];
    
    [attributedString addAttribute:NSForegroundColorAttributeName value:[UIColor blueColor] range:[str rangeOfString:@"little color"]];
    
    [attributedString addAttribute:NSFontAttributeName value:[UIFont fontWithName:@"Papyrus" size:36] range:NSMakeRange(18,5)];
    
    [attributedString addAttribute:NSFontAttributeName value:[UIFont boldSystemFontOfSize:18] range:[str rangeOfString:@"little"]];
    
    //NSMutableAttributedString移除属性
    [attributedString removeAttribute:NSFontAttributeName range:[str rangeOfString:@"little"]];
    
    //NSMutableAttributedString设置属性
    NSDictionary *attrs2 = @{NSStrokeWidthAttributeName:@-5,
                             NSStrokeColorAttributeName:[UIColor greenColor],
                             NSFontAttributeName:[UIFont systemFontOfSize:36],
                             NSUnderlineStyleAttributeName:@(NSUnderlineStyleSingle)};
    [attributedString setAttributes:attrs2 range:NSMakeRange(0, 4)];
    
    self.label.attributedText = attributedString;
}

运行结果如下:

需要注意的是,你不能直接修改已有的AttributedString, 你需要把它copy出来,修改后再进行设置:

NSMutableAttributedString *labelText = [myLabel.attributedText mutableCopy]; 
[labelText setAttributes:...];
myLabel.attributedText = labelText;

2.Dynamic type:动态字体

iOS7增加了一项用户偏好设置:动态字体,用户可以通过显示与亮度-文字大小设置面板来修改设备上所有字体的尺寸。为了支持这个特性,意味着不要用systemFontWithSize:,而要用新的字体选择器preferredFontForTextStyle:。iOS提供了六种样式:标题,正文,副标题,脚注,标题1,标题2。例如:

_textView.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];

你可以接收用户改变字体大小的通知:

[[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(preferredContentSizeChanged:) name:UIContentSizeCategoryDidChangeNotification
                                               object:nil];

-(void)preferredContentSizeChanged:(NSNotification *)notification{
    _textView.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
}

3.Exclusion paths:排除路径

iOS 上的 NSTextContainer 提供了exclusionPaths,它允许开发者设置一个 NSBezierPath 数组来指定不可填充文本的区域。如下图:

IMG_0934.PNG

正如你所看到的,所有的文本都放置在蓝色椭圆外面。在 Text View 里面实现这个行为很简单,但是有个小麻烦:Bezier Path 的坐标必须使用容器的坐标系。以下是转换方法,将它的 bounds(self.circleView.bounds)转换到 Text View 的坐标系统:

- (void)updateExclusionPaths
{
    CGRect ovalFrame = [self.textView convertRect:self.circleView.bounds fromView:self.circleView];    
}

因为没有 inset,文本会过于靠近视图边界,所以 UITextView 会在离边界还有几个点的距离的地方插入它的文本容器。因此,要得到以容器坐标表示的路径,必须从 origin 中减去这个插入点的坐标。

ovalFrame.origin.x -= self.textView.textContainerInset.left;
ovalFrame.origin.y -= self.textView.textContainerInset.top;

在此之后,只需将 Bezier Path 设置给 Text Container 即可将对应的区域排除掉。其它的过程对你来说是透明的,TextKit 会自动处理。

self.textView.textContainer.exclusionPaths = @[[UIBezierPath bezierPathWithOvalInRect: ovalFrame]];

4.多容器布局

屏幕快照 2015-03-10 下午2.52.15.png

NSTextStorage:它是NSMutableAttributedString的子类,里面存的是要管理的文本。
NSLayoutManager:管理文本布局方式
NSTextContainer:表示文本要填充的区域

如上图所示,它们的关系是 1 对 N 的关系。就是那样:一个 Text Storage 可以拥有多个 Layout Manager,一个 Layout Manager 也可以拥有多个 Text Container。这些多重性带来了多容器布局的特性:

1)将多个 Layout Manager 附加到同一个 Text Storage 上,可以产生相同文本的多种视觉表现,如果相应的 Text View 可编辑,那么在某个 Text View 上做的所有修改都会马上反映到所有 Text View 上。

    NSTextStorage *sharedTextStorage = self.originalTextView.textStorage;
    [sharedTextStorage replaceCharactersInRange:NSMakeRange(0, 0) withString:kstring];
    
    
    // 将一个新的 Layout Manager 附加到上面的 Text Storage 上
    NSLayoutManager *otherLayoutManager = [NSLayoutManager new];
    [sharedTextStorage addLayoutManager: otherLayoutManager];
    
    NSTextContainer *otherTextContainer = [NSTextContainer new];
    [otherLayoutManager addTextContainer: otherTextContainer];
    
    UITextView *otherTextView = [[UITextView alloc] initWithFrame:self.otherContainerView.bounds textContainer:otherTextContainer];
    otherTextView.backgroundColor = self.otherContainerView.backgroundColor;
    otherTextView.translatesAutoresizingMaskIntoConstraints = YES;
    otherTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    
    otherTextView.scrollEnabled = NO;
    
    [self.otherContainerView addSubview: otherTextView];
    self.otherTextView = otherTextView;

2)将多个 Text Container 附加到同一个 Layout Manager 上,这样可以将一个文本分布到多个视图展现出来。下面的例子将展示这两个特性:

// 将一个新的 Text Container 附加到同一个 Layout Manager,这样可以将一个文本分布到多个视图展现出来。
    NSTextContainer *thirdTextContainer = [NSTextContainer new];
    [otherLayoutManager addTextContainer: thirdTextContainer];
    
    UITextView *thirdTextView = [[UITextView alloc] initWithFrame:self.thirdContainerView.bounds textContainer:thirdTextContainer];
    thirdTextView.backgroundColor = self.thirdContainerView.backgroundColor;
    thirdTextView.translatesAutoresizingMaskIntoConstraints = YES;
    thirdTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    
    [self.thirdContainerView addSubview: thirdTextView];
    self.thirdTextView = thirdTextView;

结果如下所示:

IMG_0935.PNG

5.语法高亮:继承NSTextStorage

看看 TextKit 组件的责任划分,就很清楚语法高亮应该由 Text Storage 实现。不过NSTextStorage 不是一个普通的类,它是一个类簇,你可以把它理解为一个"半具体"子类,因此要继承它必须实现以下方法:

- string;
- attributesAtIndex:effectiveRange:
- replaceCharactersInRange:withString:
- setAttributes:range:

我们新建一个NSTextStorage的子类:SyntaxHighlightTextStorage

要实现以上4个方法,我们首先需要通过NSMutableAttributedString 实现一个后备存储,- setAttributes:range:这个方法需要用beginEditing和endEditing包起来,而且必须调用 edited:range:changeInLength:,所以大部分的NSTextStorage的子类都长下面这个样子:

@implementation SyntaxHighlightTextStorage
{
    NSMutableAttributedString *_backingStore;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
        _backingStore = [NSMutableAttributedString new];
    }
    return self;
}
//1
- (NSString *)string {
    return [_backingStore string];
}
//2
- (NSDictionary *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)range
{
    return [_backingStore attributesAtIndex:location
                             effectiveRange:range];
}
//3
- (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str
{
    NSLog(@"replaceCharactersInRange:%@ withString:%@",NSStringFromRange(range), str);
    [self beginEditing];
    [_backingStore replaceCharactersInRange:range withString:str];
    [self edited:NSTextStorageEditedCharacters | NSTextStorageEditedAttributes range:range changeInLength:str.length - range.length];
    [self endEditing];
}
//4
- (void)setAttributes:(NSDictionary *)attrs range:(NSRange)range {
    NSLog(@"setAttributes:%@ range:%@", attrs, NSStringFromRange(range));
    [self beginEditing];
    [_backingStore setAttributes:attrs range:range];
    [self edited:NSTextStorageEditedAttributes range:range changeInLength:0];
    [self endEditing];
}

一个方便实现高亮的办法是覆盖 -processEditing,并设置一个正则表达式来查找单词,每次文本存储有修改时,这个方法都自动被调用。

- (void)processEditing
{
    [super processEditing];
    static NSRegularExpression *expression;
    expression = expression ?: [NSRegularExpression regularExpressionWithPattern:@"(\\*\\w+(\\s\\w+)*\\*)\\s" options:0 error:NULL];   
}

首先清除之前所有的高亮:

NSRange paragaphRange = [self.string paragraphRangeForRange: self.editedRange];
    [self removeAttribute:NSForegroundColorAttributeName range:paragaphRange];

其次遍历所有的样式匹配项并高亮它们:

[expression enumerateMatchesInString:self.string options:0 range:paragaphRange usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) {
        [self addAttribute:NSForegroundColorAttributeName value:[UIColor blueColor] range:result.range];
    }];

就这样,我们在文本系统栈里面有了一个 Text Storage 的全功能替换版本。在从 Interface 文件中载入时,可以像这样将它插入文本视图:

- (void)createTextView {
    _textStorage = [SyntaxHighlightTextStorage new];
    [_textStorage addLayoutManager: self.textView.layoutManager];
    
    [_textStorage replaceCharactersInRange:NSMakeRange(0, 0) withString:@"在从 Interface 文件中载入时,可以像这样将它插入文本视图,然后加 *星号* 的字就会高亮出来了"];
    _textView.delegate = self;
}

运行如下:

IMG_0936.PNG

6.文本容器修改:继承NSTextContainer

通过继承NSTextContainer,我们可以使得textView不再是一个规规矩矩的矩形。NSTextContainer负责回答这个问题:对于给定的矩形,哪个部分可以放文字,这个问题由下面这个方法来回答:

- (CGRect)lineFragmentRectForProposedRect: atIndex: writingDirection: remainingRect:

所以我们在继承NSTextContainer的类中覆盖这个方法即可:

下面这个方法返回一个圆形区域:

- (CGRect)lineFragmentRectForProposedRect:(CGRect)proposedRect
                                  atIndex:(NSUInteger)characterIndex
                         writingDirection:(NSWritingDirection)baseWritingDirection
                            remainingRect:(CGRect *)remainingRect {

  CGRect rect = [super lineFragmentRectForProposedRect:proposedRect
                                               atIndex:characterIndex
                                      writingDirection:baseWritingDirection
                                         remainingRect:remainingRect];

  CGSize size = [self size];
  CGFloat radius = fmin(size.width, size.height) / 2.0;
  CGFloat ypos = fabs((proposedRect.origin.y + proposedRect.size.height / 2.0) - radius);
  CGFloat width = (ypos < radius) ? 2.0 * sqrt(radius * radius - ypos * ypos) : 0.0;
  CGRect circleRect = CGRectMake(radius - width / 2.0, proposedRect.origin.y, width, proposedRect.size.height);

  return CGRectIntersection(rect, circleRect);
}

使用这个继承类:

- (void)viewDidLoad {
    [super viewDidLoad];
    NSString *path = [[NSBundle mainBundle] pathForResource:@"sample.txt" ofType:nil];
    NSString *string = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
    
    NSMutableParagraphStyle *style = [NSMutableParagraphStyle new];
    [style setAlignment:NSTextAlignmentJustified];
    
    NSTextStorage *text = [[NSTextStorage alloc] initWithString:string
                                                     attributes:@{
                                                                  NSParagraphStyleAttributeName: style,
                                                                  NSFontAttributeName: [UIFont preferredFontForTextStyle:UIFontTextStyleCaption2]
                                                                  }];
    NSLayoutManager *layoutManager = [NSLayoutManager new];
    [text addLayoutManager:layoutManager];
    
    CGRect textViewFrame = CGRectMake(20, 20, 280, 280);
    CircleTextContainer *textContainer = [[CircleTextContainer alloc] initWithSize:textViewFrame.size];
    [textContainer setExclusionPaths:@[ [UIBezierPath bezierPathWithOvalInRect:CGRectMake(80, 120, 50, 50)]]];
    
    [layoutManager addTextContainer:textContainer];
    
    UITextView *textView = [[UITextView alloc] initWithFrame:textViewFrame
                                               textContainer:textContainer];
    textView.allowsEditingTextAttributes = YES;
    textView.scrollEnabled = NO;
    textView.editable = NO;
    
    [self.view addSubview:textView];
}

效果如下:

IMG_0937.PNG

7.布局修改:继承NSLayoutManager

利用NSLayoutManager的代理方法,我们可以轻松的设置行高:

- (CGFloat)layoutManager:(NSLayoutManager *)layoutManager
  lineSpacingAfterGlyphAtIndex:(NSUInteger)glyphIndex
  withProposedLineFragmentRect:(CGRect)rect
{
    return floorf(glyphIndex / 100);
}

假设你的文本中有链接,你不希望这些链接被断行分割。如果可能的话,一个 URL 应该始终显示为一个整体,一个单一的文本片段。没有什么比这更简单的了。

首先,就像前面讨论过的那样,我们使用自定义的 Text Storage,如下:

static NSDataDetector *linkDetector;
linkDetector = linkDetector ?: [[NSDataDetector alloc] initWithTypes:NSTextCheckingTypeLink error:NULL];

NSRange paragaphRange = [self.string paragraphRangeForRange: NSMakeRange(range.location, str.length)];
[self removeAttribute:NSLinkAttributeName range:paragaphRange];

[linkDetector enumerateMatchesInString:self.string
                               options:0
                                 range:paragaphRange
                            usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop)
{
    [self addAttribute:NSLinkAttributeName value:result.URL range:result.range];
}];

改变断行行为就只需要实现一个 Layout Manager 的代理方法:

- (BOOL)layoutManager:(NSLayoutManager *)layoutManager shouldBreakLineByWordBeforeCharacterAtIndex:(NSUInteger)charIndex
{
    NSRange range;
    NSURL *linkURL = [layoutManager.textStorage attribute:NSLinkAttributeName
                                                  atIndex:charIndex
                                           effectiveRange:&range];

    return !(linkURL && charIndex > range.location && charIndex <= NSMaxRange(range));   

结果就像下面这样:

IMG_0938.PNG

你可以在这里下载完整的代码。如果你觉得对你有帮助,希望你不吝啬你的star:)

参考:初识 TextKit,iOS 7 by Tutorials,iOS 7 Programming Pushing the Limits

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • 卷首语 欢迎来到 objc.io 第五期! 我们希望你跟我们一样为 iOS 7 的发布而感到兴奋。选择这个做为本期...
    评评分分阅读 556评论 0 4
  • iOS 7 引入了一个非常有用的新功能TextKit,使开发者可以通过方便的接口去修改文字的样式和排版,而不需要直...
    星___尘阅读 7,598评论 4 75
  • 0.TextKit包含类讲解 如图TextKit_1可以看到,我们一般能接触到的文字控件全是由TextKit封装而...
    破弓阅读 1,763评论 0 10
  • 转载: 经典必看总结 http://www.itnose.net/detail/6177538.html Text...
    F麦子阅读 462评论 0 1
  • 转载:https://yq.aliyun.com/articles/60173 https://objccn.io...
    F麦子阅读 2,870评论 0 2