TextKit详解

一、参与者详解

1、string:读入需要绘制的文本内容。

2、NSTextStorage:管理string的内容;这个很容易理解,NSTextStorage的父类是NSAttributedString继承属性文字所有的可设置属性,但是他们唯一不同的地方在与:NSTextStorage包含了一个方法,可以将所有对其内容进行的修改以通知的方式发送出来(这个方法在后面会将到);简单的理解就是:NSTextStorage保存并管理这个string;在使用一个自定义的 NSTextStorage 就可以让文本在稍后动态地添加字体或者颜色高亮等文本属性修饰。

3、UITextView:堆栈的另一头是实际显示的视图。作用一,就是显示内容,作用二,就是处理用户的交互。唯一,需特别处理的就是,它已遵守了UITextInput的协议,来处理键盘事件。

4、NSTextContainer:textView给出了一个文本的绘制区域;在一般情况下,NSTextContainer精确的描述了这个可用的区域,其就是一个矩形,在垂直方向上无限大;但是,在特定的情况下,例如要是界面文字内容固定大小,就像是一本书一样,每页内容固定,可以翻页的效果;还有一中情况就是,图片在这个固定大小的页面中占据了一块区域,文字内容会,填充图片意外剩余的区域。

5、NSLayoutManager:核心组件,联系了以上所有组件;1、与NSTextStorage的关系:它监听着NSTextStorage发出的关于string属性改变的通知,一旦接受到通知就会触发重新布局;2、从NSTextStorage中获取string(内容)将其转化为字形(与当前设置的字体等内容相关);3、一旦字形完全生成完毕,NSLayoutManager(管理者)会像NSTextContainer查询文本可用的绘制区域;4、NSTextContainer,会将文本的当前状态改为无效,然后交给textView去显示。

注:CoreText,并没用直接包含在TextKit中,CoreText是进行实地排版的库,他详细的管理者实地排版中的每一行,断句以及从字义到字形的翻译。

二、Demo
Demo1、基本用法

- (void)viewDidLoad
{
   [super viewDidLoad];

   //1、获取文本管理者
   NSTextStorage *sharedTextStorage = self.originalTextView.textStorage;
   //2、读取本地文件
   [sharedTextStorage replaceCharactersInRange:NSMakeRange(0, 0) withString:[NSString stringWithContentsOfURL:[NSBundle.mainBundle URLForResource:@"lorem" withExtension:@"txt"] usedEncoding:NULL error:NULL]];
   //3、布局与字形的管理
   NSLayoutManager *otherLayoutManager = [NSLayoutManager new];
   [sharedTextStorage addLayoutManager: otherLayoutManager];
   //4、布局的rect
   NSTextContainer *otherTextContainer = [NSTextContainer new];
   [otherLayoutManager addTextContainer: otherTextContainer];
   //otherTextView与originalTextView使用了同一个NSTextStorage 但是,使用了新创建的NSLayoutManager与NSTextContainer独立管理otherTextView的布局
   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;
   
   //thirdTextView与otherTextView使用了同一个otherLayoutManager:(分页的实现)
   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;
}

- (IBAction)endEditing:(UIBarButtonItem *)sender
{
   [self.view endEditing: YES];
}

Demo2、高亮文字
如果,不明白每个参与者的责任,你很难理解像textKit这样的框架;例如,唐巧也很早写过一篇博文,并在github配有Demo来讲解textKit,但是,你看完要不是一脸懵逼,就是自己写的话还是没有逻辑;
废话不多说,看代码:在前面已经介绍了,各个参与者的责任,想要实现高亮文字,其实就是由NSTextStorage负责的,因为他继承自NSMutableAttributedString;

NSTextStorage ---
NSTextStorage是NSMutableAttributedString的子类,根据苹果官方文档描述
是semiconcrete子类,因为NSTextStorage没有实现
NSMutableAttributedString中的方法,所以说NSTextStorage应该是
NSMutableAttributedString的类簇。 
所要我们深入使用NSTextStorage不仅要继承NSTextStorage类还要实现
NSMutableAttributedString的下面方法
- (NSString *)string
- (void)replaceCharactersInRange:(NSRange)range    withString:(NSString *)str
- (NSDictionary *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)range
- (void)setAttributes:(NSDictionary *)attrs range:(NSRange)range  

