package com.example.expand_button
|
|
import android.animation.ValueAnimator
|
import android.content.Context
|
import android.graphics.Canvas
|
import android.graphics.drawable.Drawable
|
import android.text.Spannable
|
import android.text.SpannableStringBuilder
|
import android.text.style.ClickableSpan
|
import android.util.AttributeSet
|
import android.view.Gravity
|
import android.view.MotionEvent
|
import android.view.View
|
import androidx.appcompat.widget.AppCompatTextView
|
import androidx.core.content.ContextCompat
|
|
/**
|
* 可展开的按钮控件
|
* 初始状态显示单个字符,点击后展开显示完整文字
|
* 展开后的每个字符都可以单独点击
|
*/
|
class ExpandButton @JvmOverloads constructor(
|
context: Context,
|
attrs: AttributeSet? = null,
|
defStyleAttr: Int = 0
|
) : AppCompatTextView(context, attrs, defStyleAttr) {
|
|
// 展开时显示的完整文字
|
private var expandedText: String = ""
|
// 收起时显示的单个字符
|
private var collapsedText: String = ""
|
// 当前是否处于展开状态
|
private var isExpanded: Boolean = false
|
// 动画持续时间,默认300毫秒
|
private var animationDuration: Long = 300
|
// 字符点击事件监听器
|
private var onCharClickListener: ((Char, Int) -> Unit)? = null
|
// 字间距,默认为2dp
|
private var customLetterSpacing: Float = context.resources.displayMetrics.density * 2
|
|
// 三角形图标
|
private val triangleDrawable: Drawable = ContextCompat.getDrawable(
|
context,
|
R.drawable.ic_triangle
|
)!!.mutate()
|
|
// 图标旋转角度
|
private var triangleRotation = 0f
|
|
// 三角形图标与文字的间距,默认为8dp
|
private var triangleMargin: Float = 3 * context.resources.displayMetrics.density
|
|
init {
|
// 读取自定义属性
|
context.theme.obtainStyledAttributes(
|
attrs,
|
R.styleable.ExpandButton,
|
defStyleAttr,
|
0
|
).apply {
|
try {
|
customLetterSpacing = getDimension(R.styleable.ExpandButton_letterSpacing, customLetterSpacing)
|
expandedText = getString(R.styleable.ExpandButton_expandedText) ?: ""
|
collapsedText = getString(R.styleable.ExpandButton_collapsedText) ?: ""
|
animationDuration = getInteger(R.styleable.ExpandButton_animDuration, 300).toLong()
|
triangleMargin = getDimension(R.styleable.ExpandButton_triangleMargin, triangleMargin)
|
} finally {
|
recycle()
|
}
|
}
|
|
// 设置初始文本和宽度
|
if (collapsedText.isNotEmpty()) {
|
text = collapsedText
|
post {
|
// 确保初始宽度为收起状态的宽度
|
layoutParams = layoutParams.apply {
|
width = paint.measureText(collapsedText).toInt() + paddingLeft + paddingRight
|
}
|
}
|
}
|
|
// 设置文本可点击,仅在收起状态时响应点击展开
|
setOnClickListener {
|
if (!isExpanded) {
|
toggleExpand()
|
}
|
}
|
|
// 添加触摸事件处理
|
setOnTouchListener { _, event ->
|
when (event.action) {
|
MotionEvent.ACTION_DOWN -> {
|
// 检查点击是否在三角形图标区域内
|
if (isClickOnTriangle(event.x)) {
|
toggleExpand()
|
return@setOnTouchListener true
|
}
|
}
|
}
|
false
|
}
|
|
// 设置左边距,为图标留出空间
|
compoundDrawablePadding = triangleMargin.toInt()
|
setPadding(
|
(16 * context.resources.displayMetrics.density + triangleMargin).toInt(), // 左边距增加,为图标留空间
|
paddingTop,
|
paddingRight,
|
paddingBottom
|
)
|
|
// 设置单行显示,防止高度变化
|
maxLines = 1
|
isSingleLine = true
|
|
// 设置文字垂直居中
|
gravity = Gravity.CENTER_VERTICAL
|
}
|
|
override fun onDraw(canvas: Canvas) {
|
// 保存画布状态
|
canvas.save()
|
|
// 计算图标位置
|
val iconSize = triangleDrawable.intrinsicWidth
|
val iconLeft = paddingLeft - iconSize - compoundDrawablePadding
|
val iconTop = (height - iconSize) / 2
|
|
// 设置图标边界
|
triangleDrawable.setBounds(
|
iconLeft,
|
iconTop,
|
iconLeft + iconSize,
|
iconTop + iconSize
|
)
|
|
// 旋转画布
|
canvas.rotate(
|
triangleRotation,
|
(iconLeft + iconSize / 2).toFloat(),
|
(iconTop + iconSize / 2).toFloat()
|
)
|
|
// 绘制图标
|
triangleDrawable.draw(canvas)
|
|
// 恢复画布状态
|
canvas.restore()
|
|
super.onDraw(canvas)
|
}
|
|
/**
|
* 设置字间距
|
* @param spacing 间距值(像素)
|
*/
|
fun setCustomLetterSpacing(spacing: Float) {
|
this.customLetterSpacing = spacing
|
if (isExpanded) {
|
setExpandedClickableText()
|
}
|
}
|
|
/**
|
* 设置展开和收起时显示的文字
|
* @param expanded 展开时显示的完整文字
|
* @param collapsed 收起时显示的单个字符
|
*/
|
fun setExpandText(expanded: String, collapsed: String) {
|
this.expandedText = expanded
|
this.collapsedText = collapsed
|
text = collapsedText
|
}
|
|
/**
|
* 设置单个字符点击监听器
|
* @param listener 点击回调,参数为被点击的字符和位置
|
* char: 被点击的字符
|
* position: 字符在文本中的位置(从0开始)
|
*/
|
fun setOnCharClickListener(listener: (char: Char, position: Int) -> Unit) {
|
this.onCharClickListener = listener
|
}
|
|
/**
|
* 设置展开/收起动画的持续时间
|
* @param duration 动画持续时间(毫秒)
|
*/
|
fun setAnimationDuration(duration: Long) {
|
this.animationDuration = duration
|
}
|
|
/**
|
* 切换展开/收起状态
|
* 使用ValueAnimator实现宽度动画和图标旋转
|
*/
|
private fun toggleExpand() {
|
isExpanded = !isExpanded
|
|
// 计算收起和展开状态的宽度
|
val collapsedWidth = paint.measureText(collapsedText).toInt() + paddingLeft + paddingRight
|
val expandedWidth = calculateExpandedWidth()
|
|
// 创建宽度动画
|
ValueAnimator.ofInt(
|
if (isExpanded) collapsedWidth else expandedWidth,
|
if (isExpanded) expandedWidth else collapsedWidth
|
).apply {
|
duration = animationDuration
|
addUpdateListener { animator ->
|
layoutParams = layoutParams.apply {
|
width = animator.animatedValue as Int
|
}
|
requestLayout()
|
}
|
start()
|
}
|
|
// 创建图标旋转动画
|
ValueAnimator.ofFloat(
|
if (isExpanded) 0f else 180f,
|
if (isExpanded) 180f else 0f
|
).apply {
|
duration = animationDuration
|
addUpdateListener { animator ->
|
triangleRotation = animator.animatedValue as Float
|
invalidate() // 重绘以更新图标旋转
|
}
|
start()
|
}
|
|
// 更新文本
|
if (isExpanded) {
|
setExpandedClickableText()
|
} else {
|
text = collapsedText
|
}
|
}
|
|
/**
|
* 计算展开后的总宽度
|
*/
|
private fun calculateExpandedWidth(): Int {
|
val spaceWidth = paint.measureText(" ") * (customLetterSpacing / 10)
|
// 计算所有字符的总宽度
|
val textWidth = expandedText.fold(0f) { acc, char ->
|
acc + paint.measureText(char.toString())
|
}
|
// 计算间距的总宽度(字符数量减1个间距)
|
val spacesWidth = spaceWidth * (expandedText.length - 1)
|
return (textWidth + spacesWidth).toInt() + paddingLeft + paddingRight
|
}
|
|
/**
|
* 设置展开后的可点击文本
|
*/
|
private fun setExpandedClickableText() {
|
val builder = SpannableStringBuilder()
|
expandedText.forEachIndexed { index, char ->
|
// 添加字符
|
builder.append(char)
|
|
// 为字符设置点击事件
|
val clickableSpan = object : ClickableSpan() {
|
override fun onClick(view: View) {
|
onCharClickListener?.invoke(char, index)
|
}
|
|
override fun updateDrawState(ds: android.text.TextPaint) {
|
super.updateDrawState(ds)
|
// 移除下划线
|
ds.isUnderlineText = false
|
// 保持原始文字颜色
|
ds.color = currentTextColor
|
}
|
}
|
builder.setSpan(
|
clickableSpan,
|
builder.length - 1,
|
builder.length,
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
)
|
|
// 只在非最后一个字符后添加空格作为间距
|
if (index < expandedText.length - 1) {
|
builder.append(" ".repeat((customLetterSpacing / 10).toInt()))
|
}
|
}
|
|
text = builder
|
// 启用LinkMovementMethod以响应ClickableSpan的点击事件
|
movementMethod = android.text.method.LinkMovementMethod.getInstance()
|
}
|
|
/**
|
* 判断点击是否在三角形图标区域内
|
*/
|
private fun isClickOnTriangle(x: Float): Boolean {
|
val iconSize = triangleDrawable.intrinsicWidth
|
val iconLeft = paddingLeft - iconSize - compoundDrawablePadding
|
return x <= paddingLeft && x >= iconLeft
|
}
|
|
/**
|
* 设置三角形图标与文字的间距
|
* @param margin 间距值(像素)
|
*/
|
fun setTriangleMargin(margin: Float) {
|
this.triangleMargin = margin
|
compoundDrawablePadding = margin.toInt()
|
setPadding(
|
(16 * context.resources.displayMetrics.density + margin).toInt(),
|
paddingTop,
|
paddingRight,
|
paddingBottom
|
)
|
}
|
}
|