IM项目中的自定义小表情实现
前言
在im项目(Android)中,用户发消息,喜欢在文字中嵌入一些小表情,以表达发送者当时的情感。除了系统输入法自带的emoji表情(emoji其实是特殊的文字)外。项目希望带一些更漂亮,带产品特色文化的自定义小表情(小图片)。
图片嵌入在文字中显示,很明显可以使用ImageSpan去实现该效果。
效果如图:
效果图实现:
实现上,主要问题是,实现文字与表情的转换。因此需要定义一套对应关系。
这里采用类似微信的实现,[key]对应表情。比如: [微笑] 对应 😊。
工具类:
object EmoticonHelper {
private const val SIGN_LEFT = '['
private const val SIGN_RIGHT = ']'
private const val ZOOM_SIZE = 1.3F
private const val CACHE_SIZE = 60
private val def = R.drawable.im_emoticon_def
private val keyList = ArrayList<String>()
private val cache = LruCache<String, Drawable>(CACHE_SIZE)
// 表情。
private val map = hashMapOf(
"微笑" kto R.drawable.im_emoticon_wx,
"撇嘴" kto R.drawable.im_emoticon_pz,
"色" kto R.drawable.im_emoticon_se,
"得意" kto R.drawable.im_emoticon_dy,
"大哭" kto R.drawable.im_emoticon_dk,
"发呆" kto R.drawable.im_emoticon_fd,
"闭嘴" kto R.drawable.im_emoticon_bz,
"睡" kto R.drawable.im_emoticon_shui,
"流泪" kto R.drawable.im_emoticon_ll,
"尴尬" kto R.drawable.im_emoticon_gg,
"发怒" kto R.drawable.im_emoticon_fn,
"调皮" kto R.drawable.im_emoticon_tb,
"惊讶" kto R.drawable.im_emoticon_jy,
"囧" kto R.drawable.im_emoticon_jiong,
"吐" kto R.drawable.im_emoticon_tu,
"哇" kto R.drawable.im_emoticon_wa,
"偷笑" kto R.drawable.im_emoticon_tx,
"愉快" kto R.drawable.im_emoticon_yk,
"白眼" kto R.drawable.im_emoticon_by,
"恐惧" kto R.drawable.im_emoticon_kj,
"衰" kto R.drawable.im_emoticon_shuai,
"笑哭" kto R.drawable.im_emoticon_kx,
"无语" kto R.drawable.im_emoticon_ww,
"晕" kto R.drawable.im_emoticon_yun,
"困" kto R.drawable.im_emoticon_kun,
"亲亲" kto R.drawable.im_emoticon_qq,
"庆祝" kto R.drawable.im_emoticon_qz,
"汗" kto R.drawable.im_emoticon_han,
"咒骂" kto R.drawable.im_emoticon_zm,
"嘘" kto R.drawable.im_emoticon_xu,
"可怜" kto R.drawable.im_emoticon_kl,
"失望" kto R.drawable.im_emoticon_sw,
"憨笑" kto R.drawable.im_emoticon_hx,
"呲牙" kto R.drawable.im_emoticon_cy,
"拥抱" kto R.drawable.im_emoticon_yb,
"思考" kto R.drawable.im_emoticon_sk,
"口罩" kto R.drawable.im_emoticon_kz,
"悠闲" kto R.drawable.im_emoticon_yxi,
"委屈" kto R.drawable.im_emoticon_wq,
"吐舌头" kto R.drawable.im_emoticon_tst,
"鬼脸" kto R.drawable.im_emoticon_gl,
"阴险" kto R.drawable.im_emoticon_yx,
"啤酒" kto R.drawable.im_emoticon_pj,
"玫瑰" kto R.drawable.im_emoticon_mg,
"凋谢" kto R.drawable.im_emoticon_dx,
"太阳" kto R.drawable.im_emoticon_ty,
"火" kto R.drawable.im_emoticon_huo,
"礼物" kto R.drawable.im_emoticon_lw,
"爱心" kto R.drawable.im_emoticon_ax,
"心碎" kto R.drawable.im_emoticon_xs,
"强" kto R.drawable.im_emoticon_qiang,
"弱" kto R.drawable.im_emoticon_ruo,
"鼓掌" kto R.drawable.im_emoticon_gz,
"OK" kto R.drawable.im_emoticon_ok,
"蛋糕" kto R.drawable.im_emoticon_dg,
"合十" kto R.drawable.im_emoticon_h10,
"胜利" kto R.drawable.im_emoticon_sl,
"握手" kto R.drawable.im_emoticon_ws,
"红包" kto R.drawable.im_emoticon_hb,
"钱" kto R.drawable.im_emoticon_qian
)
/**
* 转换表情。
*/
fun transEmoticon(context: Context, text: CharSequence, size: Float): Spannable {
val ss = SpannableString.valueOf(text)!!
spanEmoticon(context, ss, 0, ss.length, size)
return ss
}
/**
* span 表情。返回最后一个span的末尾位置(不包含)。
*/
fun spanEmoticon(context: Context, sp: Spannable, startSp: Int, endSp: Int, size: Float): Int {
if (endSp - startSp <= 2) return startSp
var last = startSp
val wh = size.toZoom()
var start = sp.indexOf(SIGN_LEFT, startSp)
while (start > -1) {
val end = sp.indexOf(SIGN_RIGHT, start)
if (end <= start || end >= endSp) break
val key = sp.substring(start + 1, end)
if (key in map.keys) {
val drawable = getDrawable(context, key, wh) ?: continue
sp.setSpan(ImageSpan(drawable), start, end + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
last = end + 1
}
start = sp.indexOf(SIGN_LEFT, start + 1)
}
return last
}
/**
* 获取表情列表。
*/
fun getEmoticonList(): List<Emoticon> {
return keyList.map { Emoticon(it, it.toCode(), map[it] ?: def) }
}
class Emoticon(val key: String, val code: String, @DrawableRes val resId: Int)
//---------private method-----------//
/**
* 获取 Drawable 并根据 key 和 大小 缓存。
*/
private fun getDrawable(context: Context, key: String, size: Int): Drawable? {
return cache[key + size] ?: ContextCompat.getDrawable(context, map[key] ?: def)?.apply {
cache.put(key + size, this)
this.setBounds(0, 0, size, size)
}
}
/**
* 转换成 code。
*/
private fun String.toCode() = SIGN_LEFT + this + SIGN_RIGHT
/**
* 缩放大小。
*/
private fun Float.toZoom() = (this * ZOOM_SIZE).toInt()
/**
* K-V 对,同时保存 key。
*/
private infix fun String.kto(that: Int): Pair<String, Int> {
keyList.add(this)
return Pair(this, that)
}
}
主要就是做一个转换功能。同时需要考虑一下性能优化,否则效率低就会卡顿。
PS:这里优化了 查询转换策略 和 Drawable复用策略,供参考。
注:Spannable有关的操作,少用String。使用CharSequence,因为不一定是String。用SpannableString.valueOf(text)
代替new SpannableString(text)
使用:
在TextView上使用,也写个BindingAdapter方法。
@BindingAdapter(value = ["binding_text_emoticon"], requireAll = true)
fun TextView.setEmoticonText(text: CharSequence?) {
if (this.text?.toString() != text) {
this.text = if (text != null) {
EmoticonHelper.transEmoticon(context, text, textSize)
} else {
""
}
}
}
@BindingAdapter(value = ["binding_text_emoticon", "binding_text_emoticon_ellipsize"], requireAll = true)
fun TextView.setEmoticonText(text: CharSequence?, avail: Float) {
if (this.text?.toString() != text) {
this.text = if (text != null) {
val emo = EmoticonHelper.transEmoticon(context, text, textSize)
TextUtils.ellipsize(emo, paint, avail, TextUtils.TruncateAt.END)
} else {
""
}
}
}
注:其中TextUtils.ellipsize(emo, paint, avail, TextUtils.TruncateAt.END)
是为了解决表情在单行textView显示不下时显“...”.的问题。直接默认用TextView的ellipsize
属性,对表情(ImageSpan)无效,会截成半个。
输入框:
表情要在输入框中显示。根据输入code,自动转换成表情(ImageSpan)。
方案1:给EditView设置监听,在文字变化后将文字做个转换。这样效率超低,输入越多越卡。否决!
方案2:根据具体变化的文本设置转换。
editText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (s !is Spannable) return
// 输入会能影响到的包含前后几格。
val end = start + count
val sl = s.lastIndexOf('[', start)
val st = if (sl > -1 && start <= s.indexOf(']', sl)) {
sl
} else {
start
}
val er = s.indexOf(']', end)
val en = if (er > -1 && s.lastIndexOf('[', er) in 0 until end) {
er + 1
} else {
end
}
val last = EmoticonHelper.spanEmoticon(editText.context, s, st, en, editText.textSize)
// 如果输入影响后几格,即连同后几格一起变成表情。将光标置于表情末尾。
if (last > end && last <= s.length) {
Selection.setSelection(s, last)
}
}
})
注:当前输入的东西(可能是复制过来的多个字符)。可能会影响到前面或后面的几个字符。
例如:原本文本:“[微]” ,在“微”后面输入一个“笑”,实际文本是“[微笑]”满足code。就会自动转变成😊表情。
此时,光标在“笑”后面,需要代码控制把光标挪到“]”的后面。才符合实际输入效果。
表情选择框操作:
删除:模拟退格,表情需要整个整个删。
editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
插入:将code插入到光标末尾。
editText.run { text.insert(selectionEnd, code) }
其他:
转发到微信,有些表情微信里没有对应。转换成emoji代替。
// 转发微信需要替换成 emoji 的表情。
private val emojiMap = hashMapOf(
"恐惧" to "\uD83D\uDE31",
"笑哭" to "\uD83D\uDE02",
"无语" to "\uD83D\uDE12",
"庆祝" to "\uD83C\uDF89",
"失望" to "\uD83D\uDE14",
"思考" to "\uD83E\uDD14",
"口罩" to "\uD83D\uDE37",
"吐舌头" to "\uD83D\uDE1D",
"鬼脸" to "\uD83D\uDC7B",
"火" to "\uD83D\uDD25",
"合十" to "\uD83D\uDE4F",
"钱" to "\uD83D\uDCB0",
"礼物" to "\uD83C\uDF81"
)
/**
* 转发微信。不支持的 code 转化为 emoji 。
*/
fun transCodeToEmoji(text: String): String {
var str = text
for (key in emojiMap.keys) {
val code = key.toCode()
if (str.contains(code)) {
str = str.replace(code, emojiMap[key].orEmpty())
}
}
return str
}
总结:
要点:
- ImageSpan实现表情的显示。😊
- code与Drawable的对应关系。
- Drawable性能的考量。
- 表情在EditText里输入的几个优化点。
- 微信转发时替换code。