最近有一个地方需要自定义文字编辑器,所以使用了iOS7开始支持的UITextKit来绘制,同时也遇到不少的坑,这里来说说我遇到的几个坑,以及解决方案。源码在Github。
UITextView 分段绘制原理分析
首先,我们来看下NSLayoutManager
里面的几个方法:
1 | - (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin; |
可以看出来,无论是绘制方法,还是布局方法,都是有个范围选择,由此可以知道,UITextView的绘制过程绝对不是一次性绘制(对比YYText)。重写该方法也可以看出来UITextView是分多段绘制的。
现在我们来简单的做几个实验:
重写- (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin;
,并使用该LayoutManager创建UITextView:
1 | DDAttachmentLayoutManager *layoutManager = [[DDAttachmentLayoutManager alloc] init]; |
放入一段足够长的文字,在我们滑动的过程中,UITextView会分多次调用draw方法,这样显著降低了损耗和提升了性能,把多次绘制过程分散到滑动的过程中。
以上是纯文本的结果,那如果我们放入其他类型的数据呢?在这里,我放入多个NSTextAttachment
自定义类型的数据。重复以上的测试。
结果是,当缓慢下拉的时候,同样是分段载入的,而且attachment
往往作为单独的一段来绘制。但是有个不同的地方就是,可以看到contentSize
在变化,而且可以看到右边的进度条在接近底部的时候忽然间回到上面,并且变短了。
由此可知在开始的时候,UITextView会拥有一个预期的大小,在加载过程中如果碰到attachment导致这个大小不符合,就会将下面一段内容加入计算,重新得出contentSize
。这样会给我们带来一些麻烦,不能准确的获得contentSize
,导致一些bug,解决方案很简单,我们先看下面另一个问题。
如果我们进入的时候是在最后一行呢。同样也是有这样的逻辑,这样的逻辑对于自定义的AttachmentView来说会有很多的问题,最大的问题就是在contentSize
变化的时候,subview位置错误。
如何解决这样的问题,只要我们强制让UITextView布局整个的富文本就行了。
1 | [self.textView.layoutManager ensureLayoutForCharacterRange:NSMakeRange(0, self.textView.textStorage.length)]; |
自定义富文本编辑器
首先我们需要实现自己的Attachment,主要功能是实现占位符的大小。
1 | @interface DDTextAttachment : NSTextAttachment |
然后重写DDAttachmentLayoutManager
1 | - (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow |
在这里把需要展示的视图,按照位置贴到父视图上。
这样就是整个方案的思路,具体实现可以参考Github
内存优化方案
最开始,我采用的是把所有的attachment view都实例化出来,再贴到textView上,但是当整个文章比较长,并且结构复杂的时候,会发现占用很多的内存,联想到苹果的分段绘制和tableView的reuse,我决定把整个框架改写为可重用的模式。
首先,我们模仿tableView定义接口。
1 | - (void)registerClass:(Class)cls forAttachmentViewWithReuseIdentifier:(NSString *)identifier; |
1 | // protocol |
然后,重写AttachmentLayoutManager绘制方法,在需要绘制的时候再去生成视图。
1 | - (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow |
然后,重写contentOffset,在其变化的时候检测是否有视图需要显示,或者是否有视图已经移出屏幕。
1 | - (void)setContentOffset:(CGPoint)contentOffset { |
[1] 当视图不在屏幕显示区域内的时候,移出父视图
[2] 当视图在显示区域并且没有变化的时候不需要重用操作。
[3] 重用视图,要求重绘这个占位符。
这样,又会转移到绘制的地方,最终会调用reuse的代码。经过实验测试,原来可能实例化的很多视图,现在同时存在的一般维持在2个左右,大大降低了内存占用。
这样的做法对性能的影响:
- 在我使用UIImageView的时候,完全感觉不出来。
- 在我使用UICollectionView的时候,在iPhone 4s手机上会有一点点的感觉,但是几乎难以察觉。
所以对这次的优化还是非常满意的。
ios8 deleteBackward
这是应该是苹果的一个bug,从iOS8.0-8.3系统,重写UITextView,UIInput协议的deleteBackward的时候,发现删除的时候不能被触发,而且仅仅只在这几个系统下才有这样的问题。stackoverflow上提出的解决方案是重写一个私有api,这个不会被苹果AppStore拒绝。
1 | - (BOOL)keyboardInputShouldDelete:(TextField *)textField { |
ios7 boudingRect
在iOS7上,要计算文字的高度,被换成了新的方法boudingRect
,但是在iOS7的系统上,还是会有错误的。
如果你是UILabel,那么没有问题,但是,如果你使用的是UITextView,那么,两者的实际高度为不一致的,可以看的出来,在iOS7上,Label的绘制方式和UITextView还是不一样的。
要解决这个问题,只能实例化一个UITextView对象了:
1 | if ([[UIDevice currentDevice].systemVersion integerValue] == 7) { |
在某些场合,为了避免频繁的动态生成,可以使用NSCache做一层缓存。
1 | - (UITextView *)templateRepoEditorTextView { |
NSTextAlignmentJustified 两端对齐
在UITextView和UILabel的对齐样式属性里面,虽然没有说明禁止使用两端对齐的方式,但是其实是不支持的,如果需要支持,需要使用NSAttributedString来设置,而且只设置了对齐方式还是不能对齐的,还需要一个下划线的属性(这可能也是一个系统缺陷)。
1 | NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle defaultParagraphStyle] mutableCopy]; |