恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Android自定义View实战:长按进度按钮的完整实现与优化
首页
资讯中心
/
Android自定义View实战:长按进度按钮的完整实现与优化
Android自定义View实战:长按进度按钮的完整实现与优化
发布时间:2026/10/16 6:07:36
1. 这个长按进度条按钮到底解决了什么问题做过运动类、打卡类或者任何需要“防误触确认”功能的朋友大概率都遇到过这种交互需求用户按住一个按钮不放按钮周围或者内部逐渐出现一圈进度条按满一定时间后才真正触发操作中途松手则进度归零、操作取消。Keep 里那个长按结束训练的按钮就是典型代表——它不是普通的点击确认弹窗而是把“确认”这个动作本身做成了一个有反馈、有节奏、有仪式感的交互过程。我最早接到这个需求的时候第一反应是找个现成的库。翻了一圈发现市面上大部分所谓的长按进度按钮要么是用属性动画硬怼出来的要么是套了好几层 ViewGroup 加 Handler 轮询代码耦合严重复用性极差。更麻烦的是这类实现往往在快速点击、滑动取消、多指同时按下这些边界场景下表现得很不稳定。后来我决定自己用自定义 View 从头撸一个核心思路就一句话把进度这个状态量交给绘制层去消费把时间这个驱动量交给一个可控的计时器去管理两者通过重绘机制解耦。这篇文章适合谁看如果你已经写过至少一个自定义 View能看懂onMeasure、onDraw、onTouchEvent这三个方法的基本职责那接下来的内容你可以直接抄作业。如果你还没写过自定义 View也没关系我会把每个关键决策背后的原因讲清楚你照着敲一遍也能跑起来。最终我们要实现的效果是一个圆形按钮长按时外圈出现进度弧线进度从 0 到 1 线性增长松手后进度平滑回退到 0进度满时触发回调同时支持自定义进度时长、颜色、线宽、半径等参数。2. 整体方案设计与核心思路拆解2.1 为什么不用属性动画加 ValueAnimator 直接驱动很多人第一反应是用ValueAnimator从 0 到 1 做插值然后在onAnimationUpdate里拿到当前值去invalidate。这个方案能跑但有几个坑。第一ValueAnimator的默认插值器是加速减速的长按进度条需要的是线性增长你得手动设置LinearInterpolator这倒不难。第二当你需要中途取消时ValueAnimator的cancel会直接跳到结束值而不是从当前位置平滑回退你得再起一个反向动画两个动画之间的衔接容易出问题。第三如果用户快速连续按下松开多个动画实例可能同时存在状态管理会变得很混乱。我最终选择的方案是用一个Runnable配合postDelayed或者Choreographer来驱动进度更新。具体来说在onTouchEvent的ACTION_DOWN里记录起始时间然后通过postOnAnimation或者postDelayed每隔一帧去计算已经过去的时间占总时长的比例把这个比例赋值给进度变量再调用invalidate。松手时把进度变量归零同样通过重绘来表现回退。这个方案的好处是进度的唯一真实来源就是“当前时间减去按下时间”不存在动画状态机也就不存在多实例冲突的问题。2.2 进度回退为什么要用平滑过渡而不是直接归零如果你直接松手就把进度设为 0 然后重绘视觉上会非常突兀像是按钮“闪”了一下。Keep 那个按钮松手后进度是有一个明显的回退动画的大概两三百毫秒内从当前位置缩回起点。实现这个回退有两种思路一种是在松手时记录当前进度然后启动一个短时的反向计时器每帧递减另一种是引入一个“目标进度”和“当前进度”两个变量每帧让当前进度向目标进度逼近逼近的速度用阻尼系数控制。我采用的是第二种因为它更通用。按下时目标进度是 1松手时目标进度是 0当前进度每帧向目标进度移动一定比例。这样无论是按下、松手还是进度满触发后的复位都走同一套逻辑代码非常干净。阻尼系数我一般取 0.15 到 0.25 之间太小了回退太慢显得拖沓太大了回退太快显得生硬。实测 0.2 左右的手感最接近 Keep 原版。2.3 绘制层为什么用 Canvas.drawArc 而不是自定义 Path进度弧线本质上就是一段圆弧Canvas.drawArc原生支持指定起始角度和扫过角度而且可以设置Paint的StrokeCap为ROUND来让弧线两端变圆。用Path加arcTo当然也能画但没必要drawArc更直接性能也更好。唯一需要注意的是drawArc的矩形区域参数它要求传入一个RectF这个RectF决定了圆弧的外接矩形。如果你想让弧线紧贴按钮边缘这个RectF的边界就要根据按钮半径和弧线线宽来仔细计算否则弧线会被裁切或者离得太远。我踩过的一个坑是一开始把RectF直接设成了 View 的整个宽高结果弧线画在了 View 的最外圈和按钮之间隔了一大段空白。正确的做法是RectF的 left 和 top 应该是paddingLeft strokeWidth / 2right 和 bottom 是width - paddingRight - strokeWidth / 2。这样弧线的中心线正好落在按钮的边界上视觉上最舒服。3. 核心细节解析与实操要点3.1 自定义属性的定义与读取为了让这个按钮能复用到不同场景我把关键参数都抽成了自定义属性。在res/values/attrs.xml里定义如下declare-styleable nameLongPressProgressButton attr namelppb_duration formatinteger / attr namelppb_progressColor formatcolor / attr namelppb_progressWidth formatdimension / attr namelppb_radius formatdimension / attr namelppb_startAngle formatfloat / attr namelppb_sweepAngle formatfloat / attr namelppb_buttonColor formatcolor / attr namelppb_text formatstring / attr namelppb_textSize formatdimension / attr namelppb_textColor formatcolor / /declare-styleable这里重点说几个参数的选择逻辑。lppb_duration是长按总时长单位毫秒Keep 原版大概是 1500 毫秒左右我默认设成 2000给用户留足反应时间。lppb_startAngle是弧线起始角度Android 的drawArc里 0 度是三点钟方向顺时针为正。Keep 那个按钮的进度是从正上方开始的所以起始角度应该是 -90 度。lppb_sweepAngle是弧线总扫过角度默认 360 度画一整圈如果你只想画半圈或者四分之三圈改这个值就行。在构造函数里读取这些属性的时候注意TypedArray用完要recycle这是基本素养。另外尺寸类的属性建议用getDimensionPixelSize而不是getDimension前者直接返回像素整数值省去手动取整的麻烦。3.2 onMeasure 里怎么处理 wrap_content自定义 View 如果不在onMeasure里处理wrap_content那在布局里写wrap_content就会变成和match_parent一样的效果这显然不是我们想要的。对于这个按钮合理的默认尺寸应该是“半径乘以二加上左右内边距”。我的处理逻辑是Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode MeasureSpec.getMode(widthMeasureSpec); int heightMode MeasureSpec.getMode(heightMeasureSpec); int widthSize MeasureSpec.getSize(widthMeasureSpec); int heightSize MeasureSpec.getSize(heightMeasureSpec); int defaultSize (int) (radius * 2 progressWidth * 2 getPaddingLeft() getPaddingRight()); int width; if (widthMode MeasureSpec.EXACTLY) { width widthSize; } else if (widthMode MeasureSpec.AT_MOST) { width Math.min(defaultSize, widthSize); } else { width defaultSize; } int height; if (heightMode MeasureSpec.EXACTLY) { height heightSize; } else if (heightMode MeasureSpec.AT_MOST) { height Math.min(defaultSize, heightSize); } else { height defaultSize; } setMeasuredDimension(width, height); }这里有个细节defaultSize的计算里我把progressWidth乘了 2因为弧线是画在按钮边界上的线宽的一半会超出按钮范围如果不预留这个空间弧线就会被 View 的边界裁掉。这个坑我在第一次实现的时候就踩了弧线两端总是缺一块排查了半天才发现是测量尺寸没算对。3.3 触摸事件里的状态机设计onTouchEvent是这个组件的核心它需要处理按下、移动、抬起、取消四种事件并且要维护一个清晰的状态机。我的状态定义很简单IDLE空闲、PRESSING按下中、RELEASING回退中。按下时从IDLE进入PRESSING抬起或取消时从PRESSING进入RELEASING回退完成后回到IDLE。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: if (state STATE_IDLE) { state STATE_PRESSING; pressStartTime System.currentTimeMillis(); targetProgress 1f; startProgressLoop(); if (listener ! null) { listener.onPressStart(); } } return true; case MotionEvent.ACTION_MOVE: // 判断手指是否滑出按钮范围 if (state STATE_PRESSING !isInsideView(event.getX(), event.getY())) { state STATE_RELEASING; targetProgress 0f; if (listener ! null) { listener.onPressCancel(); } } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (state STATE_PRESSING) { state STATE_RELEASING; targetProgress 0f; if (listener ! null) { listener.onPressCancel(); } } return true; } return super.onTouchEvent(event); }注意ACTION_MOVE里我加了滑出判断这是很多简易实现会忽略的。用户长按过程中如果手指滑出按钮区域应该视为取消操作否则会出现“手指已经移开了但进度还在走”的诡异情况。isInsideView的判断要考虑 View 的 padding不能简单用 0 到 width 来判断。3.4 进度循环的驱动方式选择前面提到我用postOnAnimation来驱动进度更新具体实现是这样的private void startProgressLoop() { if (progressLoopRunning) return; progressLoopRunning true; postOnAnimation(progressRunnable); } private final Runnable progressRunnable new Runnable() { Override public void run() { if (state STATE_PRESSING) { long elapsed System.currentTimeMillis() - pressStartTime; float rawProgress Math.min(1f, elapsed / (float) duration); currentProgress rawProgress; if (rawProgress 1f) { state STATE_IDLE; progressLoopRunning false; if (listener ! null) { listener.onProgressComplete(); } invalidate(); return; } } else if (state STATE_RELEASING) { currentProgress (targetProgress - currentProgress) * damping; if (Math.abs(currentProgress - targetProgress) 0.001f) { currentProgress targetProgress; state STATE_IDLE; progressLoopRunning false; invalidate(); return; } } invalidate(); postOnAnimation(this); } };这里用postOnAnimation而不是postDelayed是因为前者会跟随系统的帧率回调在 60Hz 屏幕上大约 16ms 一次在 120Hz 屏幕上大约 8ms 一次进度更新更平滑。postDelayed如果固定写 16ms在高刷屏上反而会显得卡顿。另外注意进度满的时候我把状态直接设回了IDLE并且停止了循环避免不必要的重绘消耗。4. 完整实操过程与核心环节实现4.1 初始化画笔与矩形区域在构造函数里完成属性读取后需要初始化两支画笔一支画进度弧线一支画按钮背景和文字。画笔的初始化有几个关键点private void initPaints() { progressPaint new Paint(Paint.ANTI_ALIAS_FLAG); progressPaint.setStyle(Paint.Style.STROKE); progressPaint.setStrokeWidth(progressWidth); progressPaint.setColor(progressColor); progressPaint.setStrokeCap(Paint.Cap.ROUND); backgroundPaint new Paint(Paint.ANTI_ALIAS_FLAG); backgroundPaint.setStyle(Paint.Style.FILL); backgroundPaint.setColor(buttonColor); textPaint new Paint(Paint.ANTI_ALIAS_FLAG); textPaint.setColor(textColor); textPaint.setTextSize(textSize); textPaint.setTextAlign(Paint.Align.CENTER); }Paint.ANTI_ALIAS_FLAG一定要加否则弧线边缘会有锯齿在高分屏上尤其明显。StrokeCap.ROUND让弧线两端变成圆头视觉上更柔和这也是 Keep 原版的做法。文字画笔的setTextAlign(CENTER)让文字水平居中垂直居中需要在onDraw里用FontMetrics计算基线偏移。矩形区域的计算放在onSizeChanged里因为只有在尺寸确定后才能算出准确的RectFOverride protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); float halfStroke progressWidth / 2f; arcRect.set( getPaddingLeft() halfStroke, getPaddingTop() halfStroke, w - getPaddingRight() - halfStroke, h - getPaddingBottom() - halfStroke ); centerX w / 2f; centerY h / 2f; buttonRadius Math.min(w - getPaddingLeft() - getPaddingRight(), h - getPaddingTop() - getPaddingBottom()) / 2f - progressWidth; }这里buttonRadius减去了progressWidth是为了让按钮背景圆和进度弧线之间留出一点间隙不然弧线会紧贴着按钮边缘看起来像是按钮的一部分而不是独立的进度指示。4.2 onDraw 里的绘制顺序与细节onDraw的绘制顺序很重要先画按钮背景圆再画进度弧线最后画文字。如果顺序反了弧线会被背景圆盖住。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 1. 画按钮背景圆 canvas.drawCircle(centerX, centerY, buttonRadius, backgroundPaint); // 2. 画进度弧线 if (currentProgress 0f) { float sweep sweepAngle * currentProgress; canvas.drawArc(arcRect, startAngle, sweep, false, progressPaint); } // 3. 画文字 if (text ! null !text.isEmpty()) { Paint.FontMetrics fontMetrics textPaint.getFontMetrics(); float textHeight fontMetrics.descent - fontMetrics.ascent; float baseline centerY textHeight / 2f - fontMetrics.descent; canvas.drawText(text, centerX, baseline, textPaint); } }文字垂直居中的计算是经典问题fontMetrics.descent - fontMetrics.ascent是文字的总高度centerY textHeight / 2f - fontMetrics.descent就是让文字视觉中心对齐centerY的基线位置。这个公式我用了好几年基本没出过错。4.3 进度回退的阻尼系数调优前面代码里用到的damping系数我一开始设的是 0.1结果松手后进度回退太慢大概要半秒多才归零感觉像是按钮“粘”住了。后来改成 0.3又太快几乎是一瞬间就弹回去了失去了回退的视觉反馈意义。经过反复测试0.2 是一个比较平衡的值回退过程大约持续 200 到 300 毫秒肉眼能清晰看到弧线收缩的过程但又不会觉得拖沓。如果你想让回退更有“弹性”可以在阻尼逼近的基础上加一点过冲比如当currentProgress接近 0 的时候让它稍微变成负数再弹回来。不过这个效果比较花哨Keep 原版没有我建议保持简洁。4.4 进度满时的回调与复位进度满的时候我在progressRunnable里直接触发了onProgressComplete回调然后把状态设回IDLE进度保持在 1 不动。但实际使用中用户触发操作后按钮通常需要复位到初始状态。我的做法是在回调里让调用方决定何时复位提供一个reset()方法public void reset() { state STATE_IDLE; currentProgress 0f; targetProgress 0f; progressLoopRunning false; removeCallbacks(progressRunnable); invalidate(); }调用方在完成业务逻辑后调用reset()按钮就回到初始状态。这样设计的好处是进度满到复位之间的时间间隔由业务方控制比如可以等网络请求返回后再复位用户体验更连贯。5. 常见问题与排查技巧实录5.1 进度弧线显示不全或被裁切这是最常见的问题表现是弧线两端或者外侧被切掉一块。原因通常有三个一是onMeasure里没有预留progressWidth的空间View 的实际尺寸不够画完整弧线二是arcRect的计算没有考虑padding导致弧线画到了 padding 区域外面三是父布局设置了clipChildren为 true把超出 View 边界的部分裁掉了。排查顺序建议从onMeasure开始打印出测量后的宽高和arcRect的边界对比一下看看弧线的外接矩形是否完全落在 View 的宽高范围内。如果onMeasure没问题再检查父布局的clipChildren属性。我遇到过一次onMeasure和arcRect都正确但弧线还是被裁最后发现是外层LinearLayout设置了android:clipChildrentrue改成 false 就好了。5.2 快速点击时进度条闪烁快速连续点击按钮时进度条可能会出现闪烁或者残影。这个问题的根源是ACTION_DOWN和ACTION_UP之间的时间间隔太短progressRunnable可能还没来得及执行第一帧状态就已经从PRESSING变成了RELEASING然后currentProgress在 0 附近来回跳变。解决方法是在ACTION_DOWN里加一个最小按下时间的判断比如按下后至少 100 毫秒内不响应抬起事件。或者更简单粗暴一点在ACTION_UP里判断如果currentProgress小于某个阈值比如 0.05就直接归零并停止循环不做回退动画。我采用的是后者效果很好快速点击时按钮几乎没有任何视觉反馈符合“防误触”的设计初衷。5.3 多指同时按下导致状态混乱如果用户用两根手指同时按在按钮上onTouchEvent会收到多个ACTION_POINTER_DOWN和ACTION_POINTER_UP事件。我的处理方式是只响应第一个手指的按下后续的手指事件全部忽略。具体做法是在ACTION_DOWN里记录activePointerId在ACTION_MOVE和ACTION_UP里只处理这个 id 对应的事件。case MotionEvent.ACTION_DOWN: activePointerId event.getPointerId(0); // ... 其他逻辑 break; case MotionEvent.ACTION_MOVE: int pointerIndex event.findPointerIndex(activePointerId); if (pointerIndex 0) break; float x event.getX(pointerIndex); float y event.getY(pointerIndex); // ... 判断是否滑出 break; case MotionEvent.ACTION_POINTER_UP: int upPointerId event.getPointerId(event.getActionIndex()); if (upPointerId activePointerId) { // 活跃手指抬起了视为取消 state STATE_RELEASING; targetProgress 0f; } break;这样即使用户多指操作按钮的状态也是确定的不会出现“一根手指按着、另一根手指抬起导致进度归零”的混乱情况。5.4 常见问题速查表问题现象可能原因排查方法解决方案弧线被裁切onMeasure 未预留线宽空间打印测量宽高与 arcRect 对比在 defaultSize 中加上 progressWidth * 2弧线位置偏移arcRect 未考虑 padding检查 arcRect 的 left/top 计算加上 getPaddingLeft/Top快速点击闪烁状态切换过快在 ACTION_UP 里打印 currentProgress小于阈值时直接归零不做回退多指状态混乱未跟踪 activePointerId打印 event.getPointerCount()只响应第一个手指的事件回退太慢/太快damping 系数不合适调整 damping 观察回退时长建议取 0.15 到 0.25 之间高刷屏上卡顿用 postDelayed 固定间隔对比 postOnAnimation改用 postOnAnimation 跟随帧率文字不居中基线计算错误打印 fontMetrics 各值用 centerY textHeight/2 - descent5.5 几个我踩过的坑和独家技巧第一个坑是关于invalidate的调用时机。我一开始在progressRunnable里先invalidate再postOnAnimation结果发现有时候会丢帧。后来改成先postOnAnimation再invalidate反而更稳定。具体原因我还没完全搞清楚猜测和消息队列的执行顺序有关但实测下来这个顺序确实更可靠。第二个技巧是关于进度弧线的渐变效果。如果你想让弧线从起点到终点有颜色渐变drawArc本身不支持需要用SweepGradient配合Paint.setShader。但SweepGradient的渐变中心默认在 (0,0)你需要用Matrix把它平移到按钮中心。这个效果做出来很炫但计算量比纯色大低端机上可能会掉帧慎用。第三个技巧是关于无障碍支持的。自定义 View 默认对无障碍服务不友好建议在onInitializeAccessibilityNodeInfo里设置className和contentDescription并且在进度满时发送AccessibilityEvent.TYPE_VIEW_CLICKED事件。这个细节很多教程都不会提但如果你做的应用需要上架审核无障碍支持是加分项。6. 性能优化与扩展思路6.1 减少不必要的重绘这个组件在PRESSING和RELEASING状态下每帧都会重绘这是必要的开销。但在IDLE状态下应该确保没有任何重绘发生。我在progressRunnable里做了判断当状态回到IDLE时直接return不再调用postOnAnimation这样按钮静止时完全不消耗 CPU。另外onDraw里尽量避免创建新对象。RectF、Paint、FontMetrics这些都应该在构造函数或者onSizeChanged里初始化好不要在onDraw里new。FontMetrics虽然每次getFontMetrics返回的是同一个对象但频繁调用也有微小开销可以缓存起来。6.2 支持自定义进度方向默认进度是从正上方顺时针增长但有些场景可能需要逆时针或者从其他角度开始。通过lppb_startAngle和lppb_sweepAngle两个属性已经可以覆盖大部分需求。如果要做逆时针把sweepAngle设成负数即可drawArc支持负的扫过角度。6.3 扩展成带图标的按钮Keep 那个按钮中间是一个图标而不是文字。要支持图标只需要在onDraw里把drawText换成drawBitmap或者drawable.draw(canvas)。注意计算图标的位置时要考虑图标的宽高让它居中显示。如果图标是Drawable记得在onSizeChanged里调用setBounds设置绘制区域。6.4 在 RecyclerView 中使用的注意事项如果这个按钮出现在RecyclerView的 item 里需要特别注意 View 的复用问题。当 item 被滑出屏幕再滑回来时onBindViewHolder会重新绑定数据但 View 的状态可能还保留着之前的进度值。解决方法是在onBindViewHolder里调用reset()方法确保每次绑定都是初始状态。另外如果 item 被回收时按钮正处于PRESSING状态progressRunnable还在循环会造成内存泄漏。建议在onDetachedFromWindow里调用removeCallbacks停止循环。Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); removeCallbacks(progressRunnable); progressLoopRunning false; }这个细节非常关键我在一个列表页里用了这个按钮没加onDetachedFromWindow的时候滑动列表会出现明显的卡顿加上之后流畅度立刻恢复正常。6.5 和普通点击事件的共存有些场景下按钮既需要支持普通点击又需要支持长按进度。我的做法是在ACTION_UP里判断currentProgress是否小于一个阈值如果小于就视为普通点击触发onClick回调如果大于阈值但没满就视为取消。这样两种交互可以共存用户短按是点击长按是进度确认互不干扰。case MotionEvent.ACTION_UP: if (state STATE_PRESSING) { if (currentProgress 0.05f) { // 视为普通点击 performClick(); } else { // 视为取消 state STATE_RELEASING; targetProgress 0f; } } break;注意performClick要重写并返回 true否则无障碍服务无法正确识别点击事件。7. 一些个人体会和后续可扩展的方向这个组件我从第一次实现到现在前后迭代了大概四五个版本每次都是在实际项目里遇到了新问题才去改。最开始只考虑了最基本的按下和抬起后来陆续加上了滑出取消、多指处理、快速点击过滤、RecyclerView 复用、无障碍支持这些细节。我的体会是自定义 View 的难点从来不在绘制本身而在于对各种边界情况的穷举和状态机的严谨设计。你画一条弧线可能只要十行代码但让这条弧线在各种极端操作下都表现正确可能需要一百行。后续如果还想继续扩展有几个方向可以考虑。一是支持进度满后的震动反馈调用Vibrator或者HapticFeedbackConstants给用户一个触觉确认。二是支持进度弧线的粒子效果在弧线末端加一些散落的小圆点增加视觉趣味性。三是把进度驱动逻辑抽成一个独立的ProgressDriver类让其他自定义 View 也能复用这套时间驱动的机制。这些扩展都不难核心思路和现在这套是一样的状态量交给绘制层驱动量交给计时器两者通过重绘解耦。代码写到最后你会发现最舒服的实现往往不是最炫技的那个而是状态最少、分支最清晰的那个。这个长按进度按钮就是这样它的核心逻辑用一句话就能概括按下时进度向 1 逼近松手时进度向 0 逼近逼近的过程用阻尼控制逼近的结果用重绘呈现。把这句话想明白了剩下的都是细节。