UITextView结构探索(一)

自从第一篇文章的排版那么丑后,后来发现原来有MarkDown可以用,操作也很简单,今天就用MarkDown来编辑这篇文章吧!

进入主题!!!

一、为什么写这篇文章

我们都知道,苹果每个版本的更新都会有一些改动,虽然有时候表面看起来和之前的没有什么不一样,但其实背地里恐怕已经是其他的了。
最近比较关注我负责的某金服的app的崩溃统计,我们用的是听云,感觉是个很不错的统计工具,比起友盟感觉要强很多,因为其中的很多信息都一目了然,尤其是崩溃时的堆栈信息。
其中就发现了一个崩溃,信息如下:

[<UITextView 0x1379bf600> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key _placeholderLabel.

从其中可以看出是因为key为_placeholderLabel不被允许(compliant)操作了。
这个时候如果有意识的话,会发现崩溃的系统都是iOS8.1和iOS8.2系列的。
但是如果你按照常理去查找的话,会发现iOS11是正常的,iOS10呢?试一试还是正常的,iOS9呢?还是正常的。这个时候有没有感觉很惊喜?有没有很意外?最后终于来到了iOS8,你会发现iOS8.4、iOS8.3都是正常的,只有iOS8.1和iOS8.2有问题。我相信一般很难有小伙伴走到这一步吧!

这个问题的症结在于这么一个需求:

多行编辑文本框(UITextView)需要一个类似于单行编辑文本框(UITextField)的提示文本,但是UITextView并没有暴露相关的接口,所以有小伙伴就想利用高大上的KVC。

个人对于KVC和利用runtime去访问一些私有接口比较忌讳,因为这样访问很容易出现各种问题。比如最近发布的手机iPhone X,你会发现下面的方法在同样系统但是不同类型手机(iPhone 7 和 iPhone X比较)上时,iPhone X会崩溃!!!(这也是发现在我们工程里的问题)

// 状态栏是由当前app控制的,首先获取当前app
UIApplication *app = [UIApplication sharedApplication];
NSArray *children = [[[app valueForKeyPath:@"statusBar"] valueForKeyPath:@"foregroundView"] subviews];
int type = 0;
for (id child in children) {
if ([child isKindOfClass:[NSClassFromString(@"UIStatusBarDataNetworkItemView") class]]) {
type = [[child valueForKeyPath:@"dataNetworkType"] intValue];
}
}
......

基于以上原因,我决定做一些力所能及的研究,并最终实现产品的需求!!!

二、UITextView的结构

下图是模拟器上当前编辑框状态:


模拟器.png

下图是对应的层次结构:


层次结构.jpg

(这里是非选中状态下的结构,有兴趣可以自己先研究有选中时的结构)

1、解析

这里可以发现,文本的展示主要是靠一个叫_UITextContainerView的容器在管理,它的高度会随着文本的多少自己变化;
然后还有一个UITextSelectionView,这个主要是用来管理选中文本的视图,其大小是完全随着_UITextContainerView来变化的,从某种角度来说,光标(图中的蓝色箭头)是一个选中了文字长度为0的视图,所以它位于该视图中。如果你看了有选中内容的时候的结构就更能理解该视图图如其名了。

下面主要看一下_UITextContainerView这个类。

2、_UITextContainerView

<<_UITextContainerView: 0x7fa6ef52cb50; frame = (0 0; 345 58); layer = <__UITextTiledLayer: 0x6040000dff70>> minSize = {0, 0}, maxSize = {1.7976931348623157e+308, 1.7976931348623157e+308}, textContainer = <NSTextContainer: 0x6040001038d0 size = (345.000000,inf); widthTracksTextView = YES; heightTracksTextView = NO>; exclusionPaths = 0x60400000fe20; lineBreakMode = 0>

上面是对此时_UITextContainerView的说明。这里我也暂时没找到更多的解释,还在努力查找中,如果您有资料,麻烦发我一份!

注意,这里有一个视图:NSTextContainer,是不是很熟悉啊?可以通过UITextView的属性textContainer获得对于这个类,官方是这样说的:

A region where text is laid out.
An NSLayoutManager uses NSTextContainer to determine where to break lines, lay out portions of text, and so on. An NSTextContainer object typically defines rectangular regions, but you can define exclusion paths inside the text container to create regions where text does not flow. You can also subclass to create text containers with nonrectangular regions, such as circular regions, regions with holes in them, or regions that flow alongside graphics.
Instances of the NSTextContainer, NSLayoutManager, and NSTextStorage classes can be accessed from threads other than the main thread as long as the app guarantees access from only one thread at a time.

简单说,它和NSLayoutManager组合,可以用来决定文字的展示方式,包括展示位置、和四边的距离、换行位置,以及展示区域的形状等等,如果有需要,我们可以实现一个子类,比如实现一个圆形区域。

这里说到了三个类:NSTextContainer, NSLayoutManager, and NSTextStorage 。NSTextContainer用来控制文字的展示区域;NSLayoutManager控制文字的展示方式,即排版布局;NSTextStorage则用来储存文本信息(它是NSMutableAttributeString的子类)。
它们三个并不是独立运行的,相互之间有约束,相互作用,相互包含

下面就一起来研究一下吧!!!

3、NSTextContainer, NSLayoutManager, and NSTextStorage

在开始下面的内容前,先来一个默认时的内容展示:


完整展示.png
(1)、先来几个坑,一起填平吧!
A、NSLayoutManager初始化

NSLayoutManager * layoutManager = [[NSLayoutManager alloc] init]; //这里必须要用这个方法初始化

B、给UITextView赋值
NSTextStorage * storeage = [[NSTextStorage alloc] initWithString:[self contentStr]];
[storeage addLayoutManager:layoutManager];

NSTextContainer * textContainer = [[NSTextContainer alloc] init];
textContainer.widthTracksTextView = YES;
textContainer.heightTracksTextView = YES;
[layoutManager addTextContainer:textContainer];
//上面这部分不能写到其他方法里,必须和textView的初始化放到一起,不知道是为什么,我刚开始写在外面是不行的
_textView = [[UITextView alloc] initWithFrame:CGRectMake(10, 10, [UIScreen mainScreen].bounds.size.width - 2 * 10, [self getContentHeight]) textContainer:textContainer];
_textView.backgroundColor = [UIColor orangeColor];
[self.view addSubview:_textView];
(2)、NSTextContainer简单使用
A、只有一个container
- (void)configBasicTextView {
    CGFloat height = [self getContentHeight];
    
    NSLayoutManager * layoutManager = [[NSLayoutManager alloc] init]; //这里必须要用这个方法初始化
    
    NSTextStorage * storeage = [[NSTextStorage alloc] initWithString:[self contentStr]];
    [storeage addLayoutManager:layoutManager];
    
    NSTextContainer * textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake([UIScreen mainScreen].bounds.size.width, height / 2)];
    textContainer.widthTracksTextView = YES;
    [layoutManager addTextContainer:textContainer];
    
    _textView = [[UITextView alloc] initWithFrame:CGRectMake(10, 10, [UIScreen mainScreen].bounds.size.width - 2 * 10, height) textContainer:textContainer];
    _textView.backgroundColor = [UIColor orangeColor];
    _textView.font = [UIFont systemFontOfSize:18];
    [self.view addSubview:_textView];
}
- (NSString *)contentStr {
    return @"A region where text is laid out.An NSLayoutManager uses NSTextContainer to determine where to break lines, lay out portions of text, and so on. An NSTextContainer object typically defines rectangular regions, but you can define exclusion paths inside the text container to create regions where text does not flow. You can also subclass to create text containers with nonrectangular regions, such as circular regions, regions with holes in them, or regions that flow alongside graphics.Instances of the NSTextContainer, NSLayoutManager, and NSTextStorage classes can be accessed from threads other than the main thread as long as the app guarantees access from only one thread at a time.";
}

