iOS 如何实现类instagram的文字边框/背景效果

demo展示.gif

demo展示2.gif

前言

  • 终于在周末抽出时间来整理了一下有关文字边框/背景效果的代码,之前在写这个效果的时候也是经历了很多坎坷。在前期查阅资料的时候,网上大部分的查询结果都是文字如何描边啊或者是直接textview如何加边框(???)之类,要么是我查阅的姿势不对,要么就是类似这种效果真的没有人做,或者没有人开源或分享出来。以下我会把整个效果的实现思路、具体实现方法、在过程中踩过的坑点分享给大家。

实现思路及方法

1. 拿到每一行文字边框的矩形
  • 首先输入框用到的控件肯定是UITextView没有异议,然后想到的是要绘制边框或者背景,首先要有提供整个边框的路径,通过路径去填充或者描边来实现背景或者边框的效果。那么如何拿到最关键的,也就是包裹每行文字的rect呢。第一反应肯定是查看UITextView的API。好的,看完UITextView的API之后很失望,确实没有任何一个方法属性或者代理能拿到文本覆盖的区域的,但是如果你对TextKit不熟悉的话,你会发现这三个属性:NSTextStorage、NSLayoutManager、NSTextContainer。没错,下面就要步入预备知识小课堂了!

    • TextKit
image.png
  • NSTextStorage ,它主要用来存储并管理TextView的文本,继承自NSMutableAttributedString,如果用MVC来类比的话,textStorage就是代表模型(Model)。它管理所有的文本信息以及属性,如果将textStorage自定义的话可以让文本在之后的显示中实现动态地添加字体或者颜色高亮、下划线等文本属性装饰。NSTextStorage从文本系统看来,只是一个带有属性的字符串,附带一些扩展,并且它可以将所有对其内容进行的修改以通知的形式发送出来。
  • NSTextContainer:container代表了每个textview可以绘制文本的区域,可以类比为MVC中的View,和其他空间的container含义一样,在简单情况下是一个无限大的rect,当然也可以限制它的大小,当文字显示区域大于textView时,它可以允许被用户滚动。
  • NSLayoutManager:顾名思义,layoutManager主要负责文本的布局,它是中心组件,可以理解为MVC中的Controller。layoutManager监听了NSTextStorage中文本属性或者内容改变的通知,一旦接收到就触发布局的进程。它根据storage提供的文本属性及内容,将所有的字符转换成字形,然后根据NSTextContainer限制的绘制区域,逐步用行填充这些区域,而行又被字形逐步填充,一行接着一行直至填充完毕。在填充的时候,断行行为、连字符、内联的图像附件等等情况都是它进行处理,当布局完成后,layoutManager就将排版好的文本设给TextView。

        以上就是简单的对textkit三个主要类进行介绍,深入了解可以在文末的参考资料中进行进一步的学习。
        毋庸置疑,由于我们是对所有输入的字体进行边框以及背景绘制,所以只需要自定义NSLayoutManager即可。当然有其他字体属性相关的业务要求的时候还是要自定义textstorage。在layoutManager的API中可以找到一下两个方法:

