iOS 表情键盘实现
需求背景
最近有需求,需要扩展 app 评论模块,加入自定义 emoji 键盘功能,最终希望评论模块支持系统emoji、自定义静态emoji、动态emoji、@好友关键词高亮功能。另外消费端(详情页评论列表、评论浮层、动态列表的评论透出模块等)支持上述元素的展示,额外需要支持高亮关键词的点击跳转功能。
此需求技术难度其实不大,但却是个精细的活儿,细节做到 100% 也不是很容易,下面逐一介绍各子功能的实现。
表情键盘
表情键盘的布局分 2 块,一个可翻页的 UIScrollView 来展示emoji集合、底部一个 toolbar 来做表情包切换(当然暂时我们只有一个表情包)和删除功能,每个emoji 对应一个 UIButton,点击事件通过代理传给评论框,用于输入:
需要注意的是键盘的调用方式,一般来讲可以通过 inputView 来设置自定义 view 作为键盘:
textView.inputView = self.emojiKeyboard
但是这种方式有个问题,键盘的切换动画无法自定义,当键盘 A 切换成 B 时,系统的切换动画是 先pop A,再push B,虽然动画时间不长,但是仍然引起了产品的注意。所以只能换一种思路实现“表情键盘”的功能,即 emojiKeyboard 以正常UIView 的形式,贴在评论框下面,当系统键盘调用时隐藏,系统键盘隐藏时显示。二者的键盘切换动画如下:
Emoji 的转义存储与显示
Emoji 的显示是一个纯粹的客户端的功能,而输入框输入的内容,以及与服务端交互的都是静态纯文本的。例如当用户输入plainText:“啊啊啊[赞]”,输入框里会翻译成富文本displyText:“啊啊啊👍”,而复制粘贴、离线存储,发送给服务端的都是 plainText。
这里需要实现 plainText 和 displyText 的相互转换,再此之前,先对表情包里每个emoji进行映射,在iOS 里面使用plist文件来存储映射关系,例如一个 [开心] 表情会存以下数据:
<dict>
<key>desc</key>
<string>[开心]</string>
<key>image</key>
<string>003.png</string>
</dict>
以上数据内容表示 [开心] 对应图片 003.png 对应的表情,而其他所有表情也都是[xxx]形式,方便后边正则匹配替换。
基于以上映射关系,一段 plainText 转换成 displyText 的过程描述如下:
- 正则匹配 “\[\w+\]” 模式的所有子串,得到数组 resultArr
- 对于 resultArr 里每个子串 desc,查找是否有对应的 emoji 表情
- 如果有对应的 emoji 表情,则使用 emoji 对应的 image 生产一个 NSTextAttachment,替换掉原来的子串 desc 即可
而 displyText 转换成 plainText 的过程则如下:
- enumerateAttribute 方法遍历富文本 displyText ,得到所有 NSTextAttachment 类型的子串 attachment
- 如果 attachment 是我们定义的 EmojiAttachment 类型,则取出 attachment.desc,将 attachment 替换成 attachment.desc 即可
EmojiAttachment类的定义如下:
class EmojiAttachment: NSTextAttachment {
@objc dynamic var imageName = ""
@objc dynamic var desc = ""
...
}
如此我们实现了 plainText 和 displyText 的相互转换,接下来只需要在表情键盘每次输入后,将评论框内光标位置的内容,替换成输入内容 desc 对应的 attachment 即可。
评论框的细节
评论框就是一个 UITextView,但是需要处理以下问题:
- 实时 plainText 和 displyText 的相互转换
- @好友 高亮
- 光标不能选到 @好友 之间
- @好友 当作一个整体选择或删除
为此,需要实现 UITextViewDelegate 的3个代理方法:
- (void)textViewDidChange:(UITextView *)textView;
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text;
- (void)textViewDidChangeSelection:(UITextView *)textView;
具体实现细节不再赘述,你可在 DUEmojiAtTextViewModel.swift 文件里查看相关细节。
图文混排
消费端显示评论列表有多个应用场景:详情页、评论浮层、列表页面的评论列表。这些场景都需要处理图文混排的问题,防止出现 emoji、静态文本、高亮文本 之间出现排列不稳定的问题。
在进行混排之前需要先了解下 UIFont 的基本知识:
UIFont
先看下关于 UIFont size 的一些属性:
open var pointSize: CGFloat { get } // 字体大小
open var ascender: CGFloat { get } // 基准线以上的高度,一般 >0
open var descender: CGFloat { get } // 基准线以下的高度,一般 <0
open var lineHeight: CGFloat { get } // 字体所占高度
关于具体含义可参考下图(其中 baseline 就是基准线):
可以看下个参数的具体值:
let f = UIFont.systemFont(ofSize: 20)
print(f.ascender, f.descender, f.lineHeight, f.pointSize)
可以发现 lineHeight = ascender - descender
(因为 descender 是负值)
那么在文本之间插入 emoji 图,其 size 可以取 lineHeight
的值来计算,有3种对齐方式:上对齐、下对齐以及基准线对齐,NSTextAttachment默认也是基线对齐,attachment.bounds的坐标原点Y轴是和基线持平,是 coregraphics 的坐标系,如果将 bounds 高度设置为lineHeight,那么整个行高将会被拉大到lineHeight+decent的 高度,可以改成底部对齐,将 bound.origin.y 移动descender距离即可:
emojiAttachment.bounds = CGRect(x: 0, y: descender, width: emojiSize, height: emojiSize)
注意 UIFont 的 bounds 与UIView 的 bounds 不是同一个概念,坐标系似乎就不一样(存疑)
具体实现
输入框的图文混排基于 UITextView,直接通过上文的方法转成 displyText,赋给 textView 即可。
消费端则需要顾及更多问题例如高度计算,行高稳定、性能稳定等问题。旧版的 TTTAttributedLabel 由于不支持 自定义 emoji 不能满足需求,因而使用 YYLabel 替换:
private lazy var contentLabel: YYLabel = {
let yyLabel = YYLabel()
yyLabel.numberOfLines = 0
yyLabel.isUserInteractionEnabled = true
yyLabel.preferredMaxLayoutWidth = self.layout.content_width
contentView.addSubview(yyLabel)
let modifier = YYTextLinePositionSimpleModifier()
modifier.fixedLineHeight = DUCommentTextCell.contentFont.lineHeight + 4 // +行间距高
yyLabel.linePositionModifier = modifier
return yyLabel
}()
赋值的代码如下:
let mattributedText = EmojiDecoder().decode(with: plainText, font: contentFont, imagesCache: NSCache<AnyObject, AnyObject>()).2
let ranges = plainText.matchedAtRanges()
let highlightColor = UIColor.du_Primary01
// @好友高亮
for r in ranges {
mattributedText.yy_setTextHighlight(r,
color: highlightColor,
backgroundColor: highlightColor.withAlphaComponent(0.4)) { (view, text, range, rect) in
}
}
高度计算如下:
static func OccupyHeight(_ attributedText : NSAttributedString, font: UIFont,_ width : CGFloat) -> CGFloat{
let attributedString: NSMutableAttributedString = NSMutableAttributedString(attributedString: attributedText)
attributedString.yy_lineSpacing = 2
let modifier = YYTextLinePositionSimpleModifier()
modifier.fixedLineHeight = font.lineHeight + 4
let container = YYTextContainer()
container.size = CGSize(width: width, height: 9999)
container.linePositionModifier = modifier
let layout: YYTextLayout? = YYTextLayout(container: container, text: attributedString)
return layout?.textBoundingRect.size.height ?? 0
}
如此,就能在评论列表里正确显示带 emoji 的消息列表了。
其它细节
支持动图
动图 emoji 的显示和静图 emoji 的显示区别不大,但是动图 emoji 借用用 imageView 来显示动图,在 YYLabel 中使用则更加简单些:
let gifImage = self.getGifImageWith(imageName: imageName, imagesCache: imagesCache)
let imageView = YYAnimatedImageView(image: gifImage)
imageView.frame = CGRect(x: 0, y: font.descender, width: font.lineHeight, height: font.lineHeight)
let attachText = NSMutableAttributedString.yy_attachmentString(withContent: imageView, contentMode: .center, attachmentSize: imageView.frame.size, alignTo: font, alignment: .center)
性能优化
在图片较多,特别是动图较多时,列表会有明显卡顿问题。主要瓶颈在2个地方:
- plainText 和 displyText 相互转换会频繁查存 emoji 信息数据数组。解决方法是加 2 个 NSCache 缓存,一个存储 desc 到 imageName 的关系映射表,一个存最近取的图片缓存
- 频繁创建 image 和 imageView。解决方法还是 NSCache 缓存,该缓存对象由展示评论的 tableView 或 view 持有,用于存消费端要展示的动态 emoji
获取字符串长度
在评论框做选择、剪切、插入、替换等操作时,需要频繁获取字符串的长度。很多场景下我们通过text.count
来获取字符串的长度,但是在包含系统 emoji 情况下得到的结果并不准确,很容易出现乱码。正确的获取方法应该是 下面方法中的一种即可:
let text = "123😌😍😍😘89"
NSAttributedString(string: text).length,
NSString(format: "%@", text).length,
text.utf16.count,
键盘动画
主要有2个坑需要注意:
- 搜狗键盘兼容问题,搜狗键盘弹出和收起会发送多个键盘通知,可能会重复执行某些代码
- 动画,iOS 在键盘弹出收起做了默认动画优化,举个例子,在键盘弹出时,一般我们会将当前 textView 抬高,一般会放在动画里面写:
UIView.animate(withDuration: 0.25, animations: {
view.frame.height -= keybordHeight;
}
这样写是为了迎合键盘弹出动画的效果,其实直接view.frame.height -= keybordHeight;
这样写也行,因为iOS 会默认给加上动画。这样确实方便许多,但是某些场景会造成一些意外的问题,特别是结合搜狗键盘乱发通知的问题会产生怪异键盘动画。
后记
如果不使用 YYText 库,如何实现支持点击、高亮、图文混排的 label?可以使用 CoreText,通过遍历处理每个 CTFrame 的每个 CTline 的每个 CTRun 来逐一处理每个文本块。然而这样做需要处理的细节是在太多了,很容易出现欠考虑的 bug 或难处理的细节(例如高度问题,例如emoji、特殊字符的分词问题),可以做用作 demo 预研,难以用于项目迭代中,有兴趣的可以玩玩。
目前表情键盘功能已在 4.43.0 版本带上,动图支持 等待产品和设计进一步解锁https://learnopengl-cn.readthedocs.io/zh/latest/01 Getting started/05 Shaders/)
既已览卷至此,何不品评一二: