| | |
| | | package com.example.expand_button |
| | | |
| | | class ExpandButton { |
| | | 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 |
| | | ) |
| | | } |
| | | } |