因为这些方法实际上NSTextStorage并没有实现然而我们断然不知道NSMutableAttributedString是如何实现这些方法,所以我们继承NSTextStorage并实现这些方法最简单的莫过于在NSTextStorage类中实例化一个NSMutableAttributedString对象然后调用NSMutableAttributedString对象的这些方法来实现NSTextStorage类中的这些方法

还值得注意的是:每次编辑都会调用-(void)processEditing的方法

-(void)processEditing;

完整的实现代码如下:
.h文件

#import <UIKit/UIKit.h>

@interface TKDHighlightingTextStorage : NSTextStorage

@end

.m文件

#import "TKDHighlightingTextStorage.h"


@implementation TKDHighlightingTextStorage
{
    NSMutableAttributedString *_imp;
}

//实例化 NSMutableAttributedString对象
- (id)init
{
    self = [super init];
    
    if (self) {
        _imp = [NSMutableAttributedString new];
    }
    
    return self;
}


#pragma mark - Reading Text - get方法

- (NSString *)string
{
    return _imp.string;
}

- (NSDictionary *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)range
{
    return [_imp attributesAtIndex:location effectiveRange:range];
}


#pragma mark - Text Editing

- (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str
{
    [_imp replaceCharactersInRange:range withString:str];
    [self edited:NSTextStorageEditedCharacters range:range changeInLength:(NSInteger)str.length - (NSInteger)range.length];
}

- (void)setAttributes:(NSDictionary *)attrs range:(NSRange)range
{
    [_imp setAttributes:attrs range:range];
    [self edited:NSTextStorageEditedAttributes range:range changeInLength:0];
}


#pragma mark - Syntax highlighting

- (void)processEditing
{
    //正则表达式来查找单词以i开头连接W的单词
    static NSRegularExpression *iExpression;
    iExpression = iExpression ?: [NSRegularExpression regularExpressionWithPattern:@"i[\\p{Alphabetic}&&\\p{Uppercase}][\\p{Alphabetic}]+" options:0 error:NULL];
    
    
    // 首先清除之前的所有高亮
    NSRange paragaphRange = [self.string paragraphRangeForRange: self.editedRange];
    [self removeAttribute:NSForegroundColorAttributeName range:paragaphRange];
    
    // 其次遍历所有的样式匹配项并高亮它们
    [iExpression enumerateMatchesInString:self.string options:0 range:paragaphRange usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) {
        // Add red highlight color
        [self addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:result.range];
    }];
  
  /*
   请注意仅仅使用 edited range 是不够的。例如,当手动键入 iWords,只有一个单词的第三个字符被键入后,正则表达式才开始匹配。但那时 editedRange 仅包含第三个字符,因此所有的处理只会影响这一个字符。通过重新处理整个段落可以解决这个问题,这样既完成高亮功能,又不会太过影响性能
   
   */
  [super processEditing];
}

@end

Demo3、布局演示
需求:文本中的网址不断行
1.NSTextStorage负责监听文本中出现的网址string

#import "TKDLinkDetectingTextStorage.h"


@implementation TKDLinkDetectingTextStorage
{
    NSTextStorage *_imp;
}

- (id)init
{
    self = [super init];
    
    if (self) {
        _imp = [NSTextStorage new];
    }
    
    return self;
}


#pragma mark - Reading Text

- (NSString *)string
{
    return _imp.string;
}

- (NSDictionary *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)range
{
    return [_imp attributesAtIndex:location effectiveRange:range];
}


#pragma mark - Text Editing

//NSString 替换字符串中某一位置的文字
- (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str
{
    // Normal replace
    [_imp replaceCharactersInRange:range withString:str];
    [self edited:NSTextStorageEditedCharacters range:range changeInLength:(NSInteger)str.length - (NSInteger)range.length];
    
    
    
    // Regular expression matching all iWords -- first character i, followed by an uppercase alphabetic character, followed by at least one other character. Matches words like iPod, iPhone, etc.
    static NSDataDetector *linkDetector;
    linkDetector = linkDetector ?: [[NSDataDetector alloc] initWithTypes:NSTextCheckingTypeLink error:NULL];
    
    // Clear text color of edited range
    NSRange paragaphRange = [self.string paragraphRangeForRange: NSMakeRange(range.location, str.length)];
    [self removeAttribute:NSLinkAttributeName range:paragaphRange];
    [self removeAttribute:NSBackgroundColorAttributeName range:paragaphRange];
    [self removeAttribute:NSUnderlineStyleAttributeName range:paragaphRange];
    
    // Find all iWords in range
    [linkDetector enumerateMatchesInString:self.string options:0 range:paragaphRange usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) {
        // Add red highlight color
        [self addAttribute:NSLinkAttributeName value:result.URL range:result.range];
        [self addAttribute:NSBackgroundColorAttributeName value:[UIColor yellowColor] range:result.range];
        [self addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:result.range];
    }];
}

