简介:面向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:android="http://schemas.android.com/apk/res/android"> <item android:id="@android:id/background"> <bitmap android:src="@drawable/seekbar_track" android:gravity="fill_vertical"/> </item> <item android:id="@android:id/progress"> <clip> <bitmap android:src="@drawable/seekbar_progress" android:gravity="fill_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 name="ImageSeekBar"> <attr name="trackImage" format="reference"/> <attr name="progressImage" format="reference"/> <attr name="thumbImage" format="reference"/> <attr name="progressHeight" format="dimension"/> </declare-styleable> </resources>属性含义分别是:背景轨道图、前景进度图、滑块图、进度条绘制高度。progressHeight单独拎出来是因为轨道图往往自带上下留白或光晕,直接按图片高度画会把控件撑得很大,通过这个属性可以把绘制区域约束到合理范围。接着在构造函数里读取这些属性并解析成 Bitmap:
public 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 销毁前单独 recycle;Bitmap 内存交给系统管理,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 起就每次都强制走一遍:先验证图片尺寸,再跑inScaled=false,最后onDraw里对 thumb 中心打 log 对比进度值。这几步几乎能挡住后面全部视觉 bug。希望这篇能帮你在类似需求上少走一些弯路。
本文还有配套的精品资源,点击获取