/************************ Drawing support ************************/
// These methods are primitives for drawing.  You can override these to perform additional drawing, or to replace text drawing entirely, but not to change layout.  You can call them if you want, but focus must already be locked on the destination view or image.  -drawBackgroundForGlyphRange:atPoint: draws the background color and selection and marked range aspects of the text display, along with block decoration such as table backgrounds and borders.  -drawGlyphsForGlyphRange:atPoint: draws the actual glyphs, including attachments, as well as any underlines or strikethroughs.  In either case all of the specified glyphs must lie in a single container.
- (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin;

// Enumerates line fragments intersecting with glyphRange.
- (void)enumerateLineFragmentsForGlyphRange:(NSRange)glyphRange usingBlock:(void (^)(CGRect rect, CGRect usedRect, NSTextContainer *textContainer, NSRange glyphRange, BOOL *stop))block NS_AVAILABLE(10_11, 7_0);

            drawBackgroundForGlyphRange方法在每次输入文字,收到从storage发出的文本变化的通知之后,都会被调用。覆写该方法可以达到在指定Range范围内绘制背景的目的。
            enumerateLineFragmentsForGlyphRange方法可以遍历拿到container中所有的行片段,类型为CGRect。对!没错,这就是我们想要的API!usingBlock中的rect是指整个行片段,这个rect是包括文字左右两边的空白区域的。而第二个参数usedRect就是指字形所使用的区域,就是包裹整行字形的rect范围。

2.绘制圆角矩形

        以上我们已经拿到的关键的两个api,接下来就可以开始自定义LayoutManager进行绘制操作了。我们自定义一个KiraTextViewLayoutManager,继承自NSLayoutManager。可以先什么都不用干,直接override关键的函数,如下:

#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN

@interface KiraTextViewLayoutManager : NSLayoutManager
@property (nonatomic, strong) UIBezierPath *path;
@end

NS_ASSUME_NONNULL_END

#import "RSAddTextViewLayoutManager.h"
@implementation KiraTextViewLayoutManager

-(void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin {
    [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin];

}
@end

        drawBackgroundForGlyphRange是可以直接在内部使用CoreGraphics的,也就是我们可以直接在该方法中进行绘制。

    NSRange range = [self characterRangeForGlyphRange:glyphsToShow
                                     actualGlyphRange:NULL];
    NSRange glyphRange = [self glyphRangeForCharacterRange:range
                                      actualCharacterRange:NULL];
     [[UIColor redColor] setFill];
      [[UIColor redColor] setStroke];
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSaveGState(context);   //保存当前的绘图配置信息
    CGContextTranslateCTM(context, origin.x, origin.y); //转换初始坐标系到绘制字形的位置
     self.path = nil;
    [self enumerateLineFragmentsForGlyphRange:glyphRange usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer * _Nonnull textContainer, NSRange glyphRange, BOOL * _Nonnull stop) {
              [self.path appendPath:[UIBezierPath bezierPathWithRoundedRect:usedRect cornerRadius:8.f]];
    }];
   [self.path stroke];

画出来的结果是这样的:


image.png
3、 绘制上下矩形交接处的圆角以及矩形的预处理

       其实在得到上一步的结果的时候,后续的思路就已经很明显了。这里将文字边框和文字背景进行分开讨论。其实边框的实现比文字背景的实现要复杂和困难,绘制边框不仅需要将上下矩形衔接处的圆角画上,还要把多余的线条擦掉。而绘制背景的话,在拿到所有path之后,直接fill就好,无所谓擦不擦除多余的线条。(反正颜色都一样分辨不出来)所以如果只做背景填充的话,方案就相对更简单,如果要做边框,那么背景和边框就统一做。后续我们就按绘制边框的方案进行讨论。

如下,最正常的情况就是多绘制以下红色部分的圆角(画的丑,能理解就行,不要在意细节):


image.png

    但是当矩形出现这样的情况时,如图,我们假设绘制圆角的半径为R,当两个矩形交界处的距离小于2*R,也就是画不下两个完整的圆角时,就会出现问题,如下情况:


image.png

      如果要在红色区域内绘制两个半径为R的圆角是放不下的。在这里可以采取两种方案解决问题:
image.png

       1、如上,假设矩形X和矩形Y的理想圆角都是R,X的左下顶点为A,Y的左上顶点为C。那么当AC< 2 * R时,在AC处就画不出两个半径都为R的圆角。方案1的解决办法是将Y的左上圆角擦掉,在AC之间绘制两个半径为AC/2的圆角。但是,效果不尽人意,当圆角特别小的时候看起来就很丑。我们追求完美的处女座选择第二种方案。

image.png

       2、第二种方案就是,当出现两个矩形交界的顶点,如A和C,AC的距离不够两倍圆角半径R的时候,将偏小的矩形替换成大的矩形,然后将两个矩形一起绘制。当然,这里存在当A和C重合的情况,这种情况出现时,A和C顶点的圆角就不需要绘制,直接用一条线连接起来,效果如红色圆角矩形所示。这种方案实际也是instagram的处理方案。

        采用第二种方案会遇到一个问题,由于我们的矩形是从enumerateLineFragmentsForGlyphRange中遍历拿到的,如果遍历进行绘制的话,在AC距离小于两倍圆角半径的情况下,矩形X比矩形Y大的时候,Y矩形可以变为X大小进行绘制没有问题。但是当矩形X比矩形Y小的时候呢,在current Rect是Y的时候,矩形X是已经绘制好的了,就算重绘为Y的大小,假设矩形X前一个矩形为W,刚好重绘后的矩形X和矩形W又满足了这种情况呢?经过测试,instagram对于这种情况的处理方案也并不是完美的,它只做到重绘了矩形X,但是对于矩形W是没有进行重绘的,更不用说W之前的复合情况的矩形了。如图:


IMG_2289.PNG

        针对这种情况,我们采取的方案是将enumerateLineFragmentsForGlyphRange中拿到的usedRect存到一个队列中,在进行绘制之前先对矩形进行预处理。那么如何进行预处理呢,其实循环遍历rect队列,假设条件A为current矩形和last矩形满足顶点距离AC小于两倍圆角半径R的情况,并且current矩形比last矩形小。条件B为current矩形和last矩形满足顶点距离AC小于两倍圆角半径R的情况,并且current矩形比last矩形大。如果两个矩形满足条件A,则将current矩形替换为last矩形大小,继续往下走。如果两个矩形满足条件B,那么将last矩形替换为current矩形大小之后,回溯到前一个索引,去判断last之前的矩形是否仍满足条件A或B,如果满足,则进行处理,不满足则return出来。算法实现如下:

- (void)preProccess {
    maxIndex = 0;
    if (self.rectArray.count < 2) {
        return;
    }
    for (int i = 1; i < self.rectArray.count; i++) {
        maxIndex = i;
        [self processRectIndex:i];
    }
}

- (void)processRectIndex:(int) index {
    if (self.rectArray.count < 2 || index < 1 || index > maxIndex) {
        return;
    }
    NSValue *value1 = [self.rectArray objectAtIndex:index - 1];
    NSValue *value2 = [self.rectArray objectAtIndex:index];
    CGRect last = value1.CGRectValue;
    CGRect cur = value2.CGRectValue;
    R = cur.size.height * 0.18;
    
    //if t1 == true 改变cur的rect
    BOOL t1 = ((cur.origin.x - last.origin.x < 2 * R) && (cur.origin.x > last.origin.x)) || ((CGRectGetMaxX(cur) - CGRectGetMaxX(last) > -2 * R) && (CGRectGetMaxX(cur) < CGRectGetMaxX(last)));
    //if t2 == true 改变last的rect
    BOOL t2 = ((last.origin.x - cur.origin.x < 2 * R) && (last.origin.x > cur.origin.x)) || ((CGRectGetMaxX(last) - CGRectGetMaxX(cur) > -2 * R) && (CGRectGetMaxX(last) < CGRectGetMaxX(cur)));
    
    if (t2) {
        //将last的rect替换为cur的rect
        CGRect newRect = CGRectMake(cur.origin.x, last.origin.y, cur.size.width, last.size.height);
        NSValue *newValue = [NSValue valueWithCGRect:newRect];
        [self.rectArray replaceObjectAtIndex:index - 1 withObject:newValue];
        [self processRectIndex:index - 1];
    }
    if (t1) {
        //将cur的rect替换为last的rect
        CGRect newRect = CGRectMake(last.origin.x, cur.origin.y, last.size.width, cur.size.height);
        NSValue *newValue = [NSValue valueWithCGRect:newRect];
        [self.rectArray replaceObjectAtIndex:index withObject:newValue];
        [self processRectIndex:index + 1];
    }
    return;
}
4、 绘制预处理之后的文字背景

        在对rect队列预处理完之后,就可以遍历队列进行矩形和圆角的绘制了。处理完矩形队列之后,last和current矩形的圆角绘制情况就只剩以下三种了。(只讨论左半边,右半边同理)
        (1)AC重合,矩形X和矩形Y一样大的情况:


image.png

        (2)|AC| > 2R,矩形X比矩形Y小的情况:


image.png

        (3)|AC| > 2R,矩形X比矩形Y大的情况:


image.png

       由于textView存在左对齐,居中,右对齐三种情况,左右两边并不一定是完全对称的,所以在绘制的时候同时要考虑右半边的三种情况。然后根据情况绘制出如上三图中红色部分的线条,然后填充路径范围内的颜色就OK。绘制部分给出代码如下:

   if (self.type == RSAddTextBackGroundTypeSolid) {
        for (int i = 0; i < self.rectArray.count; i ++) {
            NSValue *curValue = [self.rectArray objectAtIndex:i];
            CGRect cur = curValue.CGRectValue;
            R = cur.size.height * 0.18;
            [self.path appendPath:[UIBezierPath bezierPathWithRoundedRect:cur cornerRadius:R]];
            CGRect last = CGRectNull;
            if (i > 0) {
                NSValue *lastValue = [self.rectArray objectAtIndex:i-1];
                last = lastValue.CGRectValue;
                CGPoint a = cur.origin;
                CGPoint b = CGPointMake(CGRectGetMaxX(cur), cur.origin.y);
                CGPoint c = CGPointMake(last.origin.x, CGRectGetMaxY(last));
                CGPoint d = CGPointMake(CGRectGetMaxX(last), CGRectGetMaxY(last));
                
                if (a.x - c.x >= 2*R) {
                    //Draw
                    UIBezierPath * addPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(a.x - R, a.y + R) radius:R startAngle:M_PI_2 * 3 endAngle:0 clockwise:YES];
                    
                    [addPath appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(a.x + R, a.y + R) radius:R startAngle:M_PI endAngle:3 * M_PI_2 clockwise:YES]];
                    [addPath addLineToPoint:CGPointMake(a.x - R, a.y)];
                    [self.path appendPath:addPath];
                    //Remove
                    
                }
                if (a.x == c.x) {
                    //Draw
                    [self.path moveToPoint:CGPointMake(a.x, a.y - R)];
                    [self.path addLineToPoint:CGPointMake(a.x, a.y + R)];
                    [self.path addArcWithCenter:CGPointMake(a.x + R, a.y + R) radius:R startAngle:M_PI endAngle:M_PI_2 * 3 clockwise:YES];
                    [self.path addArcWithCenter:CGPointMake(a.x + R, a.y - R) radius:R startAngle:M_PI_2 endAngle:M_PI clockwise:YES];
                    //Remove
                }
                if (d.x - b.x >= 2*R) {
                    //Draw
                    UIBezierPath * addPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(b.x + R, b.y + R) radius:R startAngle:M_PI_2 * 3 endAngle:M_PI clockwise:NO];
                    [addPath appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(b.x - R, b.y + R) radius:R startAngle:0 endAngle:3 * M_PI_2 clockwise:NO]];
                    [addPath addLineToPoint:CGPointMake(b.x + R, b.y)];
                    [self.path appendPath:addPath];
                    //Remove
                    
                }
                if (d.x == b.x) {
                    //Draw
                    [self.path moveToPoint:CGPointMake(b.x, b.y - R)];
                    [self.path addLineToPoint:CGPointMake(b.x, b.y + R)];
                    [self.path addArcWithCenter:CGPointMake(b.x - R, b.y + R) radius:R startAngle:0 endAngle:M_PI_2 * 3 clockwise:NO];
                    [self.path addArcWithCenter:CGPointMake(b.x - R, b.y - R) radius:R startAngle:M_PI_2 endAngle:0 clockwise:NO];
                    //Remove
                }
                if (c.x - a.x >= 2*R) {
                    //Draw
                    UIBezierPath * addPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(c.x - R, c.y - R) radius:R startAngle:M_PI_2 endAngle:0 clockwise:NO];
                    [addPath appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(c.x + R, c.y - R) radius:R startAngle:M_PI endAngle:M_PI_2 clockwise:NO]];
                    [addPath addLineToPoint:CGPointMake(c.x - R, c.y)];
                    [self.path appendPath:addPath];
                    //Remove
                }
                if (b.x - d.x >= 2*R) {
                    //Draw
                    UIBezierPath * addPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(d.x + R, d.y - R) radius:R startAngle:M_PI_2 endAngle:M_PI clockwise:YES];
                    [addPath appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(d.x - R, d.y - R) radius:R startAngle:0 endAngle:M_PI_2 clockwise:YES]];
                    [addPath addLineToPoint:CGPointMake(d.x + R, d.y)];
                    [self.path appendPath:addPath];
                    //Remove
                }
            }
        }
        [self.path stroke];
        [self.path fill];
    }
  • 绘制文字背景部分到这里就结束了,在具体方案上,文字背景的绘制处理是优于instagram的处理方式的,并且对于实现的视觉效果也比较满意。接下来再介绍一下绘制文字边框的处理方式。