- (void)setAttributes:(NSDictionary *)attrs range:(NSRange)range
{
    [_imp setAttributes:attrs range:range];
    [self edited:NSTextStorageEditedAttributes range:range changeInLength:0];
}

@end

2.重写NSLayoutManager“对应的”drawGlyphsForGlyphRange方法
这里我们重写这个方法

#import "TKDOutliningLayoutManager.h"

@implementation TKDOutliningLayoutManager
//下面重写NSLayoutManager的drawGlyphsForGlyphRange方法
- (void)drawUnderlineForGlyphRange:(NSRange)glyphRange underlineType:(NSUnderlineStyle)underlineVal baselineOffset:(CGFloat)baselineOffset lineFragmentRect:(CGRect)lineRect lineFragmentGlyphRange:(NSRange)lineGlyphRange containerOrigin:(CGPoint)containerOrigin
{
    // Left border (== position) of first underlined glyph
    CGFloat firstPosition = [self locationForGlyphAtIndex: glyphRange.location].x;
    
    // Right border (== position + width) of last underlined glyph
    CGFloat lastPosition;
    
    // When link is not the last text in line, just use the location of the next glyph
    if (NSMaxRange(glyphRange) < NSMaxRange(lineGlyphRange)) {
        lastPosition = [self locationForGlyphAtIndex: NSMaxRange(glyphRange)].x;
    }
    // Otherwise get the end of the actually used rect
    else {
        lastPosition = [self lineFragmentUsedRectForGlyphAtIndex:NSMaxRange(glyphRange)-1 effectiveRange:NULL].size.width;
    }
    
    // Inset line fragment to underlined area
    lineRect.origin.x += firstPosition;
    lineRect.size.width = lastPosition - firstPosition;
    
    // Offset line by container origin
    lineRect.origin.x += containerOrigin.x;
    lineRect.origin.y += containerOrigin.y;
    
    // Align line to pixel boundaries, passed rects may be
    lineRect = CGRectInset(CGRectIntegral(lineRect), .5, .5);
    
    [[UIColor greenColor] set];
    [[UIBezierPath bezierPathWithRect: lineRect] stroke];
}


3.在textView所在页面,使用NSLayoutManager的代理做具体的实现

#import "TKDLayoutingViewController.h"

#import "TKDLinkDetectingTextStorage.h"
#import "TKDOutliningLayoutManager.h"


@interface TKDLayoutingViewController () <NSLayoutManagerDelegate>
{
    // Text storage must be held strongly, only the default storage is retained by the text view.
    TKDLinkDetectingTextStorage *_textStorage;
}
@end

@implementation TKDLayoutingViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // Create componentes
    _textStorage = [TKDLinkDetectingTextStorage new];
    
    NSLayoutManager *layoutManager = [TKDOutliningLayoutManager new];
    [_textStorage addLayoutManager: layoutManager];
    
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize: CGSizeZero];
    [layoutManager addTextContainer: textContainer];
    
    UITextView *textView = [[UITextView alloc] initWithFrame:CGRectInset(self.view.bounds, 5, 20) textContainer: textContainer];
    textView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
    textView.translatesAutoresizingMaskIntoConstraints = YES;
    textView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;
    [self.view addSubview: textView];
    
    
    // Set delegate
    layoutManager.delegate = self;
    
    // Load layout text
    [_textStorage replaceCharactersInRange:NSMakeRange(0, 0) withString:[NSString stringWithContentsOfURL:[NSBundle.mainBundle URLForResource:@"layout" withExtension:@"txt"] usedEncoding:NULL error:NULL]];
}


#pragma mark - Layout

- (BOOL)layoutManager:(NSLayoutManager *)layoutManager shouldBreakLineByWordBeforeCharacterAtIndex:(NSUInteger)charIndex
{
    NSRange range;
    NSURL *linkURL = [layoutManager.textStorage attribute:NSLinkAttributeName atIndex:charIndex effectiveRange:&range];
    
    // Do not break lines in links unless absolutely required
    if (linkURL && charIndex > range.location && charIndex <= NSMaxRange(range))
        return NO;
    else
        return YES;
}

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

