管灌系统巡查员智能手机App
expand_button/src/main/java/com/example/expand_button/ExpandButton.kt
@@ -25,10 +25,23 @@
    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 = ""
    private var expandedText: String = "标注点: 当前位置\n区域: 配送范围"
    // 收起时显示的单个字符
    private var collapsedText: String = ""
    private var collapsedText: String = "图例"
    // 当前是否处于展开状态
    private var isExpanded: Boolean = false
    // 动画持续时间,默认300毫秒
@@ -50,7 +63,33 @@
    // 三角形图标与文字的间距,默认为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
    init {
        // 保存 XML 中设置的默认字体大小
        defaultTextSize = textSize
        // 读取自定义属性
        context.theme.obtainStyledAttributes(
            attrs,
@@ -60,10 +99,14 @@
        ).apply {
            try {
                customLetterSpacing = getDimension(R.styleable.ExpandButton_letterSpacing, customLetterSpacing)
                expandedText = getString(R.styleable.ExpandButton_expandedText) ?: ""
                collapsedText = getString(R.styleable.ExpandButton_collapsedText) ?: ""
                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()
            }
@@ -80,26 +123,11 @@
            }
        }
        // 设置文本可点击,仅在收起状态时响应点击展开
        setOnClickListener {
            if (!isExpanded) {
                toggleExpand()
            }
        }
        // 添加触摸事件处理
        setOnTouchListener { _, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    // 检查点击是否在三角形图标区域内
                    if (isClickOnTriangle(event.x)) {
                        toggleExpand()
                        return@setOnTouchListener true
                    }
                }
            }
            false
        }
        // 修改触摸事件处理
        setOnTouchListener(null) // 移除原有的触摸监听器
        // 移除原有的点击监听器
        setOnClickListener(null)
        // 设置左边距,为图标留出空间
        compoundDrawablePadding = triangleMargin.toInt()
@@ -111,23 +139,107 @@
        )
        // 设置单行显示,防止高度变化
        maxLines = 1
        isSingleLine = true
        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,
@@ -135,20 +247,15 @@
            iconTop + iconSize
        )
        
        // 旋转画布
        canvas.rotate(
            triangleRotation,
            (iconLeft + iconSize / 2).toFloat(),
            (iconTop + iconSize / 2).toFloat()
        )
        
        // 绘制图标
        triangleDrawable.draw(canvas)
        
        // 恢复画布状态
        canvas.restore()
        super.onDraw(canvas)
    }
    /**
@@ -193,13 +300,18 @@
    /**
     * 切换展开/收起状态
     * 使用ValueAnimator实现宽度动画和图标旋转
     */
    private fun toggleExpand() {
        isExpanded = !isExpanded
        // 计算收起和展开状态的宽度
        val collapsedWidth = paint.measureText(collapsedText).toInt() + paddingLeft + paddingRight
        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()
        // 创建宽度动画
@@ -225,31 +337,46 @@
            duration = animationDuration
            addUpdateListener { animator ->
                triangleRotation = animator.animatedValue as Float
                invalidate() // 重绘以更新图标旋转
                invalidate()
            }
            start()
        }
        // 更新文本
        // 更新文本和字体大小
        if (isExpanded) {
            setExpandedClickableText()
            paint.textSize = expandedTextSize
        } else {
            text = collapsedText
            paint.textSize = defaultTextSize
            setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, defaultTextSize)
        }
        invalidate()
    }
    /**
     * 计算展开后的总宽度
     */
    private fun calculateExpandedWidth(): Int {
        val spaceWidth = paint.measureText(" ") * (customLetterSpacing / 10)
        // 计算所有字符的总宽度
        val textWidth = expandedText.fold(0f) { acc, char ->
            acc + paint.measureText(char.toString())
        // 临时保存当前字体大小
        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
        }
        // 计算间距的总宽度(字符数量减1个间距)
        val spacesWidth = spaceWidth * (expandedText.length - 1)
        return (textWidth + spacesWidth).toInt() + paddingLeft + paddingRight
    }
    /**
@@ -257,39 +384,76 @@
     */
    private fun setExpandedClickableText() {
        val builder = SpannableStringBuilder()
        expandedText.forEachIndexed { index, char ->
            // 添加字符
            builder.append(char)
        // 计算所有图例项中最宽的宽度
        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(char, index)
                    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,
                builder.length - 1,
                builder.length,
                startPosition,
                startPosition + item.description.length,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
            // 只在非最后一个字符后添加空格作为间距
            if (index < expandedText.length - 1) {
                builder.append(" ".repeat((customLetterSpacing / 10).toInt()))
            }
        }
        
        // 设置文本对齐方式为居中
        gravity = Gravity.CENTER
        text = builder
        // 启用LinkMovementMethod以响应ClickableSpan的点击事件
        movementMethod = android.text.method.LinkMovementMethod.getInstance()
    }
@@ -299,7 +463,10 @@
    private fun isClickOnTriangle(x: Float): Boolean {
        val iconSize = triangleDrawable.intrinsicWidth
        val iconLeft = paddingLeft - iconSize - compoundDrawablePadding
        return x <= paddingLeft && x >= iconLeft
        // 扩大点击区域:左右各增加 triangleClickPadding
        return x <= (paddingLeft + triangleClickPadding) &&
               x >= (iconLeft - triangleClickPadding)
    }
    /**
@@ -316,4 +483,129 @@
            paddingBottom
        )
    }
    /**
     * 设置图例内容
     */
    @JvmName("setLegendsList")
    fun setLegends(items: List<Triple<Drawable, Drawable, String>>) {
        legendItems = items.map { (selectedIcon, unselectedIcon, description) ->
            selectedIcon.setBounds(0, 0, iconSize, iconSize)
            unselectedIcon.setBounds(0, 0, iconSize, iconSize)
            LegendItem(selectedIcon, unselectedIcon, description)
        }
        if (!isExpanded) {
            text = collapsedText
        } else {
            invalidate()
        }
        requestLayout()
    }
    // 添加一个 Java 友好的方法
    @JvmName("setLegendsArray")
    fun setLegends(vararg items: Triple<Drawable, Drawable, String>) {
        setLegends(items.toList())
    }
    /**
     * 添加设置监听器的方法
     */
    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)
    }
}