实现方案
在iOS6中, UILabel, UITextField, UITextView都是基于string Drawing 和 WebKit构建的
iOS7, 苹果引入了Textkit, TextKit位于Core Text之上, TextViews(指UILabel, UITextField, UITextView等文本控件)之下, 相比于TextViews直接提供的api, TextKit有更灵活的接口, 能够实现文字排版和渲染. 由图可以看出, UITextFiled是基于Textkit实现的, 完全可以用Textkit将UILabel配置成一个UITextFiled.
Text Kit
- Text View是用来显示文本内容的控件,主要包括UILabel、UITextView和UITextField。
- Text containers对应着NSTextContainer类。NSTextContainer定义了文本可以排版的区域。一般来说,都是矩形区域,当然,也可以根据需求,通过子类化NSTextContainer来创建别的一些形状,例如圆形、不规则的形状等。NSTextContainer不仅可以创建文本可以填充的区域,它还维护着一个数组——该数组定义了一个区域,排版的时候文字不会填充该区域,因此,我们可以在排版文字的时候,填充非文本元素(例如图片,如图4所示)。
- Layout manager对应着NSLayoutManager类。该类负责对文字进行编辑排版处理——通过将存储在NSTextStorage中的数据转换为可以在视图控件中显示的文本内容,并把统一的字符编码映射到对应的字形(glyphs)上,然后将字形排版到NSTextContainer定义的区域中。
- Text storage对应着NSTextStorage类。该类定义了Text Kit扩展文本处理系统中的基本存储机制。NSTextStorage继承自NSmutableAttributedString,主要用来存储文本的字符和相关属性。另外,当NSTextStorage中的字符或属性发生了改变,会通知NSLayoutManager,进而做到文本内容的显示更新。
这其实就是一个MVC模型, 通常情况下, NSTextStorage、NSLayoutManager和NSTextContainer是一一对应的, 当然在需要分页排版的时候, 也可以一对多.
Core Text
从实现上来看, Text Kit 和Core Text都可以实现将UILabel配置成UITextFiled那样的控件, Core Text是将文本直接渲染到图形上下文, 功能更加强大, 性能更好, 能够完全控制每个字型(CTRun对象)的渲染, 但是接口也相对复杂. Text Kit是对Core Text的一层封装, 能够解决一些简单文字的排版问题. 考虑到项目需求, TextKit足以完成.
字型(Glyphs)
字符 + 字体 = 字型
功能实现
文本显示
配置NSTextStorage、NSLayoutManager和NSTextContainer的依赖关系:
[_textStorage addLayoutManager:_layoutManager];
[_layoutManager addTextContainer:_textContainer];
自定义UILabel 的drawTextInRect:方法
- (void)drawTextInRect:(CGRect)rect {
NSRange range = NSMakeRange(0, self.textStorage.length);
//绘制背景
[_layoutManager drawBackgroundForGlyphRange:range atPoint:CGPointMake(kLeftPadding, kTopPadding)];
//绘制文字
if (_textStorage.length > 0) {
[_layoutManager drawGlyphsForGlyphRange:range atPoint:CGPointMake(kLeftPadding, kTopPadding)];
}
}
文本滚动
当文本超过输入框的宽度时, 继续编辑文字需要有文字滚动, 其实就是在绘制的时候, 修改绘制起点, 以达到滚动的效果
[_layoutManager drawGlyphsForGlyphRange:range atPoint:CGPointMake(kLeftPadding, kTopPadding)];
文本编辑
insert
[_textStorage insertAttributedString:attrString atIndex:_glyphIndex];
- 文本不超过输入框的 insert, 此时的 insert 只需修改NSTextStorage中的字符, 然后刷新光标位置即可:
2.文本超过输入框大小, 最后一个字符可见, 即左边有隐藏文字, 右边无隐藏文字, 此时仍然是修改NSTextStorage中的字符, 还需要计算滚动量(表示了左边隐藏的宽度):
3.文本超过输入框大小, 最后一个字符不可见, 即左边有隐藏文字, 右边也有隐藏文字, 此时需要判断: 在该位置insert 了新的文本之后, 新的光标是否在输入框范围之内. 如果还在输入框范围之内, 那么就是修改NSTextStorage中的字符即可.
否则, 还需要计算滚动量:
综上: 其实可以合并成两种情况:
1.插入点追加了文字之后, 小于label的MaxX, 则直接在后面追加文字, 更新光标位置即可
2.插入点追加了文字之后, 大于Label的MaxX, 则右边界固定, 光标移动到最右端, 文字向左滚动
Delete
[_textStorage deleteCharactersInRange:range];
1.文本不超过输入框的情况下的删除, 直接修改 NSTextStorage 中的字符即可:
2.文本超过输入框 delete, 最后一个字符可见, 即左边有隐藏文字, 右边无隐藏文字. 这种情况需要判断删除后的文本有没有超过输入框, 如果仍然超过了, 删除当前字符, 保持最后一个字符位置不变(文字右边固定), 计算新的滚动量, 表现为文字向右滚动. 如果没有超过, 删除当前字符, 将文字全部显示出来, 表现为向右滚动:
3.文本超过输入框 delete, 最后一个字符不可见, 即左边有隐藏文字, 右边也有隐藏文字. 这种情况需要判断删除了当前文字之后, 光标位置有没有超出文本框的范围(到了左边界以左), 如果仍在输入框范围, 则只需要修改 NSTextStorage 的字符即可:
否则, 需要计算滚动量, 使得滚动后的光标正好在输入框的起始位置(数值为0)
综上: 根据最后一个字符是否可见, 分为左端固定与右端固定的情况
Touch
touch 文本后, 需要计算出 touch 的字符, 以及该字符的 frame, 然后将光标更新到这个位置, 根据touch坐标获取被 touch 的字符 index:
NSInterge index= [_layoutManager glyphIndexForPoint:point inTextContainer:_textContainer];
根据字符获取字符的 frame:
CGRect bounding =[_layoutManager boundingRectForGlyphRange:range inTextContainer:_textContainer];
粘贴菜单
粘贴操作就是读取系统粘贴板的字符串, insert 操作.
手势
主要参考了UITextField 的行为:
- 当光标在某一位置时, 再次点击这个位置, 弹出菜单
- 长按光标, 可左右移动光标, 停止移动时, 弹出菜单
自定义属性
- set text: 先clear文本, 再insert 新的文本
- set attributeText: 调用[_textStorage setAttributedString:attributedText]方法
注意: 这里不能clear 之后 insert, 因为 insert 的参数是 NSString - set font: 设置 font 会影响显示区域---textContainer, 文本属性---textStorage的属性, 还要考虑以下情况:
1 文本已经超过输入框, 这时改变了 font大小, 那么左边隐藏的宽度需要重新计算
2 placeholder 情况下, 改变了 font 大小, 导致 placeholder 超过了输入框宽度, 这个时候要使用自适应的大小 - set placeholder: 需要注意已经是 placeholder 的状态下, 要更换新的 placeholder, 然后刷新显示
- set textColor: 会影响文本属性---textStorage的属性
- set numberOfLines: 此版本目前只支持单行
- set leftView: 设置后, 要刷新输入框的布局
- set leftViewInsets: 下边距和右边距是固定的
- set clearButtonMode: 设置后, 要刷新输入框的布局
- set placeholderColor: 需要注意当前有可能是 placeholder 状态, 那么会影响文本属性---textStorage的属性
总结: 每个属性的设置, 都要考虑是否会影响(刷新)当前显示, 显示包括文本属性(颜色, 大小, 绘制起点等)和布局
遇到的问题
- 关于 attributeText, 输入框是基于 UILabel 的, 一旦设置了 text, attributeText 也会被改变, 同样的, 一旦设置了 attributeText, text 也被改变了.
- 当 textstorage 的文本为空的时候, 执行 setAttributeString 和 insertAttributeString后, text为空(不会被设置), 但是 textstorage 不为空的时候, 执行这些操作后, text会被自动设置
- NSLayoutManager 的方法- (CGRect)boundingRectForGlyphRange:(NSRange)glyphRange inTextContainer:(NSTextContainer *)container 的行为很奇怪, 这个方法是获取某一段文字的矩形边框的 frame, 但是返回值的 width, height 的值会不准确, 尤其是包含 emoji 的时候, 使用过程中发现, minX 是准确的.
注意: NSString 的sizeWithAttributes方法计算的数值和这个方法是一致的, 即使有 emoji 也是准确的, 可以考虑用这个方法替代 - NSLayoutManager 的方法- (NSUInteger)glyphIndexForPoint:(CGPoint)point inTextContainer:(NSTextContainer *)container是根据 point 获取字符的 index, 如果文本中含有花漾字, 会计算错误.
- 当文本中含有中文字符的时候, 显示区域会扩大一点点, 但是使用察觉不到, Stack Overflow 上看到有人提过, 疑似系统 bug.