| | |
| | | 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毫秒 |
| | |
| | | // 三角形图标与文字的间距,默认为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, |
| | |
| | | ).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() |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | // 设置文本可点击,仅在收起状态时响应点击展开 |
| | | 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() |
| | |
| | | ) |
| | | |
| | | // 设置单行显示,防止高度变化 |
| | | 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, |
| | |
| | | iconTop + iconSize |
| | | ) |
| | | |
| | | // 旋转画布 |
| | | canvas.rotate( |
| | | triangleRotation, |
| | | (iconLeft + iconSize / 2).toFloat(), |
| | | (iconTop + iconSize / 2).toFloat() |
| | | ) |
| | | |
| | | // 绘制图标 |
| | | triangleDrawable.draw(canvas) |
| | | |
| | | // 恢复画布状态 |
| | | canvas.restore() |
| | | |
| | | super.onDraw(canvas) |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | /** |
| | | * 切换展开/收起状态 |
| | | * 使用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() |
| | | |
| | | // 创建宽度动画 |
| | |
| | | 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 |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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() |
| | | } |
| | | |
| | |
| | | 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) |
| | | } |
| | | |
| | | /** |
| | |
| | | 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) |
| | | } |
| | | } |