注意,这里的NSTextContainer的size我设置成了和UITextView宽度一样,但是高度只有其一半,你会发现,这里只出现了一半。


一个Container.png
B、有两个container
- (void)configTextViewWithTwoContainer {
   CGFloat height = [self getContentHeight];
   
   NSLayoutManager * layoutManager = [[NSLayoutManager alloc] init]; //这里必须要用这个方法初始化
   
   NSTextStorage * storeage = [[NSTextStorage alloc] initWithString:[self contentStr]];
   [storeage addLayoutManager:layoutManager];
   
   NSTextContainer * textContainer1 = [[NSTextContainer alloc] initWithSize:CGSizeMake([UIScreen mainScreen].bounds.size.width, height / 2)];
   textContainer1.widthTracksTextView = YES;
   NSTextContainer * textContainer2 = [[NSTextContainer alloc] initWithSize:CGSizeMake([UIScreen mainScreen].bounds.size.width, height / 2)];
   textContainer2.widthTracksTextView = YES;
   [layoutManager addTextContainer:textContainer1];
   [layoutManager addTextContainer:textContainer2];
   
   _textView = [[UITextView alloc] initWithFrame:CGRectMake(10, 10, [UIScreen mainScreen].bounds.size.width - 2 * 10, height) textContainer:textContainer2];
   _textView.backgroundColor = [UIColor orangeColor];
   _textView.font = [UIFont systemFontOfSize:18];
   [self.view addSubview:_textView];
}

这里虽然有两个NSTextContainer,但是,因为我初始化TextView的时候用的是第二个,你会发现展示的和预想的不太一样,它只展示了一部分,而这部分恰好是在第一个NSTextContainer中无法展示的内容。


两个NSTextContainer.png

