前言
拖动小球图表? 什么鬼. 我们直接看图吧
这只是个半成品, 博主没有做完. 连原型长什么样子我都忘了. 产品拿着好像是一个天气应用. 让我比着做. 博主做的不算慢(也就一天左右吧), 但产品居然说不做就不做了. 业务不做梳理和规划, 说做啥就做啥的行为, 很让人头大!!
虽然没做完, 但核心逻辑都已经写出来了. 今天博主重新整理了一下, 就当巩固自定义View的知识了!
一、分析
简单元素: Y轴刻度线, XY轴标注, 区域计算划分等
折线点, 折线. 选中区域 标注高亮等
绘制小球. 以及圆弧区域. 我们用 贝赛尔曲线 绘制
监听 触摸事件, 拖动小球, 以及最终小球吸附动画
博主已经不记得这个图表是要干啥呢, 大部分参数都写死了…
二、贝赛尔曲线
不了解的可以自行百度.
简单解释: 三角形的两条边 上面分别有两个点. 同时从一端向另一端匀速运动. 两个点的连线 就是圆弧的切线
还是看图形象, 随便从网上搬了一个…!!
1.曲线绘制思路
小球左右 及 小球顶部 各一个贝赛尔曲线(共3个). 至于点位, 博主是估量选择的.
怎奈博主画图水平为0, 用电脑自带的画图软件画了一下, 各位看官 将就一下哈 😂
三、上代码吧
注释都写在代码里了.
1.自定义View代码
class DraggableBallChartView(context: Context, attrs: AttributeSet?)
: View(context, attrs), ValueAnimator.AnimatorUpdateListener {
// **************** 画笔 ***************
private val mPaintFill: Paint // 画 文字, 背景, 小球
private val mPaintStroke: Paint // 画 线, 小球描边
private var mPath: Path
// **************** 小球相关尺寸 ***************
private val ballRadius = 36f // 小球的初始半径(拖动半径. 不拖动时更小);
private val touchRadius = 42f // 小球的触控半径 (适当增大触控范围)
private val dragStart = 4f * ballRadius // 小球允许拖动的左侧最小剩余宽度
private var dragEnd = 0f // 小球右侧最小剩余宽度; 要减去Y轴标注的宽度
private var ballX = 0f // 小球的X轴位置, 初始等于 dragStart+paddingStart
private val curveKzd = 2.5f * ballRadius // 贝塞尔曲线, 两侧控制点 与圆心的X轴距离
private val curveJsd = 1.4f * ballRadius // 贝塞尔曲线, 两侧结束点 某控制距离
private var curveHighestY = 0f // 贝塞尔曲线, 中段 最高控制点的高度;
// **************** 刻度线, 折线 ***************
private var mScalelines: FloatArray? = null // Y轴刻度线. 灰色横线;
private var mChartLines: FloatArray? = null // 绿色折线
private val pointRadius = 12f // 折线点半径
private val lingHealth = 4f // 圆描边灰线的宽度
private val halfLine = lingHealth / 2f // 直线的宽度
private var mLineSpace: Float = 0f // Y轴刻度线间距
private var mWidthSpace: Float = 0f // X轴 折线点之间的距离
private var mFirstWidth: Float = 0f // X轴 首个折线点的位置
private var mTextY: Float = 0f // X轴 标注的 字底位置
// **************** 颜色参数 ***************
private val colorGrayLine: Int = Color.parseColor("#EAECEE") //灰线的颜色
private val colorTextOn = Color.parseColor("#333333") //选中的文字颜色
private val colorTextOff = Color.parseColor("#818CA4") //未选中的文字颜色
private val colorPointOn = Color.parseColor("#24D49C") //选中的点颜色
private val colorPointOff = Color.parseColor("#91E9CD") //未选中的点颜色
// **************** 折线参数值, 横轴参数值 目前写死了 ***************
private var texts = arrayOf("4月", "5月", "6月", "7月", "8月")
private var values = arrayOf(175, 178, 186, 173, 167)
private var valuesY: FloatArray? = null // 每个折线点的具体高度;
private var current = 0 // 当前选中项索引(目前只有折线点和文字高色)
// **************** 拖动小球的参数 ***************
private var downX = 0f // 拖动的起始点击位置;
private var isDrag = false // 是否在拖动过程中
private var lastX = 0f // 点击小球时, 小球的位置记录
private var mAnimator: ValueAnimator? = null
init {
// 创建画笔
mPaintFill = Paint(Paint.ANTI_ALIAS_FLAG).also {
it.style = Paint.Style.FILL
it.textSize = 36f
}
mPaintStroke = Paint(Paint.ANTI_ALIAS_FLAG)
mPaintStroke.style = Paint.Style.STROKE
mPath = Path()
dragEnd = dragStart + getTextWidth(mPaintFill, "190")
// 小球初始位置
ballX = dragStart + paddingStart
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
calculateOfPosition(measuredWidth, measuredHeight)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
calculateOfPosition(w, h)
}
/**
* 计算位置; 1.横线位置; 2.横线坐标数组 3.X轴标注位置(横纵); 4.Y轴标注; 5.点位; 6.折线数组
*/
private fun calculateOfPosition(w: Int, h: Int){
//横线位置: 假设底部预留 6倍小球半径 的空间(标注 及 小球); 刻度线固定为4条;
mLineSpace = (h - ballRadius * 6f) / 4f
val end = (w - paddingEnd).toFloat()
mScalelines = floatArrayOf(
paddingStart.toFloat(), mLineSpace, end, mLineSpace,
paddingStart.toFloat(), mLineSpace * 2f, end, mLineSpace * 2f,
paddingStart.toFloat(), mLineSpace * 3f, end, mLineSpace * 3f,
paddingStart.toFloat(), mLineSpace * 4f, end, mLineSpace * 4f
)
// X轴标注 高度位置
mTextY = height - ballRadius * 4
// X轴 首点位置, 及点间距;
mFirstWidth = paddingStart + dragStart
mWidthSpace = (w - paddingStart - paddingEnd - dragStart - dragEnd) / (texts.size - 1)
// 维护折线点 Y坐标; 这里最大值200f 是写死的;
valuesY = FloatArray(texts.size)
for (i in texts.indices){
valuesY!![i] = (200f - values[i]) / 10f * mLineSpace
}
// 维护折线数组
mChartLines = FloatArray(texts.size * 4)
for (i in 0..texts.size-2){
if(i < texts.size - 1){
mChartLines!![i*4] = mFirstWidth + mWidthSpace * i
mChartLines!![i*4 + 1] = valuesY!![i]
mChartLines!![i*4 + 2] = mFirstWidth + mWidthSpace * (i + 1)
mChartLines!![i*4 + 3] = valuesY!![i + 1]
}
}
// 贝塞尔曲线 中段 最高控制点高度
curveHighestY = height - 3f * ballRadius
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return
// 绘制背景, 以及小球
drawBgAndBall(canvas)
// 绘制 Y轴刻度线, 以及刻度值
drawScaleLineAndMark(canvas)
// 绘制折线, 折线点, X轴标注
drawBrokenLineAndMark(canvas)
}
/**
* 绘制背景, 小球
*/
private fun drawBgAndBall(canvas: Canvas){
// 白色背景, 填充模式
mPaintFill.color = Color.WHITE
mPath.reset()
// 小球不拖动时, 小一点;
val realRadius = if(isDrag){
ballRadius
} else {
ballRadius - 4f
}
// 移动到曲线起点. 然后 三段贝塞尔曲线
val heightJsd = height - curveJsd
mPath.moveTo(ballX - dragStart, height.toFloat())
mPath.quadTo(ballX - curveKzd, height.toFloat(), ballX - curveJsd, heightJsd)
mPath.quadTo(ballX, curveHighestY, ballX + curveJsd, heightJsd)
mPath.quadTo(ballX + curveKzd, height.toFloat(), ballX + dragStart, height.toFloat())
mPath.lineTo(width.toFloat(), height.toFloat())
mPath.lineTo(width.toFloat(), 0f)
mPath.lineTo(0f, 0f)
mPath.lineTo(0f, height.toFloat())
mPath.close()
// 白色小球
val centerY = height - realRadius - halfLine
mPath.addCircle(ballX, centerY, realRadius - halfLine, Path.Direction.CW)
canvas.drawPath(mPath, mPaintFill)
// 绘制小球描边
mPaintStroke.strokeWidth = lingHealth
mPaintStroke.color = colorGrayLine
canvas.drawCircle(ballX, centerY, realRadius, mPaintStroke)
}
/**
* 绘制 Y轴刻度线, 以及刻度值
*/
private fun drawScaleLineAndMark(canvas: Canvas){
//绘制横线; Y轴刻度线
mScalelines?.let {
mPaintStroke.strokeWidth = halfLine
canvas.drawLines(it, mPaintStroke)
}
//绘制Y轴标注
mPaintFill.textAlign = Paint.Align.RIGHT
mPaintFill.color = colorTextOff
val end = (width - paddingEnd).toFloat()
canvas.drawText("190", end, mLineSpace - 8f, mPaintFill)
canvas.drawText("180", end, mLineSpace * 2f - 8f, mPaintFill)
canvas.drawText("170", end, mLineSpace * 3f - 8f, mPaintFill)
canvas.drawText("0", end, mLineSpace * 4f - 8f, mPaintFill)
}
/**
* 绘制折线, 折线点, X轴标注
*/
private fun drawBrokenLineAndMark(canvas: Canvas){
// 绘制折线
mChartLines?.let {
mPaintStroke.color = colorPointOn
canvas.drawLines(it, mPaintStroke)
}
mPaintFill.textAlign = Paint.Align.CENTER
for(i in texts.indices){
// 折线点, X轴标注
val x = mFirstWidth + mWidthSpace * i
mPaintFill.color = if(current == i) colorPointOn else colorPointOff
canvas.drawCircle(x, valuesY!![i], pointRadius, mPaintFill)
mPaintFill.color = if(current == i) colorTextOn else colorTextOff
canvas.drawText(texts[i], x, mTextY, mPaintFill)
}
}
/**
* 处理事件分发
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x // 记录x坐标
val downY = event.y // 记录y坐标
// 判断是否点到了小球
if (downX <= ballX + touchRadius
&& downX >= ballX - touchRadius
&& downY >= height - touchRadius * 2
&& downY <= height) {
cancleAinimator()
isDrag = true
lastX = ballX
}
}
MotionEvent.ACTION_MOVE -> if (isDrag) {
val moveX = event.x - downX
if (abs(moveX) > 5) { // 偏移量的绝对值大于 5 为 滑动事件
var nowCenter = lastX + moveX
// 超限控制
if (nowCenter < mFirstWidth)
nowCenter = mFirstWidth
if (nowCenter > width - dragEnd - paddingEnd)
nowCenter = width - dragEnd - paddingEnd
ballX = nowCenter
invalidate()
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> if (isDrag) {
isDrag = false
var nowX = event.x
// 超限控制
if (nowX < mFirstWidth)
nowX = mFirstWidth
if (nowX > width - dragEnd - paddingEnd)
nowX = width - dragEnd - paddingEnd
// 计算当前索引
current = ((nowX - mFirstWidth) / mWidthSpace + 0.5f).toInt()
// 属性动画, 移动小球位置. 让其吸附在准确位置;
val targetX = mFirstWidth + current * mWidthSpace
startAnimator(nowX, targetX, nowX - targetX)
}
}
return true
}
private fun startAnimator(nowX: Float, toX: Float, dis: Float) {
cancleAinimator()
// 时间跟距离为线性关系
val time = abs(dis).toLong()
// 太近的话就不执行动画了;
if (time < 10) {
ballX = toX
invalidate()
return
}
mAnimator = ValueAnimator.ofFloat(nowX, toX).also {
it.duration = time
it.interpolator = AccelerateDecelerateInterpolator()
it.addUpdateListener(this)
it.start()
}
}
override fun onAnimationUpdate(animation: ValueAnimator) {
ballX = animation.animatedValue as Float
invalidate()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cancleAinimator()
}
private fun cancleAinimator() {
mAnimator?.cancel()
mAnimator = null
}
/**
* 获取文字宽度
*/
fun getTextWidth(paint: Paint, str: String): Int {
var iRet = 0
if (str.isNotEmpty()) {
val widths = FloatArray(str.length)
paint.getTextWidths(str, widths)
for (element in widths) {
iRet += ceil(element).toInt()
}
}
return iRet
}
}
2.布局代码
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/black">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".test.customview.DraggableBallChartActivity">
<com.example.kotlinmvpframe.test.customview.custom.DraggableBallChartView
android:id="@+id/dbc_ball"
android:layout_width="match_parent"
android:layout_height="240dp"
android:layout_marginTop="20dp"
android:paddingHorizontal="12dp"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
总结
没有总结
上一篇: 记一次自定义View:滑动标尺
下一篇: 酝酿中…