TextKit

作者: Saxon_Geoffrey | 来源:发表于2015-03-10 21:52 被阅读11128次

    以前,如果我们想实现如上图所示复杂的文本排版:显示不同样式的文本、图片和文字混排,你可能就需要借助于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

    相关文章

      网友评论

      • FengxinLi:请问一下4多容器布局,为什么第三个的不是从内容的开始字符布局的呢?好像是从中间manager也可以多重布局
      • 歌白梨:请问一下,我 编辑一个textview 的时候,加入多个图片,使用paths,怎么的到插入点的坐标呢?我使用selectedTextRange获得光标的位置,可是貌似这个位置并不是上一个加入图片后textview对应的位置。。
      • 五蕴盛:我如何做成填空的界面呢 ?想猿题库一样?求指导啊
        加油fight:请问现在实现了么,我现在X轴总是计算错误
      • 小凡凡520:good mark
      • oriyum:图文混排的那个如果用图片会是怎样
      • petter102:需要更多
      • 9ab6214fa360:问下, 如果我使用NSTextAttachment给文字中插入了图片, 在插入图片后我想给他一个换行, 需要怎么操作呢?
        或者说,
        无论图片大与小, 我都让他占一行
        Saxon_Geoffrey:@大佬杰 在图片后面加多一个换行符
      • xclidongbo:看了下textKit,为了实现图文混排必须要使用绝对位置frame吗?没有autolayout的demo吗?

      本文标题:TextKit

      本文链接:https://www.haomeiwen.com/subject/ldavxttx.html