管灌系统巡查员智能手机App
expand_button/src/main/java/com/example/expand_button/ExpandButton.kt
@@ -1,14 +1,644 @@
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 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)
    }
}