因为工作业务需要,需要在地图上显示点之间的连线,并且由点击事件,这个线路不是路线规划,也不是直线,还需要有点击事件的容错。因此寻思着自己写一个。
温馨提示: 本篇内容演示代码均为 Kotlin 语言。如果你是使用 Java ,那么部分方法或者关键字需要稍微做一下转换,但是本身算法和代码是通用的。
首先送上效果图
思路
拥有经纬度,那么想办法转化为屏幕坐标。
将一组组的屏幕坐标整理出来,先设计一组坐标的绘制方案。
绘制两点之间的曲线。
计算曲线的路径,得到点击事件。
设计方案 计算屏幕坐标 高德有提供一个工具方法,可以将经纬度转换为当前地图上的屏幕坐标。所以这个可以一行代码搞定。
1 2 3 4 5 6 7 8 val projection = aMap.projectionval startPoint = projection.toScreenLocation(bean.startLoc)val endPoint = projection.toScreenLocation(bean.endLoc)projection.toScreenLocation(LatLng)
绘制曲线 我们的线路中有起点和终点,两点之间的连线要弯曲成一定的弧度。 那么既然要绘制曲线,可以有几种方案: 1. 绘制一段弧线,Canvas.drawArc()。 2. 贝塞尔曲线连接。 但是弧线的话,需要计算圆心位置,半径信息。而这些就比较复杂了。因为一段任意的有方向的线段,要计算他的所在圆的圆心,并不容易。因此放弃了这个方法。 剩下的就是贝塞尔曲线了,我们可以考虑将辅助点从两点的中点 往与线段平行 的方向移动一点距离,从而得到一条弧线。
思路已经有了,那么下一步呢?怎么计算这个辅助点呢? 首先,按照上面的思路,总结出关键字:中点,平行 。 也就是说,我们要在线段的中点 位置做垂线 ,又是三角函数了。我就在想,有没有更加简单的方法?
方法也其实简单,我们在纸上怎么为线段画垂线的呢?三角板靠上去?如果不顺手怎么办?答案出来了,我们可以把纸张转一下啊。 那么我们是不是可以也借助这种思想呢?事实是可以的。 那么我们的算法就简单了,假设我们已经将画布旋转了,把线段与坐标系的X轴重叠,并且把起点放到了坐标系的起点位置,那么我们的线段就相当于X轴了,而给X轴做垂线?X不变,Y加上一段距离就好了,对吧。 那么,既然这样的话。 我们再整理一下
1 2 3 辅助点X = 线段长度 / 2 辅助点Y = 线段长度 * 弧度权重
好了,上面就是我们的算法。那么问题来了,根据上面的算法,我们又需要另外2个东西了,一个是旋转角度,一个是线段长度。 线段长度的算法很简单:就是简单的勾股定理而已。
1 2 3 4 5 6 private fun getLineLength (startPointF: PointF , endPointF: PointF ) : Float { return Math.sqrt(1.0 * (startPointF.x - endPointF.x)*(startPointF.x - endPointF.x) + (startPointF.y - endPointF.y)*(startPointF.y - endPointF.y)).toFloat() }
而我们的角度算法就稍微繁琐一点了,因为涉及到三角函数和坐标系问题,因为屏幕的坐标系和手机坐标系是不一样的,屏幕上,左上角是起点,然后向下是Y的正轴,所以有些颠倒。不过也还好吧。因为算法也就那样,所以直接贴上角度代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 private fun getAngle (start: PointF , end: PointF ) : Double { val a = (end.y - start.y) * 1.0 val b = (end.x - start.x) * 1.0 val angle = try { when { a > 0 && b >= 0 -> { 360 -Math.toDegrees(Math.atan(a / b)) } a > 0 && b < 0 -> { Math.toDegrees(Math.atan(a / -b)) - 180 } a <= 0 && b > 0 -> { Math.toDegrees(Math.atan(-a / b)) } else -> { 180 - Math.toDegrees(Math.atan(-a / -b)) } } }catch (e: Exception){ 0.0 } return angle }
本人能力有限,没有整理出一套通用的算法,所以我就只有四个象限分别计算了。
以上,2个主要参数都出来了,那么绘制呢?我们用Path来做绘制。也就是说,我们在Path里面拉出贝塞尔曲线,再Canvas.drawPath()。 而这里,我们使用三次贝塞尔来绘制,因为二次贝塞尔曲线不是很好看。
1 2 3 4 5 6 7 8 9 10 11 12 private fun onPointChange () { path.reset() path.moveTo(0F ,0F ) pointSpacing = getLineLength(startPoint,endPoint) path.cubicTo(pointSpacing/3 ,pointSpacing*-0.1F ,pointSpacing * 2 / 3 ,pointSpacing*-0.1F ,pointSpacing,0F ) saveClickPoint() }
这里说明一下path.cubicTo()函数,他有6个参数,分为3组,按照我的理解是:
1 2 3 4 5 { (上一个点的辅助点X,上一个点的辅助点Y), (下一个点的辅助点X,下一个点的辅助点Y), (下一个点的X,下一个点的Y) }
上面的算法可以看出来,我是将线段分为3份,分别在中间的2个节点插辅助点,辅助点的高度一样,都是长度的十分之一。这样的效果是不至于曲线峰顶不至于太过尖锐,让整个曲线比较平缓。而路径起点设置为(0,0)的原因是,前面说了,假设起点为坐标系原点。
路径已经设置好了,那么我们只要绘制就好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 override fun draw (canvas: Canvas ?) { if (canvas == null ){ return } canvas.save() canvas.translate(arrow.startPoint.x,arrow.startPoint.y) canvas.rotate(-1 *arrow.getAngle().toFloat()) canvas.drawPath(arrow.path,paint) canvas.restore() }
好了,这样就行了,代码里面的arrow是我将前面的算法封装到Bean里面了,绘制的时候只需要拿数据就好了,因为后续会有很多线段,如果全部都在绘制的时候,计算,那么会相当卡顿,如果我放到Bean里面,那么将绘制和计算分离开,那么就可以加快绘制的速度了。
前面绘制已经完了,理想的情况下,是可以绘制出一条曲线的了。但是截图里面,还有个箭头,本来是有2个箭头的,将曲线三等分,但是考虑UI复杂度,所以变成了一个箭头,但是代码实现是支持多箭头的。
箭头的数量,其实并不重要,不过是一个循环而已,重点是,箭头的方向要和曲线相同,也就是说,需要知道曲线的切线。
很幸运的是,Google为我们提供了方法(我将算法和iOS小伙伴交流的时候发现,他们没有,窃喜)。方法也是比较简单的,PathMeasure类,路径的测量类。
使用方法也是非常简单的,只需要将Path关联到PathMeasure类上即可,
1 pathMeasure.setPath(path, false )
参数有2个,第一个是路径,第二个boolean值是说,是否在计算的时候,将路径视为闭合的,也就是说,算的时候,当做path已经closed,但是不会影响path本身。很贴心的功能,但是我们不需要。
那么怎么用呢? 我们需要用到的只有这个方法:
1 2 3 val position = FloatArray(2 )val tangent = FloatArray(2 )pathMeasure.getPosTan(length,position,tangent)
getPosTan 方法的功能是:传入指定长度,然后将指定长度位置的坐标和对应的切线 position为传出的坐标位置。 tangent为传出的切线角度。 你可能会问了,曲线的长度呢?前面都是直线长度啊? 这里提一下,pathMeasure提供了获取路径长度的方法,叫做
这样就够了,我们就可以使用了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 private fun drawArrow (canvas: Canvas ,arrow: Arrow ) { canvas.save() paint.color = arrow.color paint.strokeWidth = lineWidth arrow.pathMeasure.setPath(arrow.path,false ) val position = FloatArray(2 ) val tangent = FloatArray(2 ) arrow.pathMeasure.getPosTan(arrow.pathMeasure.length / 2 ,position,tangent) val degrees = (Math.atan2(tangent[1 ].toDouble(), tangent[0 ].toDouble()) * 180.0 / Math.PI).toFloat() canvas.translate(position[0 ],position[1 ]) canvas.rotate(degrees) canvas.drawLine(0F ,0F ,-arrowSize,-arrowSize,paint) canvas.drawLine(0F ,0F ,-arrowSize,arrowSize,paint) canvas.restore() }
OK,绘制基本上就全部完成了,我贴一个完整的绘制方法出来,包含了起点和终点的小圆点,多条线段绘制等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 override fun draw (canvas: Canvas ?) { if (canvas == null ){ return } for (arrow in arrowList){ paint.color = arrow.color paint.strokeWidth = lineWidth paint.style = Paint.Style.STROKE canvas.save() canvas.translate(arrow.startPoint.x,arrow.startPoint.y) canvas.rotate(-1 *arrow.getAngle().toFloat()) canvas.drawPath(arrow.path,paint) drawArrow(canvas,arrow) paint.style = Paint.Style.FILL_AND_STROKE paint.strokeWidth = 1F paint.color = pointStrokeColor canvas.drawCircle(0F ,0F ,pointR+pointStroke,paint) canvas.drawCircle(arrow.pointSpacing,0F ,pointR+pointStroke,paint) paint.color = pointColor canvas.drawCircle(0F ,0F ,pointR,paint) canvas.drawCircle(arrow.pointSpacing,0F ,pointR,paint) canvas.restore() } }
OK,我们的绘制就都做完了,至于你说刷新?你自己去监听地图拖拽和缩放,然后刷新点坐标数据啊,然后发起重绘啊。
这部分不属于本次内容范畴,所以查查地图API吧。
点击事件 接下来是最后一步了,线路的点击,计算怎么点击到线路上?
我们来理一下,我们真实的需求吧,我真正的需求是,怎么才能判断点击的位置是否在曲线上,或者曲线范围内。
既然是这样的话,怎么做呢?方案很多,我这里的方案是打关键点。 就是说,我在弧线上打多个关键点,每个点为圆心做一个圆,如果点击位置在园内,那么圆所在的线路,就认为被点击了。
是不是感觉似曾相识?没错,又要用到前面的路径测量器了。
这个就很简单了,只是像上面绘制箭头一样,拿到坐标就好了。但是,又是但是。 我前面说了,我们前面的算法都是基于旋转和位移,也就说,所有的坐标都是相对的,而我们的点击位置都是绝对坐标。所以我们还要研究一下反向算法。
怎么矫正呢?
首先是角度,我们把点的角度倒过来转就好了,这里不能使用canvas的rotate()了,我们要计算计算,而这个算法说到底,就是B点绕A点旋转而已。对吧,所以这个算法,我是这样写的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private fun route (p: PointF , center: PointF , angle: Double ) { val x1 = p.x val y1 = p.y val x2 = center.x val y2 = center.y val radians = Math.toRadians(angle) p.x = ((x1 - x2) * Math.cos(radians) + (y1 - y2) * Math.sin(radians) + x2).toFloat() p.y = (-(x1 - x2) * Math.sin(radians) + (y1 - y2) * Math.cos(radians) + y2).toFloat() }
嗯,完成上面的算法,那么就把坐标换算的问题都解决了,接下来就是记录关键点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 private fun saveClickPoint () { val position = FloatArray(2 ) val tangent = FloatArray(2 ) pathMeasure.setPath(path,false ) while (clickPointList.size < clickPointSize){ clickPointList.add(PointF()) } while (clickPointList.size > clickPointSize){ clickPointList.removeAt(0 ) } val step = pathMeasure.length / clickPointList.size for (index in 0 until clickPointList.size){ pathMeasure.getPosTan(step * index ,position,tangent) clickPointList[index].set (position[0 ]+startPoint.x,position[1 ]+startPoint.y) route(clickPointList[index],startPoint,getAngle()) } }
如此就算是完了。对了,你会说,那么怎么用?很简单啊,for循环就好了啊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 private fun checkPoint (point: PointF ,x: Int ,y: Int ) : Boolean { val length = Math.sqrt(1.0 * (point.x - x)*(point.x - x) + (point.y - y)*(point.y - y)).toFloat() return length <= clickRadius } fun checkOnClick (x: Int ,y: Int ) { TaskUtils.get ().runAs { for (arrow in lineDrawable.arrowList){ for (point in arrow.clickPointList){ if (checkPoint(point,x, y)){ post { onMapLineClick(arrow) } return @runAs } } } post { onNoMapLineClick() } } }
至此,所有的逻辑都讲完了。最后再贴上完整的代码。
全部源码 线路的Bean
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 import android.graphics.*class Arrow { val startPoint = PointF() val endPoint = PointF() var color = Color.BLACK val path = Path() var id = "" var pointSpacing = 0F val clickPointList = ArrayList<PointF>() var clickPointSize = 0 var viewHeight = 0F val pathMeasure = PathMeasure() fun onPointChange (start: Point , end: Point ) { startPoint.set (start.x.toFloat(),start.y.toFloat()) endPoint.set (end.x.toFloat(),end.y.toFloat()) onPointChange() } fun onPointChange (startX: Float ,startY: Float , endX: Float , endY: Float ) { startPoint.set (startX,startY) endPoint.set (endX,endY) onPointChange() } fun onPointChange (start: PointF , end: PointF ) { startPoint.set (start) endPoint.set (end) onPointChange() } private fun onPointChange () { path.reset() path.moveTo(0F ,0F ) pointSpacing = getLineLength(startPoint,endPoint) path.cubicTo(pointSpacing/3 ,pointSpacing*-0.1F ,pointSpacing * 2 / 3 ,pointSpacing*-0.1F ,pointSpacing,0F ) saveClickPoint() } private fun getLineLength (startPointF: PointF , endPointF: PointF ) : Float { return Math.sqrt(1.0 * (startPointF.x - endPointF.x)*(startPointF.x - endPointF.x) + (startPointF.y - endPointF.y)*(startPointF.y - endPointF.y)).toFloat() } private fun saveClickPoint () { val position = FloatArray(2 ) val tangent = FloatArray(2 ) pathMeasure.setPath(path,false ) while (clickPointList.size < clickPointSize){ clickPointList.add(PointF()) } while (clickPointList.size > clickPointSize){ clickPointList.removeAt(0 ) } val step = pathMeasure.length / clickPointList.size for (index in 0 until clickPointList.size){ pathMeasure.getPosTan(step * index ,position,tangent) clickPointList[index].set (position[0 ]+startPoint.x,position[1 ]+startPoint.y) route(clickPointList[index],startPoint,getAngle()) } } fun getAngle () : Double { return getAngle(startPoint,endPoint) } private fun getAngle (start: PointF , end: PointF ) : Double { val a = (end.y - start.y) * 1.0 val b = (end.x - start.x) * 1.0 val angle = try { when { a > 0 && b >= 0 -> { 360 -Math.toDegrees(Math.atan(a / b)) } a > 0 && b < 0 -> { Math.toDegrees(Math.atan(a / -b)) - 180 } a <= 0 && b > 0 -> { Math.toDegrees(Math.atan(-a / b)) } else -> { 180 - Math.toDegrees(Math.atan(-a / -b)) } } }catch (e: Exception){ 0.0 } return angle } private fun route (p: PointF , center: PointF , angle: Double ) { val x1 = p.x val y1 = p.y val x2 = center.x val y2 = center.y val radians = Math.toRadians(angle) p.x = ((x1 - x2) * Math.cos(radians) + (y1 - y2) * Math.sin(radians) + x2).toFloat() p.y = (-(x1 - x2) * Math.sin(radians) + (y1 - y2) * Math.cos(radians) + y2).toFloat() } }
绘制线路的Drawable
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 import android.graphics.*import android.graphics.drawable.Drawableclass MapLineDrawable : Drawable () { private val paint = Paint().apply { style = Paint.Style.STROKE isAntiAlias = true isDither = true } val arrowList = ArrayList<Arrow>() var lineWidth = 5F var pointR = 5F var pointStroke = 2F var pointStrokeColor = Color.WHITE var pointColor = Color.CYAN var arrowSize = 2F var clickRadius = 2F override fun draw (canvas: Canvas ?) { if (canvas == null ){ return } for (arrow in arrowList){ paint.color = arrow.color paint.strokeWidth = lineWidth paint.style = Paint.Style.STROKE canvas.save() canvas.translate(arrow.startPoint.x,arrow.startPoint.y) canvas.rotate(-1 *arrow.getAngle().toFloat()) canvas.drawPath(arrow.path,paint) drawArrow(canvas,arrow) paint.style = Paint.Style.FILL_AND_STROKE paint.strokeWidth = 1F paint.color = pointStrokeColor canvas.drawCircle(0F ,0F ,pointR+pointStroke,paint) canvas.drawCircle(arrow.pointSpacing,0F ,pointR+pointStroke,paint) paint.color = pointColor canvas.drawCircle(0F ,0F ,pointR,paint) canvas.drawCircle(arrow.pointSpacing,0F ,pointR,paint) canvas.restore() } } private fun drawArrow (canvas: Canvas ,arrow: Arrow ) { canvas.save() paint.color = arrow.color paint.strokeWidth = lineWidth arrow.pathMeasure.setPath(arrow.path,false ) val position = FloatArray(2 ) val tangent = FloatArray(2 ) arrow.pathMeasure.getPosTan(arrow.pathMeasure.length / 2 ,position,tangent) val degrees = (Math.atan2(tangent[1 ].toDouble(), tangent[0 ].toDouble()) * 180.0 / Math.PI).toFloat() canvas.translate(position[0 ],position[1 ]) canvas.rotate(degrees) canvas.drawLine(0F ,0F ,-arrowSize,-arrowSize,paint) canvas.drawLine(0F ,0F ,-arrowSize,arrowSize,paint) canvas.restore() } override fun setAlpha (alpha: Int ) { paint.alpha = alpha invalidateSelf() } override fun getOpacity () : Int { return PixelFormat.TRANSPARENT } override fun setColorFilter (colorFilter: ColorFilter ?) { paint.colorFilter = colorFilter invalidateSelf() } }
绘制线路的View
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 import android.content.Contextimport android.graphics.Colorimport android.graphics.Pointimport android.graphics.PointFimport android.util.AttributeSetimport android.util.TypedValueimport android.widget.ImageViewimport java.util.*import kotlin.collections.ArrayListclass MapLineView (context: Context, attrs: AttributeSet?, defStyleAttr:Int ): ImageView(context, attrs, defStyleAttr), OnMapLineClickListener { constructor (context: Context, attrs: AttributeSet?):this (context,attrs,0 ) constructor (context: Context):this (context,null ) private val recyclingList = LinkedList<Arrow>() private val lineDrawable = MapLineDrawable() private val onMapLineClickListenerList = ArrayList<OnMapLineClickListener>() private var clickRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,20F ,context.resources.displayMetrics) init { background = lineDrawable lineDrawable.lineWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,2F ,context.resources.displayMetrics) lineDrawable.pointR = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,3F ,context.resources.displayMetrics) lineDrawable.pointStroke = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,1F ,context.resources.displayMetrics) lineDrawable.pointStrokeColor = Color.WHITE lineDrawable.arrowSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,3F ,context.resources.displayMetrics) lineDrawable.clickRadius = clickRadius lineDrawable.pointColor = 0xFFFFAB3A .toInt() } fun clear () { recyclingList.addAll(lineDrawable.arrowList) lineDrawable.arrowList.clear() } fun add (start: PointF , end: PointF ,color: Int ) : Arrow{ return add(getArrow().apply { onPointChange(start, end) this .color = color }) } fun add (startX: Float ,startY: Float , endX: Float , endY: Float ,color: Int ) : Arrow{ return add(getArrow().apply { onPointChange(startX,startY,endX,endY) this .color = color }) } fun add (start: Point , end: Point , color: Int ) : Arrow{ return add(getArrow().apply { onPointChange(start, end) this .color = color }) } fun add (arrow: Arrow ) : Arrow{ lineDrawable.arrowList.add(arrow) lineDrawable.invalidateSelf() arrow.id = "arrow:$arrow " arrow.clickPointSize = 40 arrow.viewHeight = height.toFloat() return arrow } fun update () { lineDrawable.invalidateSelf() } private fun getArrow () : Arrow{ return if (recyclingList.isEmpty()){Arrow()}else {recyclingList.removeFirst()} } override fun onMapLineClick (arrow: Arrow ) { for (lis in onMapLineClickListenerList){ lis.onMapLineClick(arrow) } } fun addOnMapLineClickListener (lis: OnMapLineClickListener ) { onMapLineClickListenerList.add(lis) } fun checkOnClick (x: Int ,y: Int ) { invalidate() TaskUtils.get ().runAs { for (arrow in lineDrawable.arrowList){ for (point in arrow.clickPointList){ if (checkPoint(point,x, y)){ post { onMapLineClick(arrow) } return @runAs } } } post { onNoMapLineClick() } } } private fun checkPoint (point: PointF ,x: Int ,y: Int ) : Boolean { val length = Math.sqrt(1.0 * (point.x - x)*(point.x - x) + (point.y - y)*(point.y - y)).toFloat() return length <= clickRadius } override fun onNoMapLineClick () { for (lis in onMapLineClickListenerList){ lis.onNoMapLineClick() } } }
遗留问题 以上就是全部内容了,可以看出上面的算法还有很多的不足。
点击位置的精确计算问题。 难点:曲线的检查并不像直线那样简单,怎么做到又精确又快?
绘制的冗余问题,超出屏幕的部分是应该跳过的。 难点:起点或者终点都不在屏幕中时,怎么判断曲线经过了屏幕?
但是我能力有限,没有想到更好,效率更高的解决办法。如果你有好的方案,欢迎联系我。 相信聪明的你可以找到我的联系方式的。
最后,感谢你来我的小站。