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 data class LegendItem(
|
val selectedIcon: Drawable,
|
val unselectedIcon: Drawable,
|
val description: String,
|
var isSelected: Boolean = true
|
)
|
|
private var legendItems: List<LegendItem> = listOf()
|
private var itemSpacing: Float = context.resources.displayMetrics.density * 16 // 图例项之间的水平间距
|
private var iconSize: Int = (24 * context.resources.displayMetrics.density).toInt() // 图标大小
|
private var iconTextSpacing: Float = context.resources.displayMetrics.density * 4 // 图标和文字之间的垂直间距
|
|
// 展开时显示的完整文字
|
private var expandedText: String = "标注点: 当前位置\n区域: 配送范围"
|
// 收起时显示的单个字符
|
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
|
|
// 添加新属性
|
private var textLines: List<String> = listOf()
|
|
// 添加点击回调接口
|
interface OnLegendItemClickListener {
|
fun onLegendItemClick(position: Int, isSelected: Boolean)
|
}
|
|
private var legendItemClickListener: OnLegendItemClickListener? = null
|
|
// 修改图例项的总高度计算
|
private val legendItemHeight: Int
|
get() = iconSize + iconTextSpacing.toInt() + paint.textSize.toInt() + paint.descent().toInt() - paint.ascent().toInt()
|
|
// 添加展开后的字体大小属性
|
private var expandedTextSize: Float = textSize
|
|
// 添加一个变量保存默认字体大小
|
private var defaultTextSize: Float = 0f
|
|
// 添加一个属性定义三角形图标的点击区域扩展范围
|
private val triangleClickPadding: Float = 15f * context.resources.displayMetrics.density // 20dp
|
|
// 添加一个标识符,用于区分不同的 ExpandButton 实例
|
private var buttonId: String = "default"
|
|
companion object {
|
private const val PREFS_NAME = "expand_button_prefs"
|
private const val KEY_LEGEND_STATES = "legend_states"
|
}
|
|
init {
|
// 保存 XML 中设置的默认字体大小
|
defaultTextSize = textSize
|
|
// 读取自定义属性
|
context.theme.obtainStyledAttributes(
|
attrs,
|
R.styleable.ExpandButton,
|
defStyleAttr,
|
0
|
).apply {
|
try {
|
customLetterSpacing = getDimension(R.styleable.ExpandButton_letterSpacing, customLetterSpacing)
|
expandedText = getString(R.styleable.ExpandButton_expandedText) ?: "标注点: 当前位置\n区域: 配送范围"
|
collapsedText = getString(R.styleable.ExpandButton_collapsedText) ?: "图例"
|
animationDuration = getInteger(R.styleable.ExpandButton_animDuration, 300).toLong()
|
triangleMargin = getDimension(R.styleable.ExpandButton_triangleMargin, triangleMargin)
|
itemSpacing = getDimension(R.styleable.ExpandButton_itemSpacing, itemSpacing)
|
iconSize = getDimension(R.styleable.ExpandButton_iconSize, iconSize.toFloat()).toInt()
|
iconTextSpacing = getDimension(R.styleable.ExpandButton_iconTextSpacing, iconTextSpacing)
|
expandedTextSize = getDimension(R.styleable.ExpandButton_expandedTextSize, defaultTextSize)
|
} finally {
|
recycle()
|
}
|
}
|
|
// 设置初始文本和宽度
|
if (collapsedText.isNotEmpty()) {
|
text = collapsedText
|
post {
|
// 确保初始宽度为收起状态的宽度
|
layoutParams = layoutParams.apply {
|
width = paint.measureText(collapsedText).toInt() + paddingLeft + paddingRight
|
}
|
}
|
}
|
|
// 修改触摸事件处理
|
setOnTouchListener(null) // 移除原有的触摸监听器
|
|
// 移除原有的点击监听器
|
setOnClickListener(null)
|
|
// 设置左边距,为图标留出空间
|
compoundDrawablePadding = triangleMargin.toInt()
|
setPadding(
|
(16 * context.resources.displayMetrics.density + triangleMargin).toInt(), // 左边距增加,为图标留空间
|
paddingTop,
|
paddingRight,
|
paddingBottom
|
)
|
|
// 设置单行显示,防止高度变化
|
maxLines = if (isExpanded) Int.MAX_VALUE else 1
|
isSingleLine = !isExpanded
|
|
// 设置文字垂直居中
|
gravity = Gravity.CENTER_VERTICAL
|
|
// 设置默认的内边距
|
val defaultPadding = (8 * context.resources.displayMetrics.density).toInt()
|
setPadding(
|
(16 * context.resources.displayMetrics.density + triangleMargin).toInt(), // 左边距增加,为图标留空间
|
defaultPadding, // 上边距
|
defaultPadding, // 右边距
|
defaultPadding // 下边距
|
)
|
}
|
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
|
if (isExpanded) {
|
// 展开状态下的高度计算
|
val desiredHeight = legendItemHeight + paddingTop + paddingBottom
|
setMeasuredDimension(measuredWidth, desiredHeight)
|
}
|
}
|
|
override fun onDraw(canvas: Canvas) {
|
// 绘制展开/收起图标
|
drawTriangle(canvas)
|
|
if (!isExpanded) {
|
// 收起状态使用默认字体大小
|
paint.textSize = defaultTextSize
|
super.onDraw(canvas)
|
return
|
}
|
|
// 展开状态使用展开后的字体大小
|
paint.textSize = expandedTextSize
|
|
// 计算所有图例项中最宽的宽度
|
val maxWidth = legendItems.maxOf { item ->
|
maxOf(paint.measureText(item.description), iconSize.toFloat())
|
}
|
|
// 计算总宽度(添加首尾的边距)
|
val totalWidth = legendItems.size * maxWidth +
|
(legendItems.size + 1) * itemSpacing // 修改这里,添加一个额外的间距
|
|
// 计算起始x坐标,使整体水平居中
|
var x = (width - totalWidth) / 2 + itemSpacing // 添加起始边距
|
|
// 计算垂直居中的y坐标,考虑上下边距
|
val centerY = height / 2f
|
|
legendItems.forEachIndexed { index, item ->
|
// 计算当前图例项的起始位置(移除index > 0的判断)
|
val itemStartX = x
|
|
// 计算图标的水平位置(居中于图例项)
|
val iconLeft = itemStartX + (maxWidth - iconSize) / 2
|
|
// 计算文字的位置
|
val textWidth = paint.measureText(item.description)
|
val textX = itemStartX + (maxWidth - textWidth) / 2
|
|
// 绘制图标,根据选中状态选择不同的图标
|
val iconTop = paddingTop + (height - legendItemHeight) / 2
|
val currentIcon = if (item.isSelected) item.selectedIcon else item.unselectedIcon
|
currentIcon.setBounds(
|
iconLeft.toInt(),
|
iconTop.toInt(),
|
(iconLeft + iconSize).toInt(),
|
(iconTop + iconSize).toInt()
|
)
|
currentIcon.draw(canvas)
|
|
// 绘制文字,根据选中状态使用不同的颜色
|
paint.color = if (item.isSelected)
|
currentTextColor
|
else
|
0xFF999999.toInt() // 灰色
|
val textY = iconTop + iconSize + iconTextSpacing - paint.ascent()
|
canvas.drawText(item.description, textX, textY, paint)
|
|
// 更新下一个图例项的起始位置
|
x = itemStartX + maxWidth + itemSpacing
|
}
|
|
// 恢复画笔颜色
|
paint.color = currentTextColor
|
}
|
|
// 将原来的onDraw中的三角形绘制逻辑提取出来
|
private fun drawTriangle(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()
|
}
|
|
/**
|
* 设置字间距
|
* @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
|
}
|
|
/**
|
* 切换展开/收起状态
|
*/
|
private fun toggleExpand() {
|
isExpanded = !isExpanded
|
|
// 计算收起和展开状态的宽度
|
val collapsedWidth = run {
|
paint.textSize = defaultTextSize
|
val width = paint.measureText(collapsedText).toInt() + paddingLeft + paddingRight
|
paint.textSize = if (isExpanded) expandedTextSize else defaultTextSize
|
width
|
}
|
|
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) {
|
paint.textSize = expandedTextSize
|
} else {
|
text = collapsedText
|
paint.textSize = defaultTextSize
|
setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, defaultTextSize)
|
}
|
|
invalidate()
|
}
|
|
/**
|
* 计算展开后的总宽度
|
*/
|
private fun calculateExpandedWidth(): Int {
|
// 临时保存当前字体大小
|
val currentTextSize = paint.textSize
|
// 设置为展开状态的字体大小
|
paint.textSize = expandedTextSize
|
|
try {
|
// 计算所有图例项中最宽的宽度
|
val maxWidth = legendItems.maxOf { item ->
|
maxOf(paint.measureText(item.description), iconSize.toFloat())
|
}
|
|
// 计算总宽度 = 所有图例项的宽度 + 所有间距(包括首尾) + 左右内边距
|
return (legendItems.size * maxWidth +
|
(legendItems.size + 1) * itemSpacing +
|
paddingLeft + paddingRight).toInt()
|
} finally {
|
// 恢复原来的字体大小
|
paint.textSize = currentTextSize
|
}
|
}
|
|
/**
|
* 设置展开后的可点击文本
|
*/
|
private fun setExpandedClickableText() {
|
val builder = SpannableStringBuilder()
|
|
// 计算所有图例项中最宽的宽度
|
val maxWidth = legendItems.maxOf { item ->
|
maxOf(paint.measureText(item.description), iconSize.toFloat())
|
}
|
|
// 计算总宽度
|
val totalWidth = legendItems.size * maxWidth +
|
(legendItems.size - 1) * itemSpacing
|
|
// 计算整体水平居中需要的起始空格
|
val startPadding = ((width - totalWidth) / 2 / paint.measureText(" ")).toInt()
|
if (startPadding > 0) {
|
builder.append(" ".repeat(startPadding))
|
}
|
|
// 添加垂直空间,为图标预留位置
|
val verticalSpaces = ((iconSize + iconTextSpacing) / paint.textSize).toInt()
|
builder.append("\n".repeat(verticalSpaces))
|
|
legendItems.forEachIndexed { index, item ->
|
if (index > 0) {
|
// 在图例项之间添加水平间距
|
builder.append(" ".repeat((itemSpacing / paint.measureText(" ")).toInt()))
|
}
|
|
// 计算水平居中所需的空格数
|
val textWidth = paint.measureText(item.description)
|
val paddingSpaces = ((maxWidth - textWidth) / 2 / paint.measureText(" ")).toInt()
|
|
// 添加左侧空格实现居中
|
if (paddingSpaces > 0) {
|
builder.append(" ".repeat(paddingSpaces))
|
}
|
|
// 添加描述文本
|
val startPosition = builder.length
|
builder.append(item.description)
|
|
// 添加右侧空格以确保宽度一致
|
val remainingSpaces = ((maxWidth - textWidth) / paint.measureText(" ")).toInt() - paddingSpaces
|
if (remainingSpaces > 0) {
|
builder.append(" ".repeat(remainingSpaces))
|
}
|
|
// 为文字设置点击事件
|
val clickableSpan = object : ClickableSpan() {
|
override fun onClick(view: View) {
|
onCharClickListener?.invoke(item.description[0], index)
|
}
|
|
override fun updateDrawState(ds: android.text.TextPaint) {
|
super.updateDrawState(ds)
|
ds.isUnderlineText = false
|
ds.color = currentTextColor
|
}
|
}
|
|
builder.setSpan(
|
clickableSpan,
|
startPosition,
|
startPosition + item.description.length,
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
)
|
}
|
|
// 设置文本对齐方式为居中
|
gravity = Gravity.CENTER
|
|
text = builder
|
movementMethod = android.text.method.LinkMovementMethod.getInstance()
|
}
|
|
/**
|
* 判断点击是否在三角形图标区域内
|
*/
|
private fun isClickOnTriangle(x: Float): Boolean {
|
val iconSize = triangleDrawable.intrinsicWidth
|
val iconLeft = paddingLeft - iconSize - compoundDrawablePadding
|
|
// 扩大点击区域:左右各增加 triangleClickPadding
|
return x <= (paddingLeft + triangleClickPadding) &&
|
x >= (iconLeft - triangleClickPadding)
|
}
|
|
/**
|
* 设置三角形图标与文字的间距
|
* @param margin 间距值(像素)
|
*/
|
fun setTriangleMargin(margin: Float) {
|
this.triangleMargin = margin
|
compoundDrawablePadding = margin.toInt()
|
setPadding(
|
(16 * context.resources.displayMetrics.density + margin).toInt(),
|
paddingTop,
|
paddingRight,
|
paddingBottom
|
)
|
}
|
|
/**
|
* 设置按钮的唯一标识符
|
* @param id 标识符
|
*/
|
fun setButtonId(id: String) {
|
this.buttonId = id
|
// 加载保存的状态
|
|
}
|
|
|
/**
|
* 设置图例内容
|
*/
|
@JvmName("setLegendsList")
|
fun setLegends(items: List<Quadruple<Drawable, Drawable, String, Boolean>>) {
|
legendItems = items.map { (selectedIcon, unselectedIcon, description, isSelected) ->
|
selectedIcon.setBounds(0, 0, iconSize, iconSize)
|
unselectedIcon.setBounds(0, 0, iconSize, iconSize)
|
LegendItem(selectedIcon, unselectedIcon, description, isSelected)
|
}
|
|
if (!isExpanded) {
|
text = collapsedText
|
} else {
|
invalidate()
|
}
|
requestLayout()
|
}
|
|
// 添加一个 Java 友好的方法
|
@JvmName("setLegendsArray")
|
fun setLegends(vararg items: Quadruple<Drawable, Drawable, String, Boolean>) {
|
setLegends(items.toList())
|
}
|
|
// 添加一个数据类来表示四元组
|
data class Quadruple<A, B, C, D>(
|
val first: A,
|
val second: B,
|
val third: C,
|
val fourth: D
|
)
|
|
// 添加一个便捷的扩展函数来创建 Quadruple
|
fun <A, B, C, D> quadrupleOf(first: A, second: B, third: C, fourth: D): Quadruple<A, B, C, D> {
|
return Quadruple(first, second, third, fourth)
|
}
|
|
/**
|
* 添加设置监听器的方法
|
*/
|
fun setOnLegendItemClickListener(listener: OnLegendItemClickListener) {
|
legendItemClickListener = listener
|
}
|
|
/**
|
* 处理触摸事件
|
*/
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
when (event.action) {
|
MotionEvent.ACTION_DOWN -> {
|
// 检查点击是否在三角形图标区域内
|
if (isClickOnTriangle(event.x)) {
|
toggleExpand()
|
return true
|
}
|
|
// 如果是展开状态,检查是否点击了图例项
|
if (isExpanded) {
|
val clickedIndex = getClickedItemIndex(event.x, event.y)
|
if (clickedIndex != -1) {
|
toggleItemSelection(clickedIndex)
|
return true
|
}
|
} else if (!isExpanded && event.x > paddingLeft) {
|
// 在收起状态下,点击非三角形区域也展开
|
toggleExpand()
|
return true
|
}
|
}
|
}
|
return super.onTouchEvent(event)
|
}
|
|
/**
|
* 获取点击位置对应的图例项索引
|
*/
|
private fun getClickedItemIndex(x: Float, y: Float): Int {
|
if (!isExpanded) return -1
|
|
val maxWidth = legendItems.maxOf { item ->
|
maxOf(paint.measureText(item.description), iconSize.toFloat())
|
}
|
|
val totalWidth = legendItems.size * maxWidth +
|
(legendItems.size - 1) * itemSpacing
|
|
val startX = (width - totalWidth) / 2
|
val iconTop = paddingTop + (height - legendItemHeight) / 2
|
val iconBottom = iconTop + legendItemHeight
|
|
// 检查垂直方向是否在图例项范围内
|
if (y < iconTop || y > iconBottom) return -1
|
|
// 检查水平方向点击的是哪个图例项
|
legendItems.forEachIndexed { index, _ ->
|
val itemStartX = startX + index * (maxWidth + itemSpacing)
|
val itemEndX = itemStartX + maxWidth
|
if (x >= itemStartX && x <= itemEndX) {
|
return index
|
}
|
}
|
return -1
|
}
|
|
/**
|
* 切换图例项的选中状态
|
*/
|
private fun toggleItemSelection(index: Int) {
|
if (index < 0 || index >= legendItems.size) return
|
|
legendItems[index].isSelected = !legendItems[index].isSelected
|
legendItemClickListener?.onLegendItemClick(
|
index,
|
legendItems[index].isSelected
|
)
|
|
invalidate()
|
}
|
|
/**
|
* 设置展开后的字体大小
|
* @param size 字体大小(像素)
|
*/
|
fun setExpandedTextSize(size: Float) {
|
this.expandedTextSize = size
|
if (isExpanded) {
|
invalidate()
|
}
|
}
|
|
/**
|
* 设置展开后的字体大小(SP)
|
* @param sp 字体大小(SP)
|
*/
|
fun setExpandedTextSizeSp(sp: Float) {
|
setExpandedTextSize(sp * context.resources.displayMetrics.scaledDensity)
|
}
|
}
|