简介面向Android开发者的SeekBar定制方案资源包以图片替换系统原生样式解决默认SeekBar外观单一、难以匹配界面整体设计的问题。压缩包内是一个完整的Eclipse工程示例演示如何继承SeekBar并编写MySeekBar类在attrs.xml中声明seekBarBackground与seekBarProgress自定义属性在构造方法中加载两张长条形图片分别作为背景和进度条同时重写onProgressChanged等回调以扩展交互细节。资源共计52个文件包含22张png界面素材、7个xml布局与属性定义、4个java核心源码、可直接安装的apk示例及jar/dex等辅助文件整体大小约696KB目录结构完整便于导入Android项目直接对照学习。目前已有849人学习下载适合希望快速美化滑动控件、提升应用UI细节的初中级开发者。借助其中的源码、素材与可运行示例读者能清晰理解整个自定义流程并将图片背景与进度条方案迁移到音量调节、亮度调节等实际场景中。1. 自定义SeekBar图片背景、图片进度先看清卡点再动手把设计稿里的渐变轨道、发光进度条、带阴影的滑块做进 Android 的 SeekBar直接改android:background会发现图片被拉伸得不成样子进度条也完全不听使唤。原因很简单SeekBar 的进度层不是普通背景它由progressDrawable和thumb两套机制共同决定。自定义 SeekBar图片背景、图片进度的核心不是找素材而是搞清楚 Android 内部如何把进度值“翻译”成图片的裁剪区域。这篇文章会把机制拆开给出两种落地方案再把我踩过的几个坑一次说清。适合 UI 还原度要求高、又不想引入第三方库的开发者新手可以照步骤跑通熟手可以直接拿去改边界参数。2. 为什么图片换上去会乱progressDrawable与thumb的配合内幕先别急着写代码。很多人在这一步翻车是因为把 SeekBar 当成普通 View给一个背景图和进度图就完事。实际上 SeekBar 继承自 ProgressBar它的进度显示依赖一套“drawable level”的协作规则。理解这一点后面的所有改动才有的放矢。2.1 进度不是画出来的ProgressBar把level交给drawableProgressBar 的逻辑很简单内部保存max和progress每次setProgress()时计算一个 0 到 10000 之间的整数然后调用getProgressDrawable().setLevel(level)。注意它没有直接去 Canvas 上画裁剪矩形而是把这个 level 交给 drawable让 drawable 自己决定怎么显示。Android 系统里真实的进度条用的就是ClipDrawable或ScaleDrawable。ClipDrawable收到 level 后会按clipOrientation和gravity裁剪自己的内容。level 是 0绘制宽度就是 0level 是 10000绘制宽度就是完整宽度。用一个表来理解会更直观level 值ClipDrawable 显示比例说明00%进度起点500050%绘制一半10000100%完整显示任何值都会被 progress 按公式换算progress * 10000 / max由 ProgressBar 自动完成所以如果你直接把一张普通 BitmapDrawable 塞进progressDrawablesetLevel()对它是无效的——普通 drawable 不感知 level进度永远显示整张图或完全不显示。这就是“图片换上去进度乱”的第一层原因。2.2 thumb与轨道的对齐thumbOffset、padding、bounds三件套thumb 是滑块的 drawable它和进度轨道之间有一个默认的偏移量。系统为了美观会让 thumb 的中心点对准轨道两端因此默认thumbOffset大约等于 thumb 宽度的一半。这个设计在系统自带的细长 thumb 上没问题但换成自定义图片后问题就来了如果 thumb 是一张宽 48dp 的圆角方块轨道就会被左右各“吃掉”24dp你设置的背景图却还是按全宽绘制最终显示出来的效果就是进度图片两头缺了一截。要修正这个行为需要在布局里显式设置android:thumbOffset0dp避免系统自作主张缩进度条宽度。另一个容易忽略的是 paddingprogressDrawable的有效绘制区域是View 宽度减去左右 padding如果背景图想铺满整个控件左右 padding 必须为 0否则图片会被压缩。很多开发者会习惯性给控件留 padding结果背景图变形却找不到原因。2.3 动手验证不写自定义View直接观察系统行为先做个小实验确认上面的机制不用写 Java 代码。新建一个布局给 SeekBar 指定一个普通的 BitmapDrawable 作为 progressDrawable再放一张自定义 thumb运行看看效果SeekBar android:idid/test_seek android:layout_widthmatch_parent android:layout_heightwrap_content android:progressDrawabledrawable/bg_progress_plain android:thumbdrawable/thumb_custom android:progress60 /把bg_progress_plain定义成一张简单的 png。跑起来后你会看到进度区域要么整张图糊在那里要么完全不显示。这就证明普通 drawable 不响应 level。再把bg_progress_plain换成下面要写的 ClipDrawable 组合进度才会按 60% 正确裁剪。3. 图片背景与图片进度LayerDrawable ClipDrawable落地方案理解了机制接下来就是标准做法。用图片做背景、用图片做进度最稳妥的方案不是继承 View而是组合LayerDrawable和ClipDrawable。这套方案代码量小、性能好也是市面上大多数自定义 SeekBar 的基础。3.1 drawable资源layer-list如何写背景层与进度层在res/drawable/下新建一个 layer-list 文件把背景、二级进度、主进度依次放进去。注意 item 的 id 必须写系统预设的三个 idProgressBar 就是靠 id 找到每一层的。?xml version1.0 encodingutf-8? layer-list xmlns:androidhttp://schemas.android.com/apk/res/android item android:idandroid:id/background android:drawabledrawable/bg_track / item android:idandroid:id/secondaryProgress clip android:clipOrientationhorizontal android:gravitystart android:drawabledrawable/bg_progress_secondary / /item item android:idandroid:id/progress clip android:clipOrientationhorizontal android:gravitystart android:drawabledrawable/bg_progress / /item /layer-list逻辑说明background层不需要 clip始终完整显示progress层必须包一层clip这样 ProgressBar 设置 level 时才会按水平方向裁剪图片。clipOrientationhorizontal表示按水平方向裁剪gravitystart表示裁剪后从左侧对齐这样进度从左往右增长。如果把 gravity 写成center会从中间向两边展开效果会非常奇怪。参数说明secondaryProgress是二级进度一般用于“缓冲进度”场景比如视频播放器。如果项目不需要可以直接删掉这一层不影响主进度。bg_track、bg_progress建议使用相同尺寸的 png并且高度保持一致否则 layer-list 各层会因重力布局不同而出现错位。3.2 布局里设置SeekBar的尺寸与thumbdrawable 文件准备好了布局里引用它并处理高度与 thumb 的匹配。这里有一个容易踩的细节SeekBar 的layout_height如果写成wrap_content它会被 thumb 的高度撑大导致背景轨道被垂直拉伸。正确做法是固定 minHeight 和 maxHeight让轨道高度由你说了算。SeekBar android:idid/custom_seek android:layout_widthmatch_parent android:layout_heightwrap_content android:maxHeight10dp android:minHeight10dp android:progressDrawabledrawable/seekbar_progress_layer android:thumbdrawable/thumb_round android:thumbOffset0dp android:paddingStart0dp android:paddingEnd0dp /布局参数说明minHeight和maxHeight同时设为 10dp可以把轨道高度固定住避免被 thumb 撑开。thumbOffset0dp解决前面说的轨道缩短问题。paddingStart与paddingEnd都设为 0保证背景图能完整铺满。thumb 图片建议也做成 20dp 左右高度的透明背景 png视觉上中心对准轨道即可。3.3 运行时替换setProgressDrawable后进度不刷新怎么办XML 方案适合静态皮肤。如果要做“换肤”功能比如白天主题一套图、夜间主题一套图需要在代码里动态替换。这时常见的现象是调用setProgressDrawable()之后进度条并没有按当前进度显示而是停在初始位置。SeekBar seekBar findViewById(R.id.custom_seek); Drawable bg ContextCompat.getDrawable(this, R.drawable.bg_track); Drawable progress ContextCompat.getDrawable(this, R.drawable.bg_progress); ClipDrawable clip new ClipDrawable(progress, Gravity.START, ClipDrawable.HORIZONTAL); LayerDrawable layer new LayerDrawable(new Drawable[]{bg, clip}); layer.setId(0, android.R.id.background); layer.setId(1, android.R.id.progress); seekBar.setProgressDrawable(layer); seekBar.setProgress(seekBar.getProgress());逻辑说明ClipDrawable有四个构造参数第一个是实际内容 drawable第二个是裁剪方向第三个是裁剪模式。HORIZONTAL对应水平裁剪。LayerDrawable的数组顺序必须和 id 对应索引一致先 background 后 progress。最后一行setProgress(getProgress())是关键它的作用是强制 ProgressBar 重新计算 level 并同步给新的 drawable否则新 skin 不会生效。参数说明如果多个 SeekBar 共用同一个 drawable 资源建议在setProgressDrawable前调用layer.mutate()避免多个控件共享同一份 drawable state 导致进度互相干扰。setProgress这个方法名看起来多余但它承担了“刷新 level”的职责不能省。4. 做更灵活的自绘SeekBar重写onDraw和onTouchEvent的完整写法LayerDrawable 方案能覆盖绝大多数场景但有些设计稿要求更多进度图上要叠加一个渐变、边缘要加阴影、轨道中间要画文字“已播放 50%”这些用 drawable 组合很难实现。这时候就需要继承 SeekBar重写绘制和触摸把进度条当作自定义 View 来画。4.1 重写onDraw背景、进度、thumb三层绘制顺序继承 AppCompatSeekBar在 onDraw 里手动完成三层绘制。绘制顺序不能乱先画轨道背景再画进度图最后画 thumb否则 thumb 会被进度盖住。public class ImageSeekBar extends AppCompatSeekBar { private Drawable bgDrawable; private Drawable progressDrawable; private Drawable thumbDrawable; private final Paint textPaint new Paint(); public ImageSeekBar(Context context, AttributeSet attrs) { super(context, attrs); textPaint.setColor(0xFFFFFFFF); textPaint.setTextSize(dp2px(12)); // 初始化时从 attrs 或默认资源读取 bgDrawable ContextCompat.getDrawable(context, R.drawable.bg_track).mutate(); progressDrawable ContextCompat.getDrawable(context, R.drawable.bg_progress).mutate(); thumbDrawable ContextCompat.getDrawable(context, R.drawable.thumb_round).mutate(); } Override protected synchronized void onDraw(Canvas canvas) { int width getWidth(); int height getHeight(); int max Math.max(getMax(), 1); float ratio (float) getProgress() / max; int trackWidth width - getPaddingLeft() - getPaddingRight(); bgDrawable.setBounds(0, 0, width, height); bgDrawable.draw(canvas); int progressWidth Math.round(trackWidth * ratio); progressDrawable.setBounds(getPaddingLeft(), getPaddingTop(), getPaddingLeft() progressWidth, height - getPaddingBottom()); progressDrawable.draw(canvas); int thumbWidth thumbDrawable.getIntrinsicWidth(); int thumbHeight thumbDrawable.getIntrinsicHeight(); int thumbLeft getPaddingLeft() Math.round(trackWidth * ratio) - thumbWidth / 2; int thumbTop (height - thumbHeight) / 2; thumbDrawable.setBounds(thumbLeft, thumbTop, thumbLeft thumbWidth, thumbTop thumbHeight); thumbDrawable.draw(canvas); } private float dp2px(float dp) { return getResources().getDisplayMetrics().density * dp; } }逻辑说明bgDrawable铺满整个控件只做背景不做进度裁剪。progressDrawable的宽度由 ratio 计算左边界从paddingLeft开始右边界是paddingLeft progressWidth这样进度和背景的起点才能对齐。thumb 的绘制位置需要以进度线末端为基准减去自身宽度的一半实现“中心点对准进度端点”的效果。参数说明mutate()用来避免 drawable 状态共享。如果你有多个 ImageSeekBar不给 drawable 调用 mutate其中一个控件设置 bounds 会影响另一个。thumb 的intrinsicWidth可能为 -1如果图片没有明确尺寸需要 fallback 到一个默认值否则 thumb 会消失。4.2 触摸处理按下和移动都要实时换算进度自绘 SeekBar 如果不重写触摸事件默认行为会把手指位置和 thumb 位置做偏移换算导致点击进度条空白区域时 thumb 会先跳到手指位置、progress 不联动。这里直接接管触摸事件把手指坐标转换为进度值。Override public boolean onTouchEvent(MotionEvent event) { if (!isEnabled()) { return false; } switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: setPressed(true); updateProgressFromEvent(event); break; case MotionEvent.ACTION_MOVE: updateProgressFromEvent(event); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: setPressed(false); updateProgressFromEvent(event); break; default: break; } return true; } private void updateProgressFromEvent(MotionEvent event) { int trackWidth getWidth() - getPaddingLeft() - getPaddingRight(); if (trackWidth 0) { return; } float x event.getX() - getPaddingLeft(); float ratio Math.max(0f, Math.min(1f, x / trackWidth)); setProgress(Math.round(getMax() * ratio)); // 如果需要在拖动中回调做 UI 更新可在这里调用 listener }逻辑说明updateProgressFromEvent把手指的横坐标减去左 padding除以可用轨道宽度得到 0 到 1 的比例再用 max 乘以比例得到目标进度。Math.max和Math.min做边界钳制手指拖出控件边缘时进度不会变成负数或超过最大值。参数说明arrive段返回 true表示事件已被消费不再交给父类处理这样能避免 SeekBar 默认 touch 逻辑的干扰。如果希望在点击轨道时先跳转 thumb、抬起时才真正生效类似音乐 App 松手确认可以把 ACTION_DOWN 里的updateProgressFromEvent去掉只在 UP 时计算具体行为按产品需求调整。4.3 动态换肤运行时替换背景与进度图片的刷新策略自绘控件换肤比 drawable 方案简单因为绘制逻辑在自己手里只需要替换引用并刷新。但有个细节替换后必须主动触发重绘并且旧 drawable 如果不再使用最好回收掉避免低端机内存膨胀。public void setSkin(int bgRes, int progressRes, int thumbRes) { if (bgDrawable ! null) { bgDrawable.setCallback(null); } bgDrawable ContextCompat.getDrawable(getContext(), bgRes).mutate(); progressDrawable ContextCompat.getDrawable(getContext(), progressRes).mutate(); thumbDrawable ContextCompat.getDrawable(getContext(), thumbRes).mutate(); invalidate(); }逻辑说明setCallback(null)是为了和旧 drawable 解绑防止它继续接收系统回调。invalidate()触发 onDraw 重新执行。因为自绘的进度宽度完全由ratio计算所以换皮肤后进度位置不会丢这比 drawable 方案更顺手。参数说明如果只换进度图、背景图和 thumb 不变可以给这个方法加一个 nullable 判断传 0 表示不替换。另一个注意点是 drawable 的setBounds在下一次onDraw前不会被调用所以换肤方法里不需要手动设置 bounds避免和 onDraw 的 bounds 逻辑打架。5. 避坑排查图片进度条最容易翻车的五处细节这章内容全部来自实际开发里的返工记录。每条按现象、原因、解决来写建议把它们贴到项目文档里改控件时逐个对照。5.1 进度图两头缺一截先查thumbOffset默认值现象背景图完整但进度图片永远两端差一截拖动时 thumb 能超出进度图边界。原因thumbOffset默认值是 thumb 宽度的一半ProgressBar 会因此缩短 progressDrawable 的有效绘制宽度而背景层不受影响。解决在布局里显式设置android:thumbOffset0dp或者在代码里seekBar.setThumbOffset(0)。5.2 背景图片变形把设计稿大图直接当background现象轨道两端被拉伸、高度压缩后图片模糊。原因SeekBar 的 height 被设置为 wrap_content而背景图是固定宽高比系统会自动拉伸填充。LayerDrawable 里的 item 不会保持图片比例。解决给 SeekBar 固定android:minHeight和android:maxHeight让高度精简到设计值如果图片有圆角不要用圆角 png 硬撑优先让 UI 输出带透明区域的合理尺寸图。5.3 左右两侧触摸区太小点击轨道边缘进度无响应现象手指点在 thumb 之外的轨道两端进度条不变化尤其在 thumb 图片很大的时候更明显。原因thumb 的图片有一定宽度系统默认的触摸处理会把“thumb 中心到轨道端点的距离”视作有效区域过宽的 thumb 会挤压可点区域。解决重写onTouchEvent直接按整条轨道宽度换算进度不依赖系统内部对 thumb 的偏移计算。代码就是 4.2 节里的updateProgressFromEvent方案。5.4 拖动时进度条卡顿onProgressChanged里频繁decode图片现象滑动流畅度明显下降手指一停进度条还会“跳一点”内存上涨。原因监听器里每次回调都去 decode Bitmap 或者重新加载 drawable频繁触发 IO 和内存分配。旧代码里最典型的写法是把BitmapFactory.decodeResource放进onProgressChanged。解决把图片资源预加载到字段里拖动过程中只改 progress 的 bounds不碰底层图片解码。如果必须做文字实时刷新用setText而不是重新生成整个 drawable。5.5 半透明PNG出现暗色边缘或锯齿没处理drawFilter现象带抗锯齿的半透明 thumb 图片在真机上边缘发虚甚至出现一圈黑边模拟器上却正常。原因部分手机的硬件加速管线对非预乘 alpha 图的绘制会走不同的采样策略半透明边缘的 RGB 值残留导致暗边。解决给 Paint 设置setFilterBitmap(false)可以减少部分锯齿从根本上让 UI 导出图片时确保透明边缘的 RGB 为纯色或做预乘。如果控件能接受性能开销也可以把背景轨道和进度用 shape drawable 写渐变避免位图边缘问题。6. 进阶把图片SeekBar抽成attrsStyle的组合皮肤做到这里自定义 SeekBar 已经能完全按设计稿还原了。还没做完的一部分是工程化——如果项目里有多处进度条每处布局里都复制一遍progressDrawable、thumbOffset、minHeight后期换设计风格会非常痛苦。可以把资源组合抽成 Style 和自定义属性。6.1 用attrs.xml声明自定义属性并读取resources declare-styleable nameImageSeekBar attr nametrackSrc formatreference / attr nameprogressSrc formatreference / attr namethumbSrc formatreference / /declare-styleable /resources在构造方法里读取这三个属性再走 4.3 节的setSkin逻辑。这样每种皮肤只需要写一条 Style控件的复用性一下子上来。6.2 用Style组织一套皮肤在values/themes.xml里定义一套皮肤样式把背景、进度、thumb 图都集中在一起style nameWidget.ImageSeekBar.Red item nametrackSrcdrawable/bg_track_red/item item nameprogressSrcdrawable/bg_progress_red/item item namethumbSrcdrawable/thumb_red/item /style布局里引用stylestyle/Widget.ImageSeekBar.Red切换皮肤时改一行 style 即可不需要动 Java 代码。6.3 验证技巧与上线前检查这类自定义控件最容易出现“开发机上没问题、真机上一碰就崩”的情况我的习惯是每做完一套皮肤都走一遍检查流程先跑低分辨率的低端机连续快速拖动进度条 50 次同时开 Profile 看内存曲线和 draw 耗时再检查极端进度 0 和 max 的显示状态确认没有越界绘制最后用系统开发者选项里的“显示硬件更新”看 thumb 附近是否有残留轨迹。做到这一步基本可以放心交付。从那以后我每次做完自定义控件都会强制自己在低配真机上完整跑一遍触摸流程再改下一处功能。自定义进度条不是玄学把 level 机制吃透、把 touch 事件握在自己手里图片背景和图片进度就能稳稳落地。希望帮到你。本文还有配套的精品资源点击获取