diff --git a/plugin/src/main/java/com/qmuiteam/plugin/Dep.kt b/plugin/src/main/java/com/qmuiteam/plugin/Dep.kt index 13be660c..4878a3df 100644 --- a/plugin/src/main/java/com/qmuiteam/plugin/Dep.kt +++ b/plugin/src/main/java/com/qmuiteam/plugin/Dep.kt @@ -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" diff --git a/qmuidemo/src/main/java/com/qmuiteam/qmuidemo/fragment/lab/QDEmojiInputFragment.kt b/qmuidemo/src/main/java/com/qmuiteam/qmuidemo/fragment/lab/QDEmojiInputFragment.kt new file mode 100644 index 00000000..ab663b11 --- /dev/null +++ b/qmuidemo/src/main/java/com/qmuiteam/qmuidemo/fragment/lab/QDEmojiInputFragment.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/qmuidemo/src/main/java/com/qmuiteam/qmuidemo/manager/QDDataManager.java b/qmuidemo/src/main/java/com/qmuiteam/qmuidemo/manager/QDDataManager.java index 1dc7bab5..f0ad469e 100644 --- a/qmuidemo/src/main/java/com/qmuiteam/qmuidemo/manager/QDDataManager.java +++ b/qmuidemo/src/main/java/com/qmuiteam/qmuidemo/manager/QDDataManager.java @@ -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 cls) { diff --git a/type/build.gradle.kts b/type/build.gradle.kts index ba153c99..748ce4d0 100644 --- a/type/build.gradle.kts +++ b/type/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/type/src/main/java/com/qmuiteam/qmui/type/view/EmojiEditText.kt b/type/src/main/java/com/qmuiteam/qmui/type/view/EmojiEditText.kt new file mode 100644 index 00000000..e2661b57 --- /dev/null +++ b/type/src/main/java/com/qmuiteam/qmui/type/view/EmojiEditText.kt @@ -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>() + private val pendingRemoveEmojis = mutableListOf() + private val pendingAddEmojis = mutableListOf() + 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, + 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() + 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() + } +} \ No newline at end of file