首页 / 资讯中心 / 文章详情

Android自定义SeekBar图片进度条:重写onDraw与Bitmap裁剪实战

Android自定义SeekBar图片进度条:重写onDraw与Bitmap裁剪实战 ★ FEATURED ARTICLE
简介面向Android开发者的自定义控件学习资源以图片背景与图片进度为重点解决系统SeekBar默认样式单一、难以与App视觉风格统一的问题。资源围绕继承系统SeekBar、定义自定义属性、重写交互方法三条主线展开适用于需要进行个性化滑动控件开发的中初级Android工程师。zip压缩包共52个文件大小约696KB主要包含4个java源文件、7个xml配置、22个png图片以及class、jar、dex、apk等编译产物并附带可直接安装验证的MySeekBar.apk和resources.ap_资源包结构清楚可直接导入工程查看效果。已有849人学习下载。资源内提供完整Eclipse工程目录包含MySeekBar自定义类源码、attrs.xml自定义属性声明、布局调用示例及drawable-hdpi等多密度图片素材可帮助读者快速理解从背景图准备、进度图设置到交互优化的一整套自定义SeekBar实现思路并在此基础上扩展动画与触摸反馈。1. 让 SeekBar 脱离系统皮肤图片背景与图片进度到底怎么实现不少开发者第一次接到「自定义 SeekBar」需求时第一反应都是去替换android:progressDrawable把系统默认的黄色条换成两张图片草草收工。但真正上真机就会发现图片被拉伸变形、thumb 滑块偏离进度条末端、不同分辨率下糊成一团这些问题几乎绕不开。我最早在某跨平台系统的播放器模块里做图片进度条时用的就是直接替换系统 drawable 的方案结果被那套隐式的 NinePatch 拉伸规则折腾了一周最后全砍掉重写。这篇要聊的就是我自己实际跑通的思路继承 SeekBar 重写onDraw()用 Bitmap 作为背景轨道和前景进度配合 clipRect 裁剪得到顺滑的图片进度条。适合三类人一是产品给了 PSD 但又不愿意切九宫格的场景二是进度条需要放缩略图、品牌图、渐变纹理这类不规则素材的从业者三是想彻底摆脱系统默认样式、追求像素级可控的开发者。下面从方案选型开始讲。2. 直接换 drawable 还是重写 onDraw两种方案对比与选型边界2.1 方案A替换 progressDrawable 的优缺点android:progressDrawable是官方给出的扩展点本意是让我们用layer-list和clip标签组合出进度条效果。系统自带的 ProgressBar 的进度条之所以能随进度增长就是因为它内部维护了一个ClipDrawable不断改变 clip level。顺着这个思路我们只需要准备两个 drawable一个作为背景层一个作为进度层再把进度层用clip包起来比如layer-list xmlns:androidhttp://schemas.android.com/apk/res/android item android:idandroid:id/background bitmap android:srcdrawable/seekbar_track android:gravityfill_vertical/ /item item android:idandroid:id/progress clip bitmap android:srcdrawable/seekbar_progress android:gravityfill_vertical/ /clip /item /layer-list这段配置的意思是第一层android:id/background固定显示底图第二层android:id/progress是裁剪层系统根据当前进度修改 clip level按比例显示进度图片的左侧部分。fill_vertical表示纵向满铺避免图片被水平拉伸变形。这个方案上手快不需要写 Java/Kotlin 代码改动量小适合内部工具或 demo 验证。但它的缺点很明显第一ClipDrawable 只能做水平或垂直的线性裁切如果你的进度形态是圆角、弧形或者带不规则装饰纹理它做不到第二图片与控件宽高的适配交给系统内部逻辑处理当控件宽度是动态的、或者图片实际宽度小于控件宽度时进度长度与 thumb 位置会出现偏差肉眼可见 thumb 和进度条端部没对齐。它最大的问题在于一旦要动态切换图片比如用户换主题、换皮肤你得重新解析整个 layer-list维护成本高。所以它只适合「图片素材规整、尺寸固定、一次写好不动」的极简场景。2.2 方案B继承 SeekBar 重写 onDraw为什么我推荐这种如果素材是异形的或者你想让进度图片像刻度尺一样精确贴合控件宽度就得把绘制逻辑握在自己手里。继承 SeekBar 重写onDraw(Canvas canvas)的核心思路是不再依赖系统的 progressDrawable 管道而是自己拿到进度值算出进度条应该画多宽然后手动把 Bitmap 画到 Canvas 上。这么做有三个切切实实的好处第一背景和进度是分别控制的背景图可以随意拉伸或平铺进度图可以按真实进度裁剪两者的视觉位置由代码精确计算不存在系统内部换算带来的错位第二能随心所欲地在进度条上叠加额外元素比如缩略图、文字标签、小圆点刻度这些在 layer-list 里几乎没法优雅实现第三动态替换图片素材非常轻量改一个 Bitmap 引用就行几十行代码搞定一套皮肤切换。代价是你得自己处理尺寸、密度、padding 这些细节但这些都是可预期、可测试的比系统黑匣子里的隐式规则要可控得多。我一般会建议项目里有「进度条上有缩略图」或「进度条形状不规则」这两个需求特征时直接选方案B。2.3 三张图的准备背景 track、进度 progress、滑块 thumb 的尺寸规范选型确定后素材规范是第一个需要立规矩的地方否则后期所有换算都会出问题。我们需要三种图背景轨道track、前景进度progress、滑块thumb。背景轨道和前景进度在视觉上应当同宽同高前景进度图通常是一张带颜色的条状图背景轨道则是灰色或暗色的底条。尺寸上我一般建议以控件最终显示高度为准来切不要切得太大原因后面讲屏幕密度坑时会说。假设进度条显示高度是 40dp素材切 80dp 高度的 png 即可保证在 2x 密度下不糊就行。滑块则是一张正方形或圆形的图标注意滑块的中心点就是进度值的锚点所以素材视觉重心必须和图片中心重合否则拖动时会有明显的「偏」的感觉。另外所有 png 都不要带阴影熔边带的话在非白色背景上会露出难看的黑色或白色边角。给前景进度和滑块准备xxxhdpi一套就够el 尺度退化到 drawable-nodpi 反而省事避免各密度下 BitmapFactory 自动缩放带来的额外换算。3. 实战图片背景 SeekBar 的具体实现与参数调优3.1 定义属性与构建代码骨架新建一个ImageSeekBar类继承自SeekBar先在res/values/attrs.xml里声明自定义属性让布局文件可以直接指定要用哪几张图resources declare-styleable nameImageSeekBar attr nametrackImage formatreference/ attr nameprogressImage formatreference/ attr namethumbImage formatreference/ attr nameprogressHeight formatdimension/ /declare-styleable /resources属性含义分别是背景轨道图、前景进度图、滑块图、进度条绘制高度。progressHeight单独拎出来是因为轨道图往往自带上下留白或光晕直接按图片高度画会把控件撑得很大通过这个属性可以把绘制区域约束到合理范围。接着在构造函数里读取这些属性并解析成 Bitmappublic class ImageSeekBar extends SeekBar { private Bitmap trackBitmap; private Bitmap progressBitmap; private Bitmap thumbBitmap; private int progressHeight; public ImageSeekBar(Context context, AttributeSet attrs) { super(context, attrs); TypedArray ta context.obtainStyledAttributes(attrs, R.styleable.ImageSeekBar); trackBitmap BitmapFactory.decodeResource(getResources(), ta.getResourceId(R.styleable.ImageSeekBar_trackImage, 0)); progressBitmap BitmapFactory.decodeResource(getResources(), ta.getResourceId(R.styleable.ImageSeekBar_progressImage, 0)); thumbBitmap BitmapFactory.decodeResource(getResources(), ta.getResourceId(R.styleable.ImageSeekBar_thumbImage, 0)); progressHeight ta.getDimensionPixelSize(R.styleable.ImageSeekBar_progressHeight, dp2px(6)); ta.recycle(); // 关键关闭系统自带的进度绘制与 thumb 绘制防止叠加 setProgressDrawable(null); setThumb(null); } }注意setProgressDrawable(null)和setThumb(null)这两行很多人会漏。SeekBar 的父类构造函数里会创建默认的ProgressDrawable和默认 thumb如果不清掉你自己的onDraw绘制会和系统默认绘制叠加屏幕上出现两套进度条。逻辑上先取自定义属性再把系统原生的进度条和滑块全部关掉后面onDraw里画的每一像素都是可控的。dp2px是常用工具方法用context.getResources().getDisplayMetrics().density乘一下就行这里不赘述。3.2 核心的 onDraw 逻辑clipRect 裁剪绘制进度条onDraw是整个控件的核心原则是先画背景轨道再画裁剪后的前景进度最后画 thumb。背景轨道直接画满整个控件宽度不裁剪前景进度则根据当前进度计算应显示宽度用canvas.save()clipRect()限制绘制区域然后把完整的进度 Bitmap 画进去。这样效果上就是「只露出左半边」视觉上形成进度条。Override protected synchronized void onDraw(Canvas canvas) { super.onDraw(canvas); int w getWidth() - getPaddingLeft() - getPaddingRight(); int left getPaddingLeft(); int top (getHeight() - progressHeight) / 2; int right left w; int bottom top progressHeight; // 1. 背景轨道拉伸铺满 RectF trackRect new RectF(left, top, right, bottom); canvas.drawBitmap(trackBitmap, null, trackRect, null); // 2. 前景进度按百分比裁剪 float ratio getProgress() * 1f / getMax(); int progressWidth (int) (w * ratio); if (progressWidth 0) { canvas.save(); canvas.clipRect(left, top, left progressWidth, bottom); RectF progressRect new RectF(left, top, right, bottom); canvas.drawBitmap(progressBitmap, null, progressRect, null); canvas.restore(); } // 3. thumb 的绘制稍后单独讲 }这里要理解一个典型误区不是「按比例把进度图截取一部分再拉伸」而是「先完整绘制进度 Bitmap再用 clipRect 裁掉右侧不需要的部分」。两种做法结果类似但性能开销差别很大前者每次进度变化都要创建新 Bitmap 并裁剪后者只改变裁剪区域GPU 在光栅化时直接丢弃被裁掉的像素效率高得多。参数说明ratio用getProgress() * 1f / getMax()是为了避免整数相除如果你写成getProgress() / getMax()Java 整除规则会让几乎所有非满进度都得到 0。这是新手最容易犯错的地方。clipRect的坐标是绝对坐标所以用left progressWidth作为右边界正好等于从控件左侧开始算的可见宽度。canvas.save()和restore()必须成对出现中间任何绘制操作都在裁剪范围内否则会影响后续的 thumb 绘制。3.3 滑块thumb位置计算与最小间隔处理thum 的横坐标需要精确计算否则滑块和进度条端部不能对齐。SeekBar 的进度范围是0到getMax()滑块中心应该落在进度相对宽度乘以实际绘制宽度的位置。但这里有个前置问题滑块有自身宽度如果进度为 0 时滑块还画在轨道最左边视觉上会「冒出」进度条一截如果 max 很大滑到最右侧时滑块中心又会超过轨道右边界。常规处理是让滑块中心始终落在轨道内部并取progressWidth - thumbWidth / 2作为右边界约束。private void drawThumb(Canvas canvas, int left, int top, int right, int bottom, int progressWidth) { if (thumbBitmap null) return; int thumbW thumbBitmap.getWidth(); int thumbH thumbBitmap.getHeight(); float centerX left progressWidth; // 边界约束滑块不出轨道两端 float minX left thumbW / 2f; float maxX right - thumbW / 2f; if (centerX minX) centerX minX; if (centerX maxX) centerX maxX; float centerY (top bottom) / 2f; RectF dst new RectF(centerX - thumbW / 2f, centerY - thumbH / 2f, centerX thumbW / 2f, centerY thumbH / 2f); canvas.drawBitmap(thumbBitmap, null, dst, null); }逻辑说明滑块中心先按进度值落在left progressWidth处然后做边界检测。最小中心不能小于left thumbW/2最大中心不能大于right - thumbW/2这保证滑块即使到 0% 或 100%也只露出半张在轨道内不会完全脱离。centerY 取轨道中线滑块垂直居中。注意这里有一个隐患——thumb 中心被约束后它指向的实际数值就不再严格等于 progress 比例比如 0% 时滑块中心还在轨道左侧边界外半张图的位置。如果产品要求严格对齐需要在onMeasure或 touch 事件处理里对 thumb 的触摸范围做补偿这个放到第 4 章避坑细说。3.4 参数调优高度换算、padding 与触摸热区写完基础绘制后控件高度和触摸体验是接踵而至的两个问题。SeekBar 的默认高度由最小接触目标决定Android 官方建议不低于 48dp但视觉上进度条本身只有几 dp。为了兼顾两者我一般这样定参数progressHeight控制视觉轨道高度onMeasure里把测量高度设为progressHeight 20dp留出上下触摸热区避免手指按不准。具体实现Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int h progressHeight getPaddingTop() getPaddingBottom() dp2px(20); int w MeasureSpec.getSize(widthMeasureSpec); setMeasuredDimension(w, h); }这段代码的意思是控件高度不隨图片实际高度走而是由 progressHeight 加上触摸热区决定使整个控件高约 26~30dp同时进度条只占中间一份上下留有充足的热区。另外要注意 padding 的处理有人会直接把 paddingLeft 加进 trackRect 的 left但触摸事件里 SeekBar 系统计算 thumb 位置并不会感知自定义 padding导致「按下位置与实际进度严重不符」。常见的解法是在 onTouchEvent 里把触摸的 x 坐标减去getPaddingLeft()再换算进度。具体做法Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() MotionEvent.ACTION_DOWN || event.getAction() MotionEvent.ACTION_MOVE) { float x event.getX() - getPaddingLeft(); float ratio x / (getWidth() - getPaddingLeft() - getPaddingRight()); if (ratio 0) ratio 0; if (ratio 1) ratio 1; setProgress(Math.round(ratio * getMax())); getParent().requestDisallowInterceptTouchEvent(true); return true; } return super.onTouchEvent(event); }参数说明requestDisallowInterceptTouchEvent(true)是防止父容器比如纵向 ScrollView在横向拖动时抢走事件横竖互斥的界面里这句可以省略。这里手动处理了进度换算但要留意 setProgress 会触发 onDraw每次拖动都会重绘频率高时建议在 setProgress 后调用invalidate()即可没必要触发 requestLayout。4. 自定义 SeekBar 避坑五条我真实踩过的坑4.1 坑一Bitmap 被 Recycle 后崩溃现象切换皮肤时把旧的 trackBitmap 调用了recycle()activity 退出再进来控件绘制时抛出Canvas: trying to use a recycled bitmap。原因Bitmap 的 recycle 是立即释放底层像素内存但控件持有该引用下一次 onDraw 发生时调用 drawBitmap 就会触底崩溃。解决不要在 Activity 销毁前单独 recycleBitmap 内存交给系统管理Android 8.0 以后像素数据都在 native 堆GC 会负责回收。需要动态换图时替换的时机要保证旧 Bitmap 不再被引用不要提前释放或使用getBitmap().reconfigure()复用。4.2 坑二图片在不同分辨率下变糊现象同一张 png在模拟器上清晰在真机上两条边出现锯齿或模糊。原因BitmapFactory.decodeResource会根据资源目录密度自动缩放。如果你的图放在drawable-xhdpi里在 3x 密度真机上会被放大 1.5 倍导致模糊。解决把进度条图片按高密度目录放一套或在构造函数里用BitmapFactory.Options关闭自动密度缩放BitmapFactory.Options opts new BitmapFactory.Options(); opts.inScaled false; trackBitmap BitmapFactory.decodeResource(getResources(), resId, opts);inScaled false的含义是禁用系统按屏幕密度对图片做预缩放图片保持原始像素尺寸绘制时的缩放交给 Canvas 矩阵在渲染阶段做。图片像素够的话这种方式在任意密度下都不糊。4.3 坑三thumb 与进度条视觉不同步现象进度 50% 时thumb 中心点明显偏右尤其进度条两端与控件边界有 padding 时更明显。原因系统内部计算 thumb 位置时考虑了进度条自身的 padding 和 thumb 宽度的一半偏移但我们重写 onDraw 后只按简单的left progressWidth来定位两者公式不一致。解决在 onDraw 里把 paddingLeft 纳入偏移同时规范 thumb 的边界约束公式让视觉中心尽量对齐实际进度值。我的做法是让 thumb 中心落在left progressWidth再对左右边界做钳制保证 50% 时中心点大体在轨道中线附近。4.4 坑四Glide 异步加载图片导致错位现象进度条背景或缩略图是用 Glide 加载的图片加载完成后控件显示的是上一帧的缓存旧图或者进度跳变到错误位置。原因Glide 的into(ImageView)是异步回调Bitmap 到位后 if 控件还没有 layout 完成getWidth()返回 0绘制时比例错乱。解决在onGlideReady回调里拿到 Bitmap 后用post()方法把重绘操作抛到主线程消息队列尾部等 layout 完成后再执行 invalidate更稳的是把 Bitmap 存进控件字段后主动调用layout(0, 0, getWidth(), getHeight())强制刷新。4.5 坑五无障碍语义丢失与焦点错乱现象TalkBack 朗读时进度条只能读出「滑块」读不出轨道状态键盘方向键无法调节进度。原因重写 onDraw 后setProgressDrawable(null)虽然绘制上干净了但系统无障碍组件读取的是 progress 相关属性而我们的绘制逻辑没改变语义层。解决在构造函数里保留系统 progress 属性和setMax()默认值不要重写getProgress语义若使用自定义 thumb给控件设置contentDescription并在onKeyDown里处理左右方向键与进度增减。5. 进阶技巧缩略图进度条与动态图片加载的状态绑定5.1 缩略图滑块的垂直居中与缩放策略真实项目里滑块往往是一张带有缩略图或状态图标的大图比如视频播放器的封面帧。这时如果直接按原始尺寸 drawBitmap滑块高度可能超过轨道高度 3 倍视觉上失衡。常见的做法是限制滑块绘制高度为轨道高度的 1.5~2 倍宽度按比例缩放int targetH (int) (progressHeight * 1.5f); int thumbW thumbBitmap.getWidth(); int thumbH thumbBitmap.getHeight(); float ratio targetH * 1f / thumbH; int dstW (int) (thumbW * ratio); RectF dst new RectF(centerX - dstW / 2f, centerY - targetH / 2f, centerX dstW / 2f, centerY targetH / 2f); canvas.drawBitmap(thumbBitmap, null, dst, null);逻辑说明先计算目标高度与原始高度的缩放比再按比例缩放宽度避免拉伸变形。centerY 取轨道中线滑块视觉上悬浮于轨道上贴合大多数播放器风格。这里要注意一个细节如果滑块绘制高度超过控件自身高度上下会被裁掉所以 onMeasure 的高度要预留这部分空间可以把额外热区从 20dp 改成progressHeight * 0.5f加上滑块溢出部分。5.2 用 Matrix 做精确的触摸热区映射如果滑块不仅是视觉图标还需要作为「点击热区」可被单独选中仅靠onTouchEvent的线性换算可能不够因为滑块尺寸较大、位置有边界约束我们应该将触摸点与滑块实际绘制矩形做命中检测private boolean isTouchOnThumb(float x, float y) { if (thumbBitmap null) return false; int w getWidth() - getPaddingLeft() - getPaddingRight(); int h progressHeight; int left getPaddingLeft(); int top (getHeight() - h) / 2; float ratio getProgress() * 1f / getMax(); int progressWidth (int) (w * ratio); float centerX Math.max(left thumbBitmap.getWidth() / 2f, Math.min(left progressWidth, left w - thumbBitmap.getWidth() / 2f)); float centerY (top top h) / 2f; RectF thumbRect new RectF(centerX - 20f, centerY - 20f, centerX 20f, centerY 20f); return thumbRect.contains(x, y); }这段代码的核心是用一个临时矩形模拟滑块的可点击区域20f 是一个半宽经验值也可以从thumbBitmap实际缩放后的尺寸推导。触摸事件里先做命中判断再决定是进入「拖动进度」还是「忽略本次触摸」这样能避免滑块与进度条边缘重叠时误触。那块我从第一次做自定义 SeekBar 起就每次都强制走一遍先验证图片尺寸再跑inScaledfalse最后onDraw里对 thumb 中心打 log 对比进度值。这几步几乎能挡住后面全部视觉 bug。希望这篇能帮你在类似需求上少走一些弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站