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。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353