5、 绘制预处理之后的文字边框

       在上一步中,其实已经将文字的边框路径绘制出来了,但是除了我们想要的文字外边框,其余绘制的线条需要擦除掉。如两个矩形交界处的线条,以及内部拐角处的线条都是需要擦掉的。那么如何将线条擦掉呢?CGContext有设置混合模式的接口:

CG_EXTERN void CGContextSetBlendMode(CGContextRef cg_nullable c, CGBlendMode mode)
    CG_AVAILABLE_STARTING(10.4, 2.0);

将当前context的blendMode设置为kCGBlendModeClear模式,接下来绘制出来的效果就是透明的,相当于擦除的效果。在需要正常绘图的时候将contextMode设置回kCGBlendModeNormal即可。

- (void)setContext:(CGContextRef)context ifClear:(BOOL)isClear {
    if (isClear) {
        CGContextSetBlendMode(context, kCGBlendModeClear);
        [[UIColor clearColor] setStroke];
    } else {
        CGContextSetBlendMode(context, kCGBlendModeNormal);
        if (self.useColor) {
            [self.useColor setFill];
            [self.useColor setStroke];
        } else {
            [[UIColor blackColor] setFill];
            [[UIColor whiteColor] setStroke];
        }
    }
}

       注意这里做擦除多余线条的时候可以将lineWidth设置得比正常线条稍微粗一点点,不然擦除掉线条的边缘还是会留下一点点痕迹。如果你的textView永远只是设置好的大小的话,做到这一步其实已经实现了文字边框的效果了,可以拿个70分左右的成绩。还有30分是要针对不同情况下出现的bug慢慢完善去拿到的。比如,假如textview可以缩放,如果单行输入的字符宽度还不够圆角的情况?等等。针对不同的情况出现的问题还需要慢慢完善。

结束

  • 个人觉得文字边框的实现比较容易出现有瑕疵的地方,而背景相对就没有这么多问题。可能ins之所以只做文字背景而不做边框也有可能有这个原因在吧。

  • TextKit确实是一个挺大块的东西,在查阅资料的过程中也明显感觉到这方面的资料远没有其他UI的资料充足,可能只有做阅读书籍类APP的哥们接触的比较多吧。

  • 本文主要还是以实现思路为主,关键的代码也有给出,Demo目前还没有整理出来,但是如果有需要的朋友可以留言或者私聊,我会抽时间整理一份出来的。有更好的实现方式或者对于我的实现方法有bug或者可以优化的地方欢迎提出来交流~

  • 补充:简单的Demo已经整理出来了,除了本文中提到的功能,demo中的textView实现了根据文字输入动态调整TextView大小以及固定textView大小,动态调整文字大小的功能。点击这里跳转

扩展阅读

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

推荐阅读更多精彩内容