一、背景及样式效果
因项目需要,需要文本编辑时,支持项目符号(无序列表)尝试了BulletSpan,但不是很理想,并且考虑到影响老版本回显等因素,最终决定自定义一个BulletEditText。
先看效果:
视频效果
二、自定义View BulletEditText
自定义控件BulletEditText源码:
package com.ml512.widget
import android.content.Context
import android.util.AttributeSet
import androidx.core.widget.doOnTextChanged
/**
* @Description: 简单支持项目号的文本编辑器
* @Author: Marlon
* @CreateDate: 2024/2/1 17:44
* @UpdateRemark: 更新说明:
* @Version: 1.0
*/
class BulletEditText : androidx.appcompat.widget.AppCompatEditText {
/**
* 是否开启项目符号
*/
private var isNeedBullet: Boolean = false
/**
* 项目符号
*/
private var bulletPoint: String = "• "
/**
* 项目符号占用字符数,方便设置光标位置
*/
private var bulletOffsetIndex = bulletPoint.length
/**
* 相关监听回调
*/
private var editListener: EditListener? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
init {
this.doOnTextChanged { text, start, before, count ->
//如果是关闭状态不做格式处理
if (!isNeedBullet) {
return@doOnTextChanged
}
if (count > before) {
//处理项目号逻辑
var offset = 0
var tmp = text.toString()
//连续回车去掉项目符号
if (start >= bulletOffsetIndex && tmp.substring(start, start + count) == "\n") {
val preSub = tmp.substring(start - bulletOffsetIndex, start)
if (preSub == bulletPoint) {
changeBulletState(false)
tmp = tmp.replaceRange(start-bulletOffsetIndex, start + count, "")
offset -= bulletOffsetIndex + 1
setTextAndSelection(tmp, start + count + offset)
return@doOnTextChanged
}
}
//加入项目符号
if (tmp.substring(start, start + count) == "\n") {
changeBulletState(true)
tmp = tmp.replaceRange(start, start + count, "\n$bulletPoint")
offset += bulletOffsetIndex
setTextAndSelection(tmp, start + count + offset)
}
}
}
}
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
//复制选择时直接返回,关闭项目符号
if (selStart != selEnd) {
changeBulletState(false)
return
}
//判断当前段落是否有项目号,有开启,没有关闭
val tmp = text.toString()
val prefix = tmp.substring(0, selectionStart)
if (prefix.isEmpty()) {
changeBulletState(false)
return
}
if (prefix.startsWith(bulletPoint) && !prefix.contains("\n")) {
changeBulletState(true)
return
}
val lastEnterIndex = prefix.lastIndexOf("\n")
if (lastEnterIndex != -1 && lastEnterIndex + bulletOffsetIndex + 1 <= prefix.length) {
val mathStr = prefix.substring(lastEnterIndex, lastEnterIndex + bulletOffsetIndex + 1)
if (mathStr == "\n$bulletPoint") {
changeBulletState(true)
return
}
}
changeBulletState(false)
}
/**
* 更新bullet状态
*/
private fun changeBulletState(isOpen: Boolean) {
isNeedBullet = isOpen
editListener?.onBulletStateChange(isOpen)
}
/**
* 设置是否开启项目号
*/
fun setBullet(isOpen: Boolean) {
isNeedBullet = isOpen
val tmp = text.toString()
var index = selectionStart
var prefix = tmp.substring(0, index)
val suffix = tmp.substring(index)
//加项目号
if (isOpen) {
//首个段落
if (!prefix.contains("\n") && prefix.startsWith(bulletPoint)) {
return
}
index += bulletOffsetIndex
if (prefix.isEmpty() || (!prefix.contains("\n") && !prefix.startsWith(bulletPoint))) {
setTextAndSelection("$bulletPoint$prefix$suffix", index)
return
}
prefix = prefix.replaceLast("\n", "\n$bulletPoint")
setTextAndSelection("$prefix$suffix", index)
return
}
//去掉项目号
if (prefix.startsWith(bulletPoint) && !prefix.contains("\n$bulletPoint")) {//首行逻辑
index -= bulletOffsetIndex
prefix = prefix.replaceLast(bulletPoint, "")
setTextAndSelection("$prefix$suffix", index)
return
}
if (prefix.contains("\n$bulletPoint")) {
index -= bulletOffsetIndex
prefix = prefix.replaceLast("\n$bulletPoint", "\n")
setTextAndSelection("$prefix$suffix", index)
}
}
/**
* 设置文本及光标位置
*/
private fun setTextAndSelection(text: String, index: Int) {
setText(text)
setSelection(index)
}
/**
* 替换最后一个字符
*/
private fun String.replaceLast(oldValue: String, newValue: String): String {
val lastIndex = lastIndexOf(oldValue)
if (lastIndex == -1) {
return this
}
val prefix = substring(0, lastIndex)
val suffix = substring(lastIndex + oldValue.length)
return "$prefix$newValue$suffix"
}
/**
* 设置监听
*/
fun setEditListener(listener: EditListener) {
editListener = listener
}
/**
* 监听回调
*/
interface EditListener {
/**
* 项目符号开关状态变化
*/
fun onBulletStateChange(isOpen: Boolean)
}
}
三、调用
使用时一个项目符号的按钮开关设置调用setBullet(isOpen: Boolean) 设置是否开启项目符号,同时实现一个setEditListener(listener: EditListener)根据光标位置判断当前段落是否含有项目符号,并回显按钮状态。
<com.ml512.widget.BulletEditText
android:id="@+id/etInput"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_below="@+id/tvTitle"
android:layout_marginStart="15dp"
android:layout_marginTop="15dp"
android:layout_marginEnd="15dp"
android:layout_marginBottom="15dp"
android:autofillHints="no"
android:background="@drawable/shape_edit_bg"
android:gravity="top"
android:hint="@string/text_please_input_some_worlds"
android:inputType="textMultiLine"
android:padding="15dp"
android:textColor="@color/black"
android:textColorHint="@color/color_FF_999999"
android:textSize="16sp" />
//点击按钮设置添加/取消项目符号
tvBullet.setOnClickListener {
tvBullet.isSelected = !tvBullet.isSelected
etInput.setBullet(tvBullet.isSelected)
}
//项目符号状态监听,回显到按钮
etInput.setEditListener(object :BulletEditText.EditListener{
override fun onBulletStateChange(isOpen: Boolean) {
tvBullet.isSelected = isOpen
}
})
大功告成!