Android艺术之画一条平滑的曲线

前言

说的是曲线,其实想法是来自一个曲线图的需求。图表这种东西,项目开发中也不少见,大多情况找个通用的开源框架改改就得了(老板们别打我),然而通用赶不上脑洞,要做交互和视觉比较特别的图表时,还是自己造一个轮子比较靠谱,这次要研究的就是一个优雅而平滑的曲线怎么画出来。

实现方法分析

曲线图的责任概括起来就是把数据输出为对应的图像,我们这次需求的目标效果图是这样的:



坐标轴和指示线等功能不是这篇文章的重点,抛开它们先不讨论,这次研究的重点在于曲线的绘制,而说到绘制曲线,最常用的参数曲线函数就是贝塞尔曲线。

二次贝塞尔曲线:



三次贝塞尔曲线:

关于贝塞尔曲线更详细的内容戳这里,Android也提供了绘制贝塞尔曲线的方法,方法参数就是对应贝塞尔曲线的控制点:

// 二次贝塞尔曲线
path.quadTo(auxiliaryX, auxiliaryY, endPointX, endPointY);
canvas.drawPath(path, paint);
// 三次贝塞尔曲线
path.cubicTo(auxiliaryOneX, auxiliaryOneY, auxiliaryTwoX, auxiliaryTwoY, endPointX, endPointY);
canvas.drawPath(path, paint);

仔细分析目标效果图,我们可以把曲线图拆分为一段段短的曲线,每两个数据点之间用三次贝塞尔曲线来绘制,保证曲线经过每个数据点,如下图所示,红色和蓝色的线段分别是两条三次贝塞尔曲线。


这样我们的任务就变为确定每一段三次贝塞尔曲线四个控制点P0、P1、P2和P3的位置,毫无疑问,数据点可以作为曲线的起点P0和终点P3,某个数值点既是前一条曲线的终点也是后一条曲线的起点,而确定剩下的P1和P2的位置还需要一个重要的课堂知识:

光滑曲线必定处处可导。 —— 《高中数学·必修一》

要保证整条曲线图是光滑的,关键在于贝塞尔曲线的连接点也要保证光滑,而三次贝塞尔曲线中P0和P1的连线就是P0点的切线,P2和P3的连线是P3的切线,所以我们只要保证数据点左右控制点共线,就可以保证曲线在数据点处是可导光滑的。



如图所示,A0、A3、B3分别是三个数据点,A1、A2、B1、B2是控制点,A0、A1、A2和A3构建了一条三次贝塞尔曲线,B0、B1、B2和B3也构建了一条三次贝塞尔曲线,当A2和B1共线时,A2和B1的连线就是A3点的切线,数据点A3点就是可导光滑的。之后,只要我们使控制点A2、B1连线的斜率和A3左右数据点A0、B3连线的斜率保持一致,就可以让曲线的效果更自然。

设A0的坐标为(A0X,A0Y),A3的坐标为(A3X,A3Y),B3的坐标为(B3X,B3Y),控制点A2、B1的坐标计算方法如下:

令
A0和B3连线的斜率 k = (B3Y - A0Y) / (B3X - A0X)
常数 b = A3Y - k * A3X
则
A2的X坐标 A2X = A3X - (A3X - A0X) * rate
A2的Y坐标 A2Y = k * A2X + b
B1的X坐标 B1X = A3X + (B3X - A3X) * rate
B1的Y坐标 B1Y = k * B1X + b

rate是一个(0, 0.5)区间内的值,数值越大,数值点之间的曲线弧度越小。
除此以外,如果数值点是第一个点或者最后一个点,可以把斜率k视为0,然后只计算左控制点或者有控制点。
我们只要把每个数值点左右的控制点坐标计算出来,然后画出每一段曲线,就可以组成一个完整的圆滑曲线了。

代码实现

基本原理就是这么多,还是贴代码实际。先计算全部数据点的坐标,用mValuePointList保存起来,max是图表显示的最大值,scaleX和scaleY分别是单位长度

