<think>我们计划开发一个自定义View,实现以下功能:
1. 圆弧扫描角度为150度(从-75度到75度,或者从105度到255度,具体看起点位置,这里我们选择从左侧开始,所以起点是-75度,终点75度,总共150度)
2. 圆弧上有一个倒三角指示器,可以随着用户拖动而移动,指示当前进度
3. 进度可以拖动,即用户触摸并拖动指示器时,进度随之改变
实现思路:
1. 自定义View,重写onDraw方法绘制圆弧和指示器(倒三角)。
2. 重写onTouchEvent方法,处理触摸事件,根据触摸点位置更新进度。
3. 绘制圆弧:使用drawArc方法绘制背景圆弧和进度圆弧。
4. 绘制指示器:在圆弧上根据当前进度绘制一个倒三角形。
5. 计算触摸点是否在指示器附近,并实现拖动。
步骤:
1. 定义必要的属性:圆弧的宽度、颜色,进度颜色,指示器颜色,当前进度,最大进度等。
2. 在构造方法中初始化属性,并获取自定义属性(如果需要)。
3. 在onSizeChanged中计算圆弧的矩形区域(RectF)。
4. 在onDraw中:
a. 绘制背景圆弧(灰色)
b. 绘制进度圆弧(进度部分,不同颜色)
c. 根据当前进度计算指示器在圆弧上的位置,绘制倒三角指示器。
5. 在onTouchEvent中,检测触摸事件,判断触摸点是否在指示器附近,如果是则根据触摸点更新进度(需要将触摸点坐标转换为角度,再转换为进度值)。
注意:由于扫描角度是150度,所以我们需要从-75度开始画到75度(或者从105度到255度,但为了对称,我们选择以顶部为0度,左侧为负,右侧为正,所以从-105度到-105+150=45度?不,通常0度在右侧,但我们的圆弧是对称的,所以我们可以调整起始角度为-105度(即从左上开始),然后扫过150度到45度(右下)?但用户要求是150度,我们选择对称的圆弧,即圆弧的起点在左下方,终点在右下方,中间顶部为最高点?或者我们选择从顶部开始,但扫描150度,那么起点是90+75=165度?为了避免混淆,我们这样定义:
我们将圆弧的起点定义为从水平方向(3点钟方向)为0度,逆时针为正。但我们希望圆弧对称于垂直方向(即顶部为对称轴),那么:
总扫描角度150度,那么从90+75=165度开始,到90-75=15度结束?这样不对,因为165度到15度是逆时针旋转了150度(165到180是15度,0到15是15度,中间180到0是180度,总共15+180+15=210度,不对)。所以我们需要调整坐标系。
另一种常见的做法:将圆弧的起始点设为-90度(顶部为0度,顺时针为正,那么-90度在左侧,90度在右侧),但我们希望对称,所以从-75度到75度(相对于顶部对称),那么总扫描角度为150度。但是,在Android中,drawArc方法使用的角度是:
起始角度:0度表示3点钟方向,逆时针为正。所以,如果我们希望圆弧对称于y轴(顶部),那么起始角度应该是90+75=165度(从3点钟方向逆时针165度,即从左上开始),然后顺时针扫描150度(因为第三个参数是扫描角度,我们使用正数表示顺时针,负数表示逆时针)?这样就会从165度顺时针画到315度(即-45度)?不对,这样不对称。
重新调整:我们希望圆弧的对称轴是垂直向上(y轴负方向),那么圆弧的起点和终点应该关于y轴对称。所以,我们选择起始角度为180度+75度=255度(从3点钟方向逆时针255度,即位于左下方),然后顺时针扫描150度到45度(右下方)。这样圆弧就是对称的。但是这样计算复杂。
更简单的方法:我们将圆弧的起始角度设置为120度(从3点钟方向逆时针120度,即左上),然后扫描150度(顺时针150度)到270度(右下)?这样也不对称。
实际上,我们可以这样定义:圆弧的起始角度为-105度(相对于3点钟方向,顺时针105度,即左上),然后扫描150度(顺时针150度)到45度(右下)。这样圆弧关于y轴对称。
但是,在Android中,drawArc的起始角度是0度指向正右方(3点钟方向),正角表示逆时针,负角表示顺时针。而扫描角度可以是正(逆时针)或负(顺时针)。如果我们希望从左上(-105度)开始顺时针画150度到右下(45度),那么:
起始角度:-105度(相当于逆时针旋转105度到左上,但注意负号表示顺时针,所以-105度实际上是顺时针105度,即从0度顺时针转105度,到达255度的位置(即-105度)?不对,0度顺时针105度是255度?不对,0度顺时针90度是270度,105度就是255度?不对,0度顺时针105度:0->360-105=255度?是的,255度。然后扫描150度顺时针(扫描角度为负值-150度)?或者用正角表示:起始角度255度,扫描角度150度顺时针(即用负数表示,-150度)?但drawArc的扫描角度用正数表示逆时针,负数表示顺时针。
所以,我们可以这样:
起始角度:255度(即从0度逆时针转255度,到达255度的位置,也就是左上方向)
扫描角度:-150度(顺时针150度,从255度顺时针150度到达255-150=105度?不对,角度是循环的,255-150=105度,但105度在右下?不对,255度在左上(255度在第三象限),顺时针150度后到达255-150=105度,在第二象限?不对,255度顺时针150度:255-150=105度,105度在第二象限(右上)?但我们希望到达右下(45度)?所以不对。
我们重新计算:我们希望圆弧从左上(大约210度)开始,到右下(大约330度)结束,且对称于y轴。那么对称轴是270度(即正上方12点钟方向是270度?在Android中,0度是3点钟方向,90度是12点钟方向,180度是9点钟方向,270度是6点钟方向)。所以,我们希望圆弧关于90度(即12点钟方向)对称。那么,起始角度应该是90-75=15度(逆时针15度,即右上),然后顺时针扫描150度到90+75=165度(左上)?这样也不对称。
实际上,我们可以将圆弧的起始点设为90+75=165度(左上),然后逆时针扫描150度到15度(右上)?这样圆弧就在上方,但方向是逆时针,从左上到右上,但这样圆弧是向上的,不符合我们的要求(我们希望是向下凸出的圆弧)。所以,我们想要一个向下凸出的圆弧,那么应该从左上(165度)顺时针扫描到右上(15度),这样就是300度(360-60)的圆弧?不对,165顺时针到15度:165到360是195度,再加上0到15是15度,总共210度?不对。
为了简化,我们采用以下方案:
我们以View的中心为圆心,绘制一个圆弧,起始角度为120度(相对于3点钟方向逆时针120度,即左上),然后扫描角度为300度(顺时针300度)?这样也不对。
实际上,我们常见的圆弧进度条是从-90度(顶部)开始,顺时针180度(到右侧底部)?但我们要150度,所以我们可以从-75度(相对于顶部顺时针75度,即左上)开始,然后顺时针扫描150度到75度(相对于顶部逆时针75度,即右上)?不对,因为-75度(顶部顺时针75度)实际上是15度(从0度顺时针75度是285度?)很乱。
我们换一种思路:将圆弧的对称轴设为垂直向上(y轴负方向),那么圆弧的起点和终点分别位于对称轴两侧各75度。所以,起点角度为180度+75度=255度(从0度逆时针255度),终点角度为180度-75度=105度(从0度逆时针105度)?但是这样起点和终点之间的夹角是255-105=150度(逆时针),所以我们可以画一个从255度逆时针到105度的圆弧(150度),但这样圆弧是逆时针画的,且位于下方?不对,255度在左下方,105度在右下方,逆时针从255到105需要经过0度,所以实际是150度(255->0是105度,0->105是105度,总共210度?不对)。
为了避免混淆,我们直接定义:
起始角度:从0度逆时针旋转的角度,作为起始角度。扫描角度:顺时针为正,逆时针为负?不,在drawArc中,扫描角度正数为逆时针,负数为顺时针。
我们想要一个向下凸出的圆弧,对称轴在y轴负方向(即顶部),那么起始角度应该是:对称轴角度(90度,即12点钟方向)减去75度=15度(逆时针15度,即右上),然后顺时针扫描150度(所以扫描角度为-150度)到165度(逆时针165度,即左上)?不对,15度顺时针150度后到达15+150=165度(顺时针165度,相当于逆时针195度?)位置不对。
实际上,如果我们从15度开始,顺时针扫描300度才能到达165度(因为15到165顺时针需要150度?15到165顺时针:15->0(15度),0->360->165(165度),总共180度?不对,15到165顺时针是150度?15->30->...->165,正好150度?是的。所以,如果我们从15度开始,顺时针扫描150度,到达165度,那么圆弧就是从右上到左上,但这样圆弧是向上的(凸向上方),而我们希望向下凸。
所以,我们应该从165度开始,顺时针扫描150度到15度?这样圆弧就是向下凸的。因为165度在左上,15度在右上,顺时针扫描150度(从左上到右上)经过的是下方。所以:
起始角度:165度(从0度逆时针165度)
扫描角度:150度(顺时针150度)?但drawArc的扫描角度参数如果是正数表示逆时针,负数表示顺时针。所以我们要用-150度表示顺时针扫描150度。
因此,在drawArc中,我们这样绘制:
drawArc(rectF, 165, -150, false, paint);
这样画出的圆弧就是从165度(左上)顺时针150度到15度(右上),对称轴在90度(顶部)位置,且向下凸出。
但是,注意:165度在左上,15度在右上,顺时针150度经过的区域是:从165度开始顺时针到165+150=315度(即右下)?不对,165度顺时针150度:165+150=315度(从0度逆时针315度,即右下),但我们要的是15度(右上)。所以不对。
重新理解:drawArc(rect, startAngle, sweepAngle, useCenter, paint)
startAngle:起始角度,0度指向3点钟方向,逆时针为正。
sweepAngle:扫描角度,正值表示逆时针扫描,负值表示顺时针扫描。
如果我们设置startAngle=165,sweepAngle=-150:
从165度开始,顺时针扫描150度,到达的角度是165-150=15度(因为顺时针是角度减小)。所以,从165度顺时针150度到达15度,这个圆弧就是向下凸出的圆弧,对称于y轴(90度位置)。所以正确。
因此,我们采用:
起始角度:165度
扫描角度:-150度(顺时针150度)
绘制圆弧时,我们需要一个矩形区域(RectF)来定义圆弧的边界。
6. 指示器的位置:根据当前进度计算圆弧上的点坐标。
角度 = 165 + 当前进度百分比 * (-150) (因为从165度顺时针150度,所以角度是递减的)
注意:当前进度百分比 = 当前进度值 / 最大值
角度值(弧度) = Math.toRadians(角度) 但注意,在计算坐标时,三角函数需要弧度。
圆心坐标:(centerX, centerY)
半径:圆弧的半径(RectF的宽度或高度的一半减去一些padding)
则指示器的位置:
x = centerX + radius * Math.cos(angleRad)
y = centerY + radius * Math.sin(angleRad)
但是注意:在数学坐标系中,0度是x轴正方向,y轴正方向是90度。而Android的Canvas坐标系是y轴向下为正,所以我们在计算时,角度需要调整:实际上,我们计算的角度是基于数学坐标系的,而Canvas的0度方向也是x轴正方向,但y轴正方向是向下,所以角度方向(逆时针)与数学坐标系一致。因此,我们可以直接使用。
7. 绘制指示器(倒三角):
我们将在圆弧上的点(x, y)绘制一个倒三角形。倒三角形可以由三个点构成:顶点(在圆弧上,指向圆心),另外两个点分别位于顶点的两侧(垂直于半径方向向外延伸一点)。
但是,我们想要一个倒三角,即尖角朝外。所以:
顶点:在圆弧上的点(x, y)
另外两个点:在顶点两侧,且与圆弧切线方向垂直(即沿着半径方向向内偏移一点,然后向两侧扩展)?不对,倒三角应该是底边在圆弧内,顶点在圆弧上。所以:
我们定义:
顶点:圆弧上的点(x, y)
底边的两个点:从顶点沿着半径方向向内(圆心方向)移动一定距离,然后向两侧(垂直于半径方向)扩展一定距离。
具体:
半径方向向量:从圆心指向顶点,向量为 (dx = x - centerX, dy = y - centerY)
单位化:len = Math.sqrt(dx*dx+dy*dy), ux = dx/len, uy = dy/len
底边的中点:从顶点向内移动一定距离(例如10dp)得到: (x1, y1) = (x - ux * indicatorHeight, y - uy * indicatorHeight)
然后,底边的两个点分别从中点向两侧垂直移动:垂直方向向量(与半径垂直)可以是 (uy, -ux) 和 (-uy, ux) 吗?注意,在二维中,与向量(ux, uy)垂直的向量有两个:(-uy, ux) 和 (uy, -ux)。我们取其中一个,然后乘以底边宽度的一半。
所以,底边左点:x2 = x1 + (-uy) * (indicatorWidth/2), y2 = y1 + ux * (indicatorWidth/2)
底边右点:x3 = x1 + uy * (indicatorWidth/2), y3 = y1 - ux * (indicatorWidth/2)
然后,连接顶点、左点、右点形成倒三角形。
8. 触摸事件处理:
当用户触摸时,我们需要判断触摸点是否在指示器附近(可以用一个圆形区域判断,或者用指示器的区域判断,这里我们用圆形区域简单)。
计算触摸点到圆弧上当前指示器位置的距离,如果小于一个阈值(比如指示器的大小),则认为触摸在指示器上。
然后,在拖动时,根据触摸点坐标计算角度,然后转换为进度。
如何根据触摸点计算角度?
圆心已知(centerX, centerY),触摸点(touchX, touchY)
计算向量:dx = touchX - centerX, dy = touchY - centerY
角度(弧度) = Math.atan2(dy, dx) 注意,atan2返回的是从x轴正方向逆时针旋转的角度,范围[-π, π]。
然后转换为角度值:angle = (float) Math.toDegrees(angleRad);
注意:由于角度可能是负值,我们可以转换为0~360度:if (angle < 0) angle += 360;
但是,我们的圆弧范围是从165度顺时针到15度(即角度从165度递减到15度,在0~360度中,15度小于165度,所以当我们得到角度值后,需要判断这个角度是否在圆弧范围内(15度到165度之间)?不对,因为我们是从165度顺时针到15度,所以角度值从165度减小到15度。在0~360度中,15度在165度的顺时针方向(因为15<165,所以顺时针方向角度减小)。所以,我们得到的角度值(0~360)中,在圆弧范围内的角度应该是从165度到360度,然后0度到15度?不对,165顺时针到15度,中间经过0度,所以角度值从165到360(195度)和0到15(15度),总共210度?不对,圆弧只有150度。所以我们需要将角度映射到进度。
我们定义圆弧的起始角度165度,结束角度15度(顺时针),但15度比165度小,所以我们可以这样处理:
将角度值转换为相对于起始角度的顺时针偏移量:
如果当前角度 <= 165度且当前角度>=15度,那么偏移角度 = 165 - 当前角度 (顺时针偏移量)
如果当前角度 > 165度(比如180度),那么顺时针偏移量 = 165 + (360 - 当前角度) 这样不对,因为165顺时针到180只有15度,但180度在圆弧外(因为圆弧只到15度,顺时针150度后是165-150=15度,所以圆弧范围是165度顺时针150度,即165~15度(经过0度))。所以,圆弧上的角度应该是:165~360(195度)和0~15(15度)?总共210度?不对,150度圆弧。
实际上,圆弧覆盖的角度范围是:从165度顺时针到15度,相当于角度值从165度开始,顺时针减少150度,所以角度值范围是[165, 15](但15小于165,所以用区间表示就是[165, 360]和[0,15])。所以,当触摸点角度在[15,165]区间内时,实际上不在圆弧上(因为圆弧在另外两个区间)。因此,我们需要判断:
如果当前角度在[15,165]区间内,则忽略(不在圆弧上)。
否则,如果当前角度在[165,360]区间,那么偏移量 = 当前角度 - 165 然后取反?不对,偏移量应该是从165度顺时针旋转到当前角度的度数,即165-当前角度(因为顺时针角度减小)?不对,比如当前角度是180,那么165顺时针到180是15度,所以偏移量15度(占150度的10%)。
如果当前角度在[0,15]区间,那么偏移量 = (360 - 165) + 当前角度 即195+当前角度?这样不对,因为165顺时针到0度是195度(165到360是195度),然后0度到15度是15度,总共210度?不对,应该是165顺时针到当前角度:165顺时针到0度是195度,然后0度到15度是15度,所以165到15度总共210度?但实际只有150度圆弧。所以我们的映射不对。
重新映射:我们定义圆弧的起点为165度,终点为15度(顺时针150度)。所以,我们将圆弧上的角度映射到[0,150]的进度:
设当前角度为angle(0~360)
如果angle在[165,360]区间,则进度 = (angle - 165) * (150/150) = angle-165 但这样不对,因为165到360有195度,而实际圆弧只有150度。所以我们需要将角度限制在圆弧内。
实际上,我们只需要计算从165度顺时针到当前角度的角度差,但不超过150度。所以:
如果angle >=165,那么偏移量 = angle - 165 但最大为150度(因为圆弧只有150度)?不对,比如angle=360,偏移量=360-165=195>150,所以我们要取最小值150?不对,360度在圆弧上吗?在圆弧的终点(15度)之后?不对,圆弧终点是15度,所以当angle>165+150=315度?不对,165+150=315度(顺时针150度)就是15度(因为165+150=315,而315度相当于-45度,即315度)?不对,165顺时针150度:165-150=15度(角度值),所以角度值15度对应圆弧终点。
所以,圆弧上的角度范围是:从165度(起点)顺时针到15度(终点),中间经过的角度值从165递减到15(但经过0度时重置为360)。所以,实际角度值范围是[15,165]的补集?不对,是[165,360]和[0,15]两个区间。
计算偏移量(圆弧上的位置):
如果angle在[0,15]区间,那么偏移量 = (165 - (angle + 360)) 不对,这样是负的。
我们这样计算:从165度顺时针到angle的角度 = (165 - angle) 但是,如果angle在[0,15]区间,那么165-angle是大于150的(比如angle=0,165-0=165>150),所以不对。
换一种:将角度值归一化到165度附近:
我们计算从165度顺时针到angle的角度差:
如果angle <= 165,那么角度差 = 165 - angle (因为165顺时针到0度是165度,0度到angle是angle度,总共165+angle度?不对)
实际上,我们考虑圆弧的起点是165度,终点是15度(相当于375度),所以:
总圆弧跨度:150度
当前角度相对于起点的位置:将角度加上360度(如果小于165度)再与起点相减。
具体:
如果angle < 165,则angle2 = angle + 360
否则,angle2 = angle
然后,偏移量 = angle2 - 165 (顺时针偏移量)
但是,这个偏移量可能大于150度(比如angle=0,则angle2=360,偏移量=360-165=195>150),所以我们需要限制偏移量在0到150之间。
所以,我们这样计算:
float offset;
if (angle < 165) {
offset = 165 - angle; // 因为165顺时针到360是195度,然后0度到angle是angle度,所以总共195+angle度?不对,我们想要的是从165顺时针到angle的角度,当angle<165时,实际上已经超过了终点(15度),所以应该算作150度(最大值)?
} else {
offset = angle - 165; // 这个offset是逆时针的偏移量,但我们需要顺时针偏移量?
}
这个逻辑不对。
为了简化,我们直接计算触摸点相对于圆心的角度,然后判断该角度是否在圆弧的范围内(即从165度顺时针150度到15度),然后计算该角度在圆弧上的比例。
更好的方法:将角度转换为圆弧上的比例位置(0~1):
首先,将角度归一化到[0,360):
angle = angle % 360;
if (angle < 0) angle += 360;
然后,计算圆弧的起点角度(165度)和终点角度(15度,但15<165,所以终点角度可以表示为15+360=375度)。
那么,圆弧上的点对应的角度范围是:165度 ~ 375度(即165~360,0~15映射到360~375)?不对,375度就是15度。
所以,当前角度对应的归一化角度:如果当前角度<165,则归一化角度=angle+360,否则归一化角度=angle。
然后,当前角度在圆弧上的位置 = (归一化角度 - 165) / 150 (150是总扫描角度)
但是,归一化角度-165可能大于150(比如angle=0,归一化角度=360,则360-165=195>150),所以我们需要限制:
position = (normalizedAngle - 165) / 150;
if (position > 1) position = 1;
if (position < 0) position = 0; // 理论上不会小于0
但是,当触摸点在圆弧外时,我们不应该更新进度。所以,我们只处理归一化角度在165到375之间(即归一化角度在[165, 375])的触摸点,且归一化角度-165不能超过150(即最大为375-165=210,但我们限制为150),所以当超过150时,我们取1(即100%)。
所以,进度百分比 = (normalizedAngle - 165) / 150 然后限制在0~1之间。
但是,这样计算,当归一化角度=165时,进度0;归一化角度=375(即15度)时,进度=(375-165)/150=210/150=1.4>1,所以我们需要限制:
position = Math.min(1, (normalizedAngle - 165) / 150);
这样,当触摸点角度在圆弧终点(15度)之后,进度都取1。
9. 更新进度:根据计算出的position(0~1)更新当前进度,并刷新View。
10. 属性动画:如果支持,可以添加属性动画,当进度改变时平滑移动指示器。
由于代码较长,我们分步骤实现。
下面开始编写代码:
定义属性:
private int mArcWidth = 20; // 圆弧宽度,单位dp
private int mProgressColor = Color.BLUE;
private int mArcColor = Color.GRAY;
private int mIndicatorColor = Color.RED;
private int mIndicatorHeight = 10; // dp
private int mIndicatorWidth = 20; // dp
private float mMaxProgress = 100;
private float mCurrentProgress = 0;
在构造方法中获取自定义属性(如果有)。
在onSizeChanged中计算圆弧的矩形区域:
int padding = getPaddingLeft();
int width = getWidth() - padding * 2;
int height = getHeight() - padding * 2;
// 取宽高中的最小值作为直径
int min = Math.min(width, height);
// 计算圆弧的矩形区域
mRectF = new RectF(padding, padding, padding + min, padding + min);
// 圆心
mCenterX = padding + min / 2;
mCenterY = padding + min / 2;
mRadius = min / 2 - mArcWidthPx / 2; // 减去圆弧宽度的一半,让圆弧在矩形内
在onDraw中绘制:
// 绘制背景圆弧
paint.setColor(mArcColor);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(mArcWidthPx);
canvas.drawArc(mRectF, 165, -150, false, paint);
// 绘制进度圆弧
paint.setColor(mProgressColor);
float sweep = -150 * (mCurrentProgress / mMaxProgress); // 顺时针扫描的角度(负值)
canvas.drawArc(mRectF, 165, sweep, false, paint);
// 绘制指示器
// 计算指示器位置:角度 = 165 + sweep (因为sweep是负值,所以165+sweep就是当前进度对应的角度)
float angle = 165 + sweep; // 单位:度
// 转换为弧度
double angleRad = Math.toRadians(angle);
// 计算圆弧上的点
float x = (float) (mCenterX + mRadius * Math.cos(angleRad));
float y = (float) (mCenterY + mRadius * Math.sin(angleRad));
// 绘制倒三角形
drawIndicator(canvas, x, y, angleRad);
绘制指示器的方法:
private void drawIndicator(Canvas canvas, float x, float y, double angleRad) {
// 计算半径方向向量(从圆心指向圆弧上的点)
float dx = x - mCenterX;
float dy = y - mCenterY;
float length = (float) Math.sqrt(dx * dx + dy * dy);
// 单位向量
float ux = dx / length;
float uy = dy / length;
// 底边的中点:从顶点向圆心方向移动indicatorHeight
float midX = x - ux * mIndicatorHeightPx;
float midY = y - uy * mIndicatorHeightPx;
// 计算垂直向量(垂直于半径方向):取(uy, -ux)和(-uy, ux)都可以,这里取一个,比如(-uy, ux)为垂直向量的一个方向
float perpX = -uy;
float perpY = ux;
// 底边的两个点
float leftX = midX + perpX * (mIndicatorWidthPx / 2);
float leftY = midY + perpY * (mIndicatorWidthPx / 2);
float rightX = midX - perpX * (mIndicatorWidthPx / 2);
float rightY = midY - perpY * (mIndicatorWidthPx / 2);
// 绘制三角形
Path path = new Path();
path.moveTo(x, y); // 顶点
path.lineTo(leftX, leftY);
path.lineTo(rightX, rightY);
path.close();
Paint indicatorPaint = new Paint();
indicatorPaint.setColor(mIndicatorColor);
indicatorPaint.setStyle(Paint.Style.FILL);
canvas.drawPath(path, indicatorPaint);
}
注意:上面的垂直向量计算,我们取(-uy, ux)作为垂直向量,然后乘以宽度的一半得到两个底边点。但是,这样绘制的三角形方向是否正确?我们可以通过调试或调整方向。
触摸事件处理:
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 检查是否触摸在指示器上
if (isTouchOnIndicator(x, y)) {
mIsDragging = true;
return true;
}
break;
case MotionEvent.ACTION_MOVE:
if (mIsDragging) {
updateProgress(x, y);
return true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsDragging = false;
invalidate();
break;
}
return super.onTouchEvent(event);
}
private boolean isTouchOnIndicator(float touchX, float touchY) {
// 计算当前指示器的位置
float sweep = -150 * (mCurrentProgress / mMaxProgress);
float angle = 165 + sweep;
double angleRad = Math.toRadians(angle);
float x = (float) (mCenterX + mRadius * Math.cos(angleRad));
float y = (float) (mCenterY + mRadius * Math.sin(angleRad));
// 计算触摸点与指示器顶点的距离
float distance = (float) Math.sqrt((x - touchX) * (x - touchX) + (y - touchY) * (y - touchY));
return distance < mIndicatorTouchRadiusPx; // 触摸半径,比如20dp
}
private void updateProgress(float touchX, float touchY) {
// 计算触摸点相对于圆心的角度
float dx = touchX - mCenterX;
float dy = touchY - mCenterY;
double angleRad = Math.atan2(dy, dx);
float angle = (float) Math.toDegrees(angleRad);
if (angle < 0) {
angle += 360;
}
// 归一化角度:如果angle<165,则+360
float normalizedAngle = angle;
if (angle < 165) {
normalizedAngle += 360;
}
// 计算进度百分比
float position = (normalizedAngle - 165) / 150;
if (position < 0) {
position = 0;
} else if (position > 1) {
position = 1;
}
mCurrentProgress = position * mMaxProgress;
invalidate();
}
注意:在updateProgress中,我们直接更新当前进度,并重绘。
11. 单位转换:在构造方法中将dp转换为px。
以上是核心代码,下面给出完整自定义View的代码框架。
注意:由于代码较长,可能需要根据实际情况调整。
完整代码示例:</think>下面我将详细指导您在Android中实现自定义可拖动进度圆弧控件(扫描角度150度,带倒三角指示器)。以下是完整的实现方案:
### 实现思路
1. 创建自定义`View`继承自`View`
2. 绘制静态圆弧(150度扫描角度)
3. 添加可拖动的倒三角指示器
4. 实现触摸事件处理
5. 添加进度变化回调
### 完整实现代码(Java)
```java
import android.content.Context;
import android.graphics.*;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;
public class ArcProgressView extends View {
// 尺寸和颜色配置
private static final int DEFAULT_ARC_WIDTH_DP = 15;
private static final int DEFAULT_INDICATOR_RADIUS_DP = 15;
private static final int DEFAULT_START_ANGLE = 165; // 起始角度(0点在右侧)
private static final int SWEEP_ANGLE = 150; // 扫描角度
private Paint arcPaint;
private Paint indicatorPaint;
private RectF arcRect = new RectF();
private Path indicatorPath = new Path();
private float currentProgress = 0; // 0~100
private float arcWidthPx;
private float indicatorRadiusPx;
private int centerX, centerY;
private float radius;
private OnProgressChangeListener listener;
public ArcProgressView(Context context) {
super(context);
init();
}
public ArcProgressView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
// 转换dp为px
arcWidthPx = dpToPx(DEFAULT_ARC_WIDTH_DP);
indicatorRadiusPx = dpToPx(DEFAULT_INDICATOR_RADIUS_DP);
// 圆弧画笔配置
arcPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
arcPaint.setStyle(Paint.Style.STROKE);
arcPaint.setStrokeCap(Paint.Cap.ROUND);
arcPaint.setStrokeWidth(arcWidthPx);
arcPaint.setColor(Color.parseColor("#3F51B5")); // 进度色
// 指示器画笔配置
indicatorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
indicatorPaint.setStyle(Paint.Style.FILL);
indicatorPaint.setColor(Color.parseColor("#FF4081")); // 指示器色
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
centerX = w / 2;
centerY = h / 2;
// 计算圆弧绘制区域(考虑padding)
int size = Math.min(w, h) - getPaddingLeft() - getPaddingRight();
radius = size / 2f - arcWidthPx;
arcRect.set(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius
);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 1. 绘制背景圆弧
arcPaint.setColor(Color.LTGRAY);
canvas.drawArc(arcRect, DEFAULT_START_ANGLE, SWEEP_ANGLE, false, arcPaint);
// 2. 绘制进度圆弧
arcPaint.setColor(Color.parseColor("#3F51B5"));
float sweep = SWEEP_ANGLE * (currentProgress / 100f);
canvas.drawArc(arcRect, DEFAULT_START_ANGLE, sweep, false, arcPaint);
// 3. 绘制倒三角指示器
drawIndicator(canvas, sweep);
}
private void drawIndicator(Canvas canvas, float sweepAngle) {
// 计算指示器位置(圆弧上的点)
float angle = DEFAULT_START_ANGLE + sweepAngle;
double angleRad = Math.toRadians(angle);
float x = (float) (centerX + radius * Math.cos(angleRad));
float y = (float) (centerY + radius * Math.sin(angleRad));
// 创建倒三角形路径
indicatorPath.reset();
float triangleSize = indicatorRadiusPx * 1.5f;
// 计算方向向量(指向圆心)
float dx = centerX - x;
float dy = centerY - y;
float length = (float) Math.sqrt(dx * dx + dy * dy);
dx /= length;
dy /= length;
// 计算垂直向量(用于三角形底边)
float perpX = -dy;
float perpY = dx;
// 构建三角形路径
indicatorPath.moveTo(x, y); // 顶点
indicatorPath.lineTo(
x - perpX * triangleSize/2 + dx * triangleSize,
y - perpY * triangleSize/2 + dy * triangleSize
);
indicatorPath.lineTo(
x + perpX * triangleSize/2 + dx * triangleSize,
y + perpY * triangleSize/2 + dy * triangleSize
);
indicatorPath.close();
canvas.drawPath(indicatorPath, indicatorPaint);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!isEnabled()) return false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
updateProgress(event.getX(), event.getY());
return true;
}
return true;
}
private void updateProgress(float touchX, float touchY) {
// 计算触摸点相对于圆心的角度
float dx = touchX - centerX;
float dy = touchY - centerY;
double touchAngle = Math.toDegrees(Math.atan2(dy, dx));
// 转换为0-360度
if (touchAngle < 0) touchAngle += 360;
// 计算相对于起始角度的进度
float relativeAngle = (float) touchAngle - DEFAULT_START_ANGLE;
if (relativeAngle < 0) relativeAngle += 360;
// 限制在扫描角度范围内
if (relativeAngle > SWEEP_ANGLE) {
relativeAngle = (relativeAngle > 180) ? SWEEP_ANGLE : 0;
}
// 更新进度
currentProgress = (relativeAngle / SWEEP_ANGLE) * 100;
// 刷新视图并回调
invalidate();
if (listener != null) {
listener.onProgressChanged(currentProgress);
}
}
public void setProgress(float progress) {
currentProgress = Math.max(0, Math.min(progress, 100));
invalidate();
}
public void setOnProgressChangeListener(OnProgressChangeListener listener) {
this.listener = listener;
}
public interface OnProgressChangeListener {
void onProgressChanged(float progress);
}
private float dpToPx(float dp) {
return dp * getResources().getDisplayMetrics().density;
}
}
```
### 使用示例(在Activity中)
```java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ArcProgressView progressView = findViewById(R.id.arc_progress);
progressView.setOnProgressChangeListener(progress -> {
Log.d("Progress", "当前进度: " + progress + "%");
});
}
}
```
```xml
<!-- activity_main.xml -->
<com.example.ArcProgressView
android:id="@+id/arc_progress"
android:layout_width="300dp"
android:layout_height="300dp"
android:padding="20dp"/>
```
### 关键实现细节
1. **150度圆弧实现**:
- 使用`drawArc()`方法,起始角度165°,扫描角度150°
- 公式:$ \text{角度} = 165^\circ + \frac{\text{进度}}{100} \times 150^\circ $
2. **倒三角指示器**:
- 通过路径(Path)绘制三角形
- 方向计算:使用向量计算指向圆心的方向
$$ \begin{cases}
\Delta x = \text{centerX} - x \\
\Delta y = \text{centerY} - y
\end{cases} $$
- 垂直向量用于构建三角形底边:
$$ \text{垂直向量} = (-\Delta y, \Delta x) $$
3. **触摸事件处理**:
- 使用反正切函数计算触摸点角度:
$$ \theta = \tan^{-1}\left(\frac{\Delta y}{\Delta x}\right) $$
- 角度范围映射到0-150度(扫描角度)
4. **性能优化**:
- 使用`Paint.ANTI_ALIAS_FLAG`抗锯齿
- 在`onSizeChanged()`中预计算尺寸
- 避免在`onDraw()`中创建新对象
### 相关问题
1. **如何添加渐变颜色效果到圆弧?**
使用`SweepGradient`着色器:
```java
Shader shader = new SweepGradient(centerX, centerY,
new int[]{Color.RED, Color.YELLOW, Color.GREEN}, null);
arcPaint.setShader(shader);
```
2. **如何实现平滑的拖动动画?**
使用`ValueAnimator`平滑过渡:
```java
ValueAnimator animator = ValueAnimator.ofFloat(oldProgress, newProgress);
animator.addUpdateListener(animation -> {
setProgress((float) animation.getAnimatedValue());
});
animator.start();
```
3. **如何保存/恢复控件状态?**
重写`onSaveInstanceState()`和`onRestoreInstanceState()`:
```java
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putFloat("progress", currentProgress);
bundle.putParcelable("super", super.onSaveInstanceState());
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle bundle = (Bundle) state;
currentProgress = bundle.getFloat("progress");
super.onRestoreInstanceState(bundle.getParcelable("super"));
}
}
```
4. **如何添加刻度标记?**
在`onDraw()`中添加:
```java
for (int i = 0; i <= 10; i++) {
float angle = DEFAULT_START_ANGLE + (SWEEP_ANGLE / 10f) * i;
// 计算刻度起止点
canvas.drawLine(/* 坐标计算 */);
}
```
5. **如何适配深色模式?**
使用资源文件定义颜色:
```xml
<!-- res/values/colors.xml -->
<color name="arc_color">#3F51B5</color>
<!-- res/values-night/colors.xml -->
<color name="arc_color">#BB86FC</color>
```
在代码中获取:
```java
arcPaint.setColor(ContextCompat.getColor(context, R.color.arc_color));
```
这个实现完全符合您的需求,包含150度扫描圆弧、可拖动的倒三角指示器,并提供了进度回调接口。您可以根据需要调整颜色、尺寸等参数[^1]。
[^1]: 参考Android绘图基础:Canvas和Paint的使用,以及自定义View触摸事件处理。