前言

在im项目(Android)中,用户发消息,喜欢在文字中嵌入一些小表情,以表达发送者当时的情感。除了系统输入法自带的emoji表情(emoji其实是特殊的文字)外。项目希望带一些更漂亮,带产品特色文化的自定义小表情(小图片)。

图片嵌入在文字中显示,很明显可以使用ImageSpan去实现该效果。

效果如图:

效果图

实现:

实现上,主要问题是,实现文字与表情的转换。因此需要定义一套对应关系。

这里采用类似微信的实现,[key]对应表情。比如: [微笑] 对应 😊。

工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
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方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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:根据具体变化的文本设置转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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。就会自动转变成😊表情。
此时,光标在“笑”后面,需要代码控制把光标挪到“]”的后面。才符合实际输入效果。

表情选择框操作

删除:模拟退格,表情需要整个整个删。

1
editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))

插入:将code插入到光标末尾。

1
editText.run { text.insert(selectionEnd, code) }

其他:

转发到微信,有些表情微信里没有对应。转换成emoji代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 转发微信需要替换成 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
}

总结:

要点:

  1. ImageSpan实现表情的显示。😊
  2. code与Drawable的对应关系。
  3. Drawable性能的考量。
  4. 表情在EditText里输入的几个优化点。
  5. 微信转发时替换code。