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

iOS 如何实现类instagram的文字边框/背景效果_第1张图片
demo展示.gif

iOS 如何实现类instagram的文字边框/背景效果_第2张图片
demo展示2.gif

前言

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

实现思路及方法

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

    • TextKit
iOS 如何实现类instagram的文字边框/背景效果_第3张图片
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 
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];

画出来的结果是这样的:


iOS 如何实现类instagram的文字边框/背景效果_第4张图片
image.png
3、 绘制上下矩形交接处的圆角以及矩形的预处理

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

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


iOS 如何实现类instagram的文字边框/背景效果_第5张图片
image.png

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


iOS 如何实现类instagram的文字边框/背景效果_第6张图片
image.png

      如果要在红色区域内绘制两个半径为R的圆角是放不下的。在这里可以采取两种方案解决问题:
iOS 如何实现类instagram的文字边框/背景效果_第7张图片
image.png

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

iOS 如何实现类instagram的文字边框/背景效果_第8张图片
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一样大的情况:


iOS 如何实现类instagram的文字边框/背景效果_第9张图片
image.png

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


iOS 如何实现类instagram的文字边框/背景效果_第10张图片
image.png

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


iOS 如何实现类instagram的文字边框/背景效果_第11张图片
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表情之后发现再输入文字会发现字体变回了系统字体,那么点击这里,有解决方案 。

你可能感兴趣的:(iOS 如何实现类instagram的文字边框/背景效果)