- (CGFloat)layoutManager:(NSLayoutManager *)layoutManager paragraphSpacingAfterGlyphAtIndex:(NSUInteger)glyphIndex withProposedLineFragmentRect:(CGRect)rect
{
    return 10;
}

@end

Demo4、综合实例
NSTextContainer 和NSBezierPath的使用

#import "TKDInteractionViewController.h"

#import "TKDCircleView.h"//只是为椭圆添加一个空白边距

@interface TKDInteractionViewController () <UITextViewDelegate>
{
   CGPoint _panOffset;
}
@end

@implementation TKDInteractionViewController

- (void)viewDidLoad
{
   [super viewDidLoad];
   
   // Load text
   [self.textView.textStorage replaceCharactersInRange:NSMakeRange(0, 0) withString:[NSString stringWithContentsOfURL:[NSBundle.mainBundle URLForResource:@"lorem" withExtension:@"txt"] usedEncoding:NULL error:NULL]];
   
   // Delegate
   self.textView.delegate = self;
   self.clippyView.hidden = YES;
   
   // Set up circle pan
   [self.circleView addGestureRecognizer: [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(circlePan:)]];
   [self updateExclusionPaths];
   
   // Enable hyphenation
   self.textView.layoutManager.hyphenationFactor = 1.0;
}


#pragma mark - Exclusion

- (void)circlePan:(UIPanGestureRecognizer *)pan
{
   // Capute offset in view on begin
   if (pan.state == UIGestureRecognizerStateBegan)
       _panOffset = [pan locationInView: self.circleView];
   
   // Update view location
   CGPoint location = [pan locationInView: self.view];
   CGPoint circleCenter = self.circleView.center;
   
   circleCenter.x = location.x - _panOffset.x + self.circleView.frame.size.width / 2;
   circleCenter.y = location.y - _panOffset.y + self.circleView.frame.size.width / 2;
   self.circleView.center = circleCenter;
   
   // Update exclusion path
   [self updateExclusionPaths];
}

- (void)updateExclusionPaths
{
   CGRect ovalFrame = [self.textView convertRect:self.circleView.bounds fromView:self.circleView];
   
   // Since text container does not know about the inset, we must shift the frame to container coordinates
   ovalFrame.origin.x -= self.textView.textContainerInset.left;
   ovalFrame.origin.y -= self.textView.textContainerInset.top;
   
   // Simply set the exclusion path
   UIBezierPath *ovalPath = [UIBezierPath bezierPathWithOvalInRect: ovalFrame];
   self.textView.textContainer.exclusionPaths = @[ovalPath];
   
   // And don't forget clippy
   [self updateClippy];
}


#pragma mark - Selection tracking

- (void)textViewDidChangeSelection:(UITextView *)textView
{
   [self updateClippy];
}

- (void)updateClippy
{
   // Zero length selection hide clippy
   NSRange selectedRange = self.textView.selectedRange;
   if (!selectedRange.length) {
       self.clippyView.hidden = YES;
       return;
   }
   
   // Find last rect of selection
   NSRange glyphRange = [self.textView.layoutManager glyphRangeForCharacterRange:selectedRange actualCharacterRange:NULL];
   __block CGRect lastRect;
   [self.textView.layoutManager enumerateEnclosingRectsForGlyphRange:glyphRange withinSelectedGlyphRange:glyphRange inTextContainer:self.textView.textContainer usingBlock:^(CGRect rect, BOOL *stop) {
       lastRect = rect;
   }];
   
   
   // Position clippy at bottom-right of selection
   CGPoint clippyCenter;
   clippyCenter.x = CGRectGetMaxX(lastRect) + self.textView.textContainerInset.left;
   clippyCenter.y = CGRectGetMaxY(lastRect) + self.textView.textContainerInset.top;
   
   clippyCenter = [self.textView convertPoint:clippyCenter toView:self.view];
   clippyCenter.x += self.clippyView.bounds.size.width / 2;
   clippyCenter.y += self.clippyView.bounds.size.height / 2;
   
   self.clippyView.hidden = NO;
   self.clippyView.center = clippyCenter;
}

@end

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容