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
    }

总结:

要点:

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

推荐阅读更多精彩内容