private fun calculateValuePoint(itemList: List<Item>, max: Float, scaleX: Float, scaleY: Float) {
    mValuePointList.clear()
    for ((i, item) in itemList.withIndex()) {
        val x = i * scaleX
        val y = (max - item.value) * scaleY
        mValuePointList.add(PointF(x, y))
    }
}

然后计算控制点的坐标,用mControlPointList保存起来

private fun calculateControlPoint(pointList: List<PointF>) {
    mControlPointList.clear()
    if (pointList.size <= 1) {
        return
    }
    for ((i, point) in pointList.withIndex()) {
        when (i) {
            0 -> {//第一项
                //添加后控制点
                val nextPoint = pointList[i + 1]
                val controlX = point.x + (nextPoint.x - point.x) * SMOOTHNESS
                val controlY = point.y
                mControlPointList.add(PointF(controlX, controlY))
            }
            pointList.size - 1 -> {//最后一项
                //添加前控制点
                val lastPoint = pointList[i - 1]
                val controlX = point.x - (point.x - lastPoint.x) * SMOOTHNESS
                val controlY = point.y
                mControlPointList.add(PointF(controlX, controlY))
            }
            else -> {//中间项
                val lastPoint = pointList[i - 1]
                val nextPoint = pointList[i + 1]
                val k = (nextPoint.y - lastPoint.y) / (nextPoint.x - lastPoint.x)
                val b = point.y - k * point.x
                //添加前控制点
                val lastControlX = point.x - (point.x - lastPoint.x) * SMOOTHNESS
                val lastControlY = k * lastControlX + b
                mControlPointList.add(PointF(lastControlX, lastControlY))
                //添加后控制点
                val nextControlX = point.x + (nextPoint.x - point.x) * SMOOTHNESS
                val nextControlY = k * nextControlX + b
                mControlPointList.add(PointF(nextControlX, nextControlY))
            }
        }
    }
}

最后绘制曲线和数值

//连接各部分曲线
mPath.reset()
val firstPoint = pointList.first()
mPath.moveTo(firstPoint.x, height)
mPath.lineTo(firstPoint.x, firstPoint.y)
for (i in 0 until pointList.size * 2 step 2) {
    val leftControlPoint = controlPointList[i]
    val rightControlPoint = controlPointList[i + 1]
    val rightPoint = pointList[i / 2 + 1]
    mPath.cubicTo(leftControlPoint.x, leftControlPoint.y, rightControlPoint.x, rightControlPoint.y, rightPoint.x, rightPoint.y)
}
val lastPoint = pointList.last()
//填充渐变色
mPath.lineTo(lastPoint.x, height)
mPath.lineTo(firstPoint.x, height)
mPaint.alpha = 255
mPaint.style = Paint.Style.FILL
mPaint.shader = LinearGradient(0F, 0F, 0F, height, COLOR_GRAPH_FILL, null, Shader.TileMode.CLAMP)
canvas.drawPath(mPath, mPaint)
//绘制全部路径
mPath.setLastPoint(lastPoint.x, height)
mPaint.strokeWidth = SIZE_GRAPH
mPaint.style = Paint.Style.STROKE
mPaint.shader = null
mPaint.color = COLOR_GRAPH
canvas.drawPath(mPath, mPaint)
for (i in 0..pointList.size()) {
    val point = pointList[i]
    //画数值线
    mPaint.color = COLOR_POINT
    mPaint.alpha = 100
    canvas.drawLine(point.x, point.y, point.x, height, mPaint)
    //画数值点
    mPaint.style = Paint.Style.FILL
    mPaint.alpha = 255
    canvas.drawCircle(point.x, point.y, SIZE_POINT, mPaint)
}

OK!大功告成,最终效果图:


剩下的刻度效果和滑动效果并不是太复杂,有时间再写一篇吧,谢谢各位看官支持。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335

推荐阅读更多精彩内容