mirror of
https://github.com/Tencent/QMUI_Android.git
synced 2026-10-26 09:39:26 +08:00
feat: EmojiEditText
This commit is contained in:
@@ -15,7 +15,7 @@ object Dep {
|
||||
const val group = "com.qmuiteam"
|
||||
const val qmuiVer = "2.1.0.1"
|
||||
const val archVer = "2.1.0.2"
|
||||
const val typeVer = "0.1.0"
|
||||
const val typeVer = "0.1.0.1"
|
||||
const val composeCoreVer = "0.0.1"
|
||||
const val composeVer = "0.0.1.2"
|
||||
const val photoVer = "0.0.1.3"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.qmuiteam.qmuidemo.fragment.lab
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.qmuiteam.qmui.arch.annotation.LatestVisitRecord
|
||||
import com.qmuiteam.qmui.kotlin.*
|
||||
import com.qmuiteam.qmui.type.parser.EmojiTextParser
|
||||
import com.qmuiteam.qmui.type.view.EmojiEditText
|
||||
import com.qmuiteam.qmui.widget.QMUITopBarLayout
|
||||
import com.qmuiteam.qmuidemo.QDQQFaceManager
|
||||
import com.qmuiteam.qmuidemo.R
|
||||
import com.qmuiteam.qmuidemo.base.BaseFragment
|
||||
import com.qmuiteam.qmuidemo.lib.annotation.Widget
|
||||
|
||||
@Widget(name = "EmojiEditText", iconRes = R.mipmap.icon_grid_in_progress)
|
||||
@LatestVisitRecord
|
||||
class QDEmojiInputFragment : BaseFragment() {
|
||||
override fun onCreateView(): View {
|
||||
return EmojiLayout(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
class EmojiLayout(context: Context): ConstraintLayout(context){
|
||||
val topBarLayout = QMUITopBarLayout(context).apply {
|
||||
fitsSystemWindows = true
|
||||
id = View.generateViewId()
|
||||
}
|
||||
val editText = EmojiEditText(context).apply {
|
||||
gravity = Gravity.TOP or Gravity.LEFT
|
||||
textParser = EmojiTextParser(QDQQFaceManager.getInstance()) { true }
|
||||
}
|
||||
|
||||
val se = TextView(context).apply {
|
||||
text = "[色]"
|
||||
setPadding(0, dip(20), 0, dip(20))
|
||||
onClick {
|
||||
editText.replaceSelection("[色]")
|
||||
}
|
||||
}
|
||||
val weixiao = TextView(context).apply {
|
||||
text = "[微笑]"
|
||||
setPadding(0, dip(20), 0, dip(20))
|
||||
onClick {
|
||||
editText.replaceSelection("[微笑]")
|
||||
}
|
||||
}
|
||||
val daku = TextView(context).apply {
|
||||
text = "[大哭]"
|
||||
setPadding(0, dip(20), 0, dip(20))
|
||||
onClick {
|
||||
editText.replaceSelection("[大哭]")
|
||||
}
|
||||
}
|
||||
val delete = TextView(context).apply {
|
||||
text = "delete"
|
||||
setPadding(0, dip(20), 0, dip(20))
|
||||
onClick {
|
||||
editText.delete()
|
||||
}
|
||||
}
|
||||
val toolBar = LinearLayout(context).apply {
|
||||
id = View.generateViewId()
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
addView(se, LinearLayout.LayoutParams(0, wrapContent, 1f))
|
||||
addView(weixiao, LinearLayout.LayoutParams(0, wrapContent, 1f))
|
||||
addView(daku, LinearLayout.LayoutParams(0, wrapContent, 1f))
|
||||
addView(delete, LinearLayout.LayoutParams(0, wrapContent, 1f))
|
||||
}
|
||||
|
||||
init {
|
||||
addView(topBarLayout, LayoutParams(0, wrapContent).apply {
|
||||
alignParentHor()
|
||||
topToTop = constraintParentId
|
||||
})
|
||||
addView(toolBar, LayoutParams(0, wrapContent).apply {
|
||||
alignParentHor()
|
||||
bottomToBottom = constraintParentId
|
||||
})
|
||||
|
||||
addView(editText, LayoutParams(0, 0).apply {
|
||||
alignParentHor()
|
||||
topToBottom = topBarLayout.id
|
||||
bottomToTop = toolBar.id
|
||||
})
|
||||
|
||||
editText.setText("反反复复[微笑][色]发发发方法")
|
||||
}
|
||||
|
||||
fun handleClick(text: String){
|
||||
val origin = editText.text
|
||||
if(origin == null){
|
||||
editText.setText(text)
|
||||
}else{
|
||||
origin.append(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import com.qmuiteam.qmuidemo.fragment.lab.QDArchTestFragment;
|
||||
import com.qmuiteam.qmuidemo.fragment.lab.QDComposeTipFragment;
|
||||
import com.qmuiteam.qmuidemo.fragment.lab.QDContinuousNestedScrollFragment;
|
||||
import com.qmuiteam.qmuidemo.fragment.lab.QDEditorFragment;
|
||||
import com.qmuiteam.qmuidemo.fragment.lab.QDEmojiInputFragment;
|
||||
import com.qmuiteam.qmuidemo.fragment.lab.QDPhotoClipFragment;
|
||||
import com.qmuiteam.qmuidemo.fragment.lab.QDPhotoFragment;
|
||||
import com.qmuiteam.qmuidemo.fragment.lab.QDSchemeFragment;
|
||||
@@ -153,6 +154,7 @@ public class QDDataManager {
|
||||
mLabNames.add(QDPhotoFragment.class);
|
||||
mLabNames.add(QDPhotoClipFragment.class);
|
||||
mLabNames.add(QDEditorFragment.class);
|
||||
mLabNames.add(QDEmojiInputFragment.class);
|
||||
}
|
||||
|
||||
public QDItemDescription getDescription(Class<? extends BaseFragment> cls) {
|
||||
|
||||
@@ -36,6 +36,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(Dep.AndroidX.annotation)
|
||||
api(Dep.AndroidX.coreKtx)
|
||||
implementation(Dep.AndroidX.appcompat)
|
||||
implementation(Dep.AndroidX.annotation)
|
||||
implementation(Dep.AndroidX.coreKtx)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.qmuiteam.qmui.type.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.Editable
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.TextWatcher
|
||||
import android.text.style.ImageSpan
|
||||
import android.util.AttributeSet
|
||||
import android.util.SparseArray
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.util.putAll
|
||||
import androidx.core.util.valueIterator
|
||||
import com.qmuiteam.qmui.type.TypeModel
|
||||
import com.qmuiteam.qmui.type.element.Element
|
||||
import com.qmuiteam.qmui.type.element.EmojiElement
|
||||
import com.qmuiteam.qmui.type.parser.EmojiTextParser
|
||||
|
||||
open class EmojiEditText(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null
|
||||
) : AppCompatEditText(context, attributeSet) {
|
||||
|
||||
private var emojiModel: EmojiModel? = null
|
||||
private var isTextFirstSet: Boolean = false
|
||||
private var isTextManualSetting: Boolean = false
|
||||
|
||||
var textParser: EmojiTextParser? = null
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
if (isTextFirstSet) {
|
||||
setText(text, BufferType.EDITABLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val textWatcher = object : TextWatcher {
|
||||
|
||||
private val pendingRemoveRange = mutableListOf<Pair<Int, Int>>()
|
||||
private val pendingRemoveEmojis = mutableListOf<Emoji>()
|
||||
private val pendingAddEmojis = mutableListOf<Emoji>()
|
||||
private var isPendingRemoving = false
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
if (isPendingRemoving || isTextManualSetting) {
|
||||
return
|
||||
}
|
||||
val model = emojiModel
|
||||
if (s != null && model != null && count > 0) {
|
||||
val end = start + count
|
||||
var current = start
|
||||
while (current < end) {
|
||||
val emoji = model.getEmoji(current)
|
||||
if (emoji != null) {
|
||||
pendingRemoveEmojis.add(emoji)
|
||||
if (emoji.start < start) {
|
||||
pendingRemoveRange.add(emoji.start to start)
|
||||
}
|
||||
val emojiEnd = emoji.start + emoji.text.length
|
||||
if (emojiEnd >= end) {
|
||||
val offset = count - after
|
||||
pendingRemoveRange.add((end - offset) to (emojiEnd - offset))
|
||||
}
|
||||
current = emoji.start + emoji.text.length
|
||||
} else {
|
||||
current++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
if (isPendingRemoving || isTextManualSetting) {
|
||||
return
|
||||
}
|
||||
val model = emojiModel
|
||||
if (model != null) {
|
||||
var cur: Emoji? = model.end
|
||||
val offset = count - before
|
||||
val correctStartIndex = start + before
|
||||
while (cur != null && cur.start >= correctStartIndex) {
|
||||
cur.start += offset
|
||||
cur = cur.prev
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
val insertModel = textParser?.parse(s!!.subSequence(start, start + count))?.toEmojiModel(start)
|
||||
if (insertModel != null) {
|
||||
if (model != null) {
|
||||
model.merge(insertModel)
|
||||
} else {
|
||||
emojiModel = insertModel
|
||||
}
|
||||
insertModel.map.valueIterator().asSequence().filterTo(pendingAddEmojis) { true }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (isPendingRemoving || isTextManualSetting) {
|
||||
return
|
||||
}
|
||||
isPendingRemoving = true
|
||||
pendingRemoveRange.forEach {
|
||||
s?.removeRange(it.first, it.second)
|
||||
}
|
||||
pendingRemoveRange.clear()
|
||||
pendingRemoveEmojis.forEach {
|
||||
s?.removeSpan(it.span)
|
||||
}
|
||||
pendingRemoveEmojis.clear()
|
||||
pendingAddEmojis.forEach {
|
||||
s?.setSpan(it.span, it.start, it.start + it.text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
pendingAddEmojis.clear()
|
||||
fixSelection()
|
||||
isPendingRemoving = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
init {
|
||||
addTextChangedListener(textWatcher)
|
||||
}
|
||||
|
||||
fun replaceSelection(toInsert: CharSequence) {
|
||||
val origin = text
|
||||
if (origin == null) {
|
||||
setText(toInsert)
|
||||
} else {
|
||||
origin.replace(selectionStart, selectionEnd, toInsert)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val origin = text ?: return
|
||||
if (selectionStart != selectionEnd) {
|
||||
origin.replace(selectionStart, selectionEnd, "")
|
||||
} else if (selectionStart > 0) {
|
||||
origin.delete(selectionStart - 1, selectionEnd)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
isTextFirstSet = true
|
||||
val model = textParser?.parse(text)?.toEmojiModel(0)
|
||||
emojiModel = model
|
||||
val spannable = if (text is Spannable) {
|
||||
val spans = text.getSpans(0, text.length, EmojiSpan::class.java)
|
||||
for (span in spans) {
|
||||
text.removeSpan(span)
|
||||
}
|
||||
text
|
||||
} else {
|
||||
SpannableString(text ?: "")
|
||||
}
|
||||
model?.map?.valueIterator()?.forEach {
|
||||
spannable.setSpan(it.span, it.start, it.start + it.text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
isTextManualSetting = true
|
||||
super.setText(spannable, type)
|
||||
fixSelection()
|
||||
isTextManualSetting = false
|
||||
}
|
||||
|
||||
private fun fixSelection() {
|
||||
val model = emojiModel ?: return
|
||||
if (selectionStart == selectionEnd) {
|
||||
val emoji = model.getEmoji(selectionStart) ?: return
|
||||
if (selectionStart > emoji.start && selectionStart < (emoji.start + emoji.text.length)) {
|
||||
setSelection(emoji.start + emoji.text.length)
|
||||
}
|
||||
} else {
|
||||
var fixStart = selectionStart
|
||||
var fixEnd = selectionEnd
|
||||
val start = model.getEmoji(selectionStart)
|
||||
if (start != null && selectionStart > start.start && selectionStart < (start.start + start.text.length)) {
|
||||
fixStart = start.start
|
||||
}
|
||||
val end = model.getEmoji(selectionEnd)
|
||||
if (end != null && selectionEnd > end.start && selectionEnd < (end.start + end.text.length)) {
|
||||
fixEnd = end.start + end.text.length
|
||||
}
|
||||
setSelection(fixStart, fixEnd)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Emoji(val span: EmojiSpan, val text: CharSequence, var start: Int) {
|
||||
var prev: Emoji? = null
|
||||
var next: Emoji? = null
|
||||
}
|
||||
|
||||
class EmojiModel(
|
||||
val map: SparseArray<Emoji>,
|
||||
var begin: Emoji,
|
||||
var end: Emoji
|
||||
) {
|
||||
fun merge(other: EmojiModel) {
|
||||
map.putAll(other.map)
|
||||
when {
|
||||
other.begin.start > end.start -> {
|
||||
end.next = other.begin
|
||||
other.begin.prev = end
|
||||
end = other.end
|
||||
}
|
||||
other.end.start < begin.start -> {
|
||||
other.end.next = begin
|
||||
begin.prev = other.end
|
||||
begin = other.begin
|
||||
}
|
||||
else -> {
|
||||
var next: Emoji?
|
||||
var otherNext: Emoji?
|
||||
val newBegin: Emoji = if (begin.start < other.begin.start) {
|
||||
next = begin.next
|
||||
otherNext = other.begin
|
||||
begin
|
||||
} else {
|
||||
otherNext = other.begin.next
|
||||
next = begin
|
||||
other.begin
|
||||
}
|
||||
var cur = newBegin
|
||||
while (next != null || otherNext != null) {
|
||||
when {
|
||||
next == null -> {
|
||||
cur.next = otherNext
|
||||
otherNext!!.prev = cur
|
||||
begin = newBegin
|
||||
end = other.end
|
||||
return
|
||||
}
|
||||
otherNext == null -> {
|
||||
cur.next = next
|
||||
next.prev = cur
|
||||
begin = newBegin
|
||||
return
|
||||
}
|
||||
next.start < otherNext.start -> {
|
||||
cur.next = next
|
||||
next.prev = cur
|
||||
cur = next
|
||||
next = next.next
|
||||
}
|
||||
else -> {
|
||||
cur.next = otherNext
|
||||
otherNext.prev = cur
|
||||
cur = otherNext
|
||||
otherNext = otherNext.next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getEmoji(pos: Int): Emoji? {
|
||||
if (begin.start > pos) {
|
||||
return null
|
||||
}
|
||||
if (end.start + end.text.length <= pos) {
|
||||
return null
|
||||
}
|
||||
var lo = 0
|
||||
var hi: Int = map.size() - 1
|
||||
|
||||
while (lo <= hi) {
|
||||
val mid = lo + hi ushr 1
|
||||
val midVal = map.valueAt(mid)
|
||||
when {
|
||||
midVal.start < pos -> {
|
||||
lo = mid + 1
|
||||
}
|
||||
midVal.start + midVal.text.length >= pos -> {
|
||||
hi = mid - 1
|
||||
}
|
||||
else -> {
|
||||
return midVal
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun TypeModel.toEmojiModel(offset: Int): EmojiModel? {
|
||||
var node: Element? = firstElement()
|
||||
val map = SparseArray<Emoji>()
|
||||
var begin: Emoji? = null
|
||||
var end: Emoji? = null
|
||||
while (node != null) {
|
||||
if (node is EmojiElement) {
|
||||
val next = Emoji(EmojiSpan(node.drawable.apply {
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
}), node.text, offset + node.start)
|
||||
map.put(offset + node.start, next)
|
||||
if (begin == null) {
|
||||
begin = next
|
||||
end = next
|
||||
} else {
|
||||
next.prev = end
|
||||
end!!.next = next
|
||||
end = next
|
||||
}
|
||||
}
|
||||
node = node.next
|
||||
}
|
||||
if (begin == null) {
|
||||
return null
|
||||
}
|
||||
return EmojiModel(map, begin, end!!)
|
||||
}
|
||||
|
||||
class EmojiSpan(drawable: Drawable) : ImageSpan(drawable) {
|
||||
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
||||
val rect = drawable.bounds
|
||||
if (fm != null) {
|
||||
val iconHeight = rect.height()
|
||||
val fontMetricsHeight = fm.descent - fm.ascent
|
||||
if (iconHeight != fontMetricsHeight) {
|
||||
val scale = fontMetricsHeight.toFloat() / iconHeight
|
||||
rect.left = 0
|
||||
rect.top = 0
|
||||
rect.right = (rect.width() * scale).toInt()
|
||||
rect.bottom = (iconHeight * scale).toInt()
|
||||
drawable.bounds = rect
|
||||
}
|
||||
}
|
||||
|
||||
return rect.width()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
|
||||
canvas.save()
|
||||
val rect = drawable.bounds
|
||||
val fontMetricsInt = paint.fontMetricsInt
|
||||
val fontTop = y + fontMetricsInt.top
|
||||
val fontMetricsHeight = fontMetricsInt.bottom - fontMetricsInt.top
|
||||
val iconHeight = rect.height()
|
||||
val iconTop = fontTop + (fontMetricsHeight - iconHeight) / 2
|
||||
canvas.translate(x, iconTop.toFloat())
|
||||
drawable.draw(canvas)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user