通过上面可以发现,NSTextContainer会将内容自动按照自己的尺寸做特殊的分页展示。

C、NSTextContainer属性lineBreakMode

先看看该属性的可选值:

typedef NS_ENUM(NSInteger, NSLineBreakMode) {
NSLineBreakByWordWrapping = 0, // Wrap at word boundaries, default
NSLineBreakByCharWrapping, // Wrap at character boundaries
NSLineBreakByClipping, // Simply clip
NSLineBreakByTruncatingHead, // Truncate at head of line: "...wxyz"
NSLineBreakByTruncatingTail, // Truncate at tail of line: "abcd..."
NSLineBreakByTruncatingMiddle // Truncate middle of line: "ab...yz"
}

使用很简单,只需要在原有的基础上添加一句类似下面的代码:

textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle;

我就不粘贴代码了!
该值默认为NSLineBreakByWordWrapping;
注意:这里如果设置成后三种带有截断性质的话,展示会有所不同,比如设置成NSLineBreakByTruncatingMiddle,展示如下图:


image.png

(自己找不同哦)

结论:

1、通过实验,你会发现,后三种截断的出现都只在最后一行的相应位置
2、虽然截断了,也展示出了整段文字的最后一部分的信息,但是并不影响第二个NSTextContainer的展示。

D、NSTextContainer属性lineFragmentPadding

该属性设置padding,即内容距离左右两边的距离,默认值为5.

textContainer.lineFragmentPadding = 20;

效果如下:


padding=20.png

注意:

UITextView有个属性textContainerInset,该属性表示content的开始位置距离四边的距离,它确定了开始位置;
NSTextContainer属性lineFragmentPadding,该属性表示每行相对于内容开始和结束的地方再偏离的值;
它俩的联系,个人觉得是:lineFragmentPadding是基于textContainerInset做的判断。
比如此时代码如下:

    textContainer.widthTracksTextView = YES;
    textContainer.lineFragmentPadding = 20;
    [layoutManager addTextContainer:textContainer];
    
    _textView = [[UITextView alloc] initWithFrame:CGRectMake(10, 10, [UIScreen mainScreen].bounds.size.width - 2 * 10, height) textContainer:textContainer];
    _textView.backgroundColor = [UIColor orangeColor];
    _textView.textContainerInset = UIEdgeInsetsMake(0, 20, 0, 20);
    _textView.font = [UIFont systemFontOfSize:18];
    [self.view addSubview:_textView];
    NSLog(@"%@",NSStringFromUIEdgeInsets(_textView.textContainerInset)); //{20, 0, 20, 0}

效果图如下:


textContainerInset&lineFragmentPadding.png
E、NSTextContainer属性exclusionPaths

这个应该是该类的精华,可以用该属性指定内容不展示的位置!该值的定义和说明如下:

// Default value : empty array An array of UIBezierPath representing the exclusion paths inside the receiver's bounding rect.
@property (copy, NS_NONATOMIC_IOSONLY) NSArray<UIBezierPath *> *exclusionPaths NS_AVAILABLE(10_11, 7_0);

不说了,直接上代码!!!

- (void)configTextViewWithContainerAndExclusionPaths {
    CGFloat height = [self getContentHeight];
    CGFloat width = [UIScreen mainScreen].bounds.size.width - 2 * 10;
    
    NSLayoutManager * layoutManager = [[NSLayoutManager alloc] init]; //这里必须要用这个方法初始化
    
    NSTextStorage * storeage = [[NSTextStorage alloc] initWithString:[self contentStr]];
    [storeage addLayoutManager:layoutManager];
    
    NSTextContainer * textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake([UIScreen mainScreen].bounds.size.width, height / 2)];
    textContainer.widthTracksTextView = YES;
    
    UIBezierPath * path1 = [UIBezierPath bezierPathWithRect:CGRectMake(0, 0, width / 2, textContainer.size.height / 2)];
    UIBezierPath * path2 = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(width / 2, textContainer.size.height * 3 / 4, width / 2, textContainer.size.height / 4) byRoundingCorners:(UIRectCornerTopLeft) cornerRadii:CGSizeMake(20, 20)];
    textContainer.exclusionPaths = @[path1, path2];
    
    [layoutManager addTextContainer:textContainer];
    
    _textView = [[UITextView alloc] initWithFrame:CGRectMake(10, 10, width, height) textContainer:textContainer];
    _textView.backgroundColor = [UIColor orangeColor];
    _textView.font = [UIFont systemFontOfSize:18];
    [self.view addSubview:_textView];
}

这里画出来了两个区域,左上角上一个矩形不带圆角,右下角是一个左上角带圆角的矩形。可以看到,在这两个区域寸草不生了。


image.png

作用:可以做出简单的图文混排,只要把留出来的位置放上图片就可以了!

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

推荐阅读更多精彩内容