Android自定义SeekBar并使用Glide处理图片
本文讲解了在 Android 中如何使用 Glide 处理图片,并将结果作为 SeekBar 的样式。
在最近的一个处理音频播放的需求中,视觉同事要求实现如下一个效果:
左边一个进度条,中间一个时间长度,右边一个播放按钮。当点击播放按钮时,会播放音频,并不停刷新播放进度
如果将最长的竖线+左右4个对称竖向称为 1 个音视频视觉段,两个音频段之间以 1 个竖线分割,则音视频视觉段数量不确定,可能有 1、2、3、4 个
鉴于音视频视觉段的数量不固定,但是视觉不想重复切图,只想给一个最长的切图。所以剩下较短的段数开发自己计算比例,裁剪图片得到。
根据视觉提供的布局说明,则可以得到 1、2、3、4 个音视频视觉段的比例为:0.196f、0.468f、0.736f、1.0f。
本文不讲播放逻辑,只讲视觉实现逻辑。
上述音频进度条功能可以使用 SeekBar 实现,但是不能简单的组合 LayerDrawable 和 ClipDrawable 作为 SeekBar 的 progressDrawable,这种方案在裁剪特定音频段数时,会出现展示不全的问题。鉴于项目中使用 Glide 加载图片,所以在经过尝试后,决定使用 Glide 加载图片,经过变换处理后,再作为 progressDrawable 传递给 SeekBar。
要实现以上功能,我们需要实现以下几个功能点:
- 使用 Glide 为图片添加内边距(该功能与音频视觉无关,是另一个功能点的内容,此处一起讲了)。
- 使用 Glide 按照某个比例裁剪图片。
- 将我们自定义的图片 Drawable 包装后作为 progressDrawable 传递给 SeekBar。
使用 Glide 为图片添加内边框
Android 系统并未提供为图片或者 View 添加内边框(也叫描边)的方法。系统提供的添加边框的 xml 语法,是添加的外边框。如果想要为 为图片或者 View 添加内边框,我们需要自定义实现。要使用 Glide 为图片添加内边框,我们可以使用 Glide 提供的图片变换(Transformation)功能。假设我们自定义的 Transformation 的名称为 InnerStrokeTransformation,其接受三个参数:边框宽度,边框颜色,边框圆角半径。则其使用方式如下:
var options = RequestOptions()
// 添加 Transformation
val transformation = mutableListOf<Transformation<Bitmap>>()
// 其他 Transformation 省略
transformation.add(
InnerStrokeTransformation(
R.dimen.dp_0_5.dimenRes.toFloat(), // 边框宽度为 0.5dp
R.color.color_DFE2EA.colorRes, // 边框颜色为 #DFE2EA
R.dimen.dp_6.dimenRes.toFloat() // 边框圆角半径为:6dp
)
)
// 应用 Transformation
if (transformation.isNotEmpty()) {
options = options.transform(MultiTransformation(transformation))
}
// 加载图片
Glide.with(imageView)
.load(url)
.apply(options) // 应用 RequestOptions
.into(imageView)
InnerStrokeTransformation 的具体实现步骤如下:
1 - 创建 InnerStrokeTransformation 类,继承自 BitmapTransformation。并新增 3 个变量:width: Float, color: Int, radius: Float。分别代表 描边宽度,描边颜色,描边的圆角半径
2 - 实现 updateDiskCacheKey 方法、equals 方法和 hashCode 方法,方便 Glide 管理缓存。其实现可以参考 Glide 内置的 Transformation。
3 - 实现 transform 方法,添加描边。
接下来我们一个个讲。
实现 updateDiskCacheKey、equals 和 hashCode 方法
这一步非常简单,模仿 Glide 内的实现即可,没啥好讲的。ID 弄个可以唯一标识本类的字符串即可。
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.security.MessageDigest
class InnerStrokeTransformation(
var width: Float, // 描边宽度
var color: Int, // 描边颜色
var radius: Float // 描边的圆角半径
) : BitmapTransformation() {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID.toByteArray(CHARSET))
}
override fun equals(other: Any?): Boolean {
return other is InnerStrokeTransformation
}
override fun hashCode(): Int {
return ID.hashCode()
}
companion object {
private const val TAG = "InnerStrokeTransformation"
private const val VERSION = 1
private const val ID = "com.test.android.glide.transformation.$TAG.$VERSION"
}
}
添加描边
添加描边的步骤如下图所示:
可以看出,添加描边主要是两个步骤:绘制原图像、绘制描边。
要绘制特定形状的原图像(比如带圆角的图像),我们可以为 paint 添加 BitmapShader。Paint 着色器的讲解,可以参考这片文章: HenCoder Android 开发进阶: 自定义 View 1-2 Paint 详解
val paint = Paint().apply {
this.isAntiAlias = true
this.setShader(
BitmapShader(bmp, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
setLocalMatrix(
Matrix().apply {
setScale(outWidth * 1f / bmpWidth, outHeight * 1f / bmpHeight)
}
)
}
)
}
为 paint 添加了 BitmapShader 后,我们可以使用Canvas.drawRoundRect(float left, float top, float right, float bottom, float rx, float ry,@NonNull Paint paint)
绘制原图像。
drawRoundRect(0f, 0f, outWidth.toFloat(), outHeight.toFloat(), mRadius, mRadius, paint)
绘制描边和绘制原图像调用的方法一样,只是 paint 不一致,并且绘制的位置不一样。假设原图像的绘制区域为 (0, 0) 到 (width, height),考虑到描边宽度,则描边的绘制范围为 (strokeWidth, strokeWidth) 到 (width - strokeWidth, height - strokeWidth)。即从 0 开始绘制,描边就是外描边;从 strokeWidth 开始绘制,描边就是内描边了。
结合上面的讲解,最终的源代码如下:
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Shader
import androidx.core.graphics.applyCanvas
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.security.MessageDigest
/**
* 为图片添加内描边
* */
class InnerStrokeTransformation(
var width: Float, // 描边宽度
var color: Int, // 描边颜色
var radius: Float // 描边的圆角半径
) : BitmapTransformation() {
companion object {
private const val TAG = "InnerStrokeTransformation"
private const val VERSION = 1
private const val ID = "com.test.android.glide.transformation.$TAG.$VERSION"
}
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID.toByteArray(CHARSET))
}
override fun equals(other: Any?): Boolean {
return other is InnerStrokeTransformation
}
override fun hashCode(): Int {
return ID.hashCode()
}
override fun transform(
pool: BitmapPool,
toTransform: Bitmap,
outWidth: Int,
outHeight: Int
): Bitmap {
val mWidth = this.width
// 浮点型的误差值设为 0.05,小于 0.05 则视作 0
if (mWidth < 0.05f || color == 0) {
// 未设置,不做转换
return toTransform
}
val sWidth = toTransform.width
val sHeight = toTransform.height
val mRadius = radius
return pool.get(sWidth, sHeight, Bitmap.Config.ARGB_8888).apply {
setHasAlpha(true)
applyCanvas {
// 绘制 bmp
val paint = generatePaint(toTransform, sWidth, sHeight, outWidth, outHeight)
drawRoundRect(
0f,
0f,
outWidth.toFloat(),
outHeight.toFloat(),
mRadius,
mRadius,
paint
)
// 绘制内描边
val borderPaint = obtainBorderPaint(mWidth)
drawRoundRect(
mWidth,
mWidth,
outWidth - mWidth,
outHeight - mWidth,
mRadius,
mRadius,
borderPaint
)
this.setBitmap(null)
}
}
}
private fun obtainBorderPaint(mStrokeWidth: Float) = Paint().apply {
this.style = Paint.Style.STROKE
this.strokeWidth = mStrokeWidth
this.color = this@InnerStrokeTransformation.color
this.isAntiAlias = true
}
private fun generatePaint(
bmp: Bitmap,
bmpWidth: Int,
bmpHeight: Int,
outWidth: Int,
outHeight: Int
) = Paint().apply {
this.isAntiAlias = true
this.setShader(
BitmapShader(bmp, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
setLocalMatrix(
Matrix().apply {
setScale(outWidth * 1f / bmpWidth, outHeight * 1f / bmpHeight)
}
)
}
)
}
}
使用 Glide 按比例裁剪图片
要使用 Glide 按比例裁剪图片,我们仍然需要使用 Glide 提供的图片变换(Transformation)功能。其实现步骤与上文的 InnerStrokeTransformation 类似,代码的主要不同在于 transform 方法。 其代码如下:
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import androidx.core.graphics.applyCanvas
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.security.MessageDigest
import kotlin.math.min
import kotlin.math.roundToInt
class CutTransformation(
private val ratio: Float = -1f, // 裁剪比例
) : BitmapTransformation() {
override fun transform(
pool: BitmapPool,
toTransform: Bitmap,
outWidth: Int,
outHeight: Int
): Bitmap {
// 使用比例裁剪
return cutWithRatio(pool, toTransform, outWidth, outHeight)
}
private fun cutWithRatio(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
if (ratio < 0.05f) {
// 浮点型不准确,使用 0.05f 表示 0f,比例为负,表示不用比例裁剪
return toTransform
}
val srcWidth = toTransform.width
val srcHeight = toTransform.height
// 水平裁剪,按比例计算新的宽度,高度不变
val targetWidth = min((srcWidth * ratio).roundToInt(), srcWidth)
val targetHeight = srcHeight
// 获取 bitmap
return pool.get(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).applyCanvas {
val paint = Paint(PAINT_FLAGS)
// 将 toTransform 的内容从左上角开始绘制到新 Bitmap 上,新 Bitmap 的尺寸为 (targetWidth, targetHeight)
drawBitmap(toTransform, 0f, 0f, paint)
setBitmap(null)
}
}
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID.toByteArray(CHARSET))
}
override fun equals(other: Any?): Boolean {
return other is CutTransformation
}
override fun hashCode(): Int {
return ID.hashCode()
}
companion object {
private const val TAG = "CutTransformation"
private const val VERSION = 1
private const val ID = "com.test.android.glide.transformation.$TAG.$VERSION"
private const val PAINT_FLAGS = Paint.DITHER_FLAG or Paint.FILTER_BITMAP_FLAG
}
}
为 SeekBar 设置 progressDrawable
要得到裁剪的图片,我们可以使用 Glide 加载,加载的代码如下:
// 加载图片并裁剪,将 Glide 的异步加载转为协程
private suspend fun loadDrawable(
activity: Activity,
resId: Int,
// 音频视觉段的裁剪比例:0.196f、0.468f、0.736f、1.0f
ratioLevel: Float,
) = withContext(Dispatchers.IO) {
suspendCoroutine<Drawable?> { continuation ->
try {
Glide.with(activity)
.asDrawable()
.load(resId)
.apply(
RequestOptions().run {
this.transform(CutTransformation(ratio = ratioLevel))
}
)
.into(object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
continuation.resume(resource)
}
override fun onLoadFailed(errorDrawable: Drawable?) {
continuation.resume(null)
}
override fun onLoadCleared(placeholder: Drawable?) {
continuation.resume(null)
}
})
} catch (e: Exception) {
CLog.e(TAG, e)
continuation.resume(null)
}
}
}
要为 SeekBar 设置 progressDrawable。我们需要得到一个 LayerDrawable,这个 LayerDrawable 包含两个图层的 ClipDrawable,分为代表 SeekBar 的全部进度(backgroundDrawable)和已播放进度(mProgressDrawable)。并将全部进度的 id 设置为 android.R.id.background,将已播放进度的 id 设置为 android.R.id.progress。
至于为什么 LayerDrawable 包含的必须是 ClipDrawable。这是因为 SeekBar 在改变进度时,会为其对应的 mProgressDrawable 设置 level。这个 level 是在 Drawable 中定义的。虽然 Drawable 的子类都可以使用,但是在官方提供的实现中,只有 ClipDrawable 会根据 level 取计算一个比例,并按照该比例裁剪 Drawable。比例的计算方式为:(level / 10000),即 ClipDrawable 的最大 level 为 10000(一万)。只有按比例裁剪 Drawable,SeekBar 才能呈现出进度不断变化的样式。
val height = R.dimen.dp_18.dimenRes
activity.lifecycleScope.launch(Dispatchers.Main) {
val backgroundDeferred = async { loadDrawable(activity, backgroundRes, ratioLevel) }
val progressDeferred = async { loadDrawable(activity, progressRes, ratioLevel) }
// 异步加载两个 Drawable
val backgroundDrawable = backgroundDeferred.await() ?: return@launch
val mProgressDrawable = progressDeferred.await() ?: return@launch
val drawableList = arrayOf(
ClipDrawable(backgroundDrawable, Gravity.START, ClipDrawable.HORIZONTAL).apply {
// 背景进度条不用裁剪
level = 10000
},
ClipDrawable(mProgressDrawable, Gravity.START, ClipDrawable.HORIZONTAL)
)
LayerDrawable(drawableList).apply {
setId(0, android.R.id.background)
setId(1, android.R.id.progress)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setLayerHeight(0, height)
setLayerHeight(1, height)
}
// 将最终得到的 LayerDrawable 赋值给 LiveData
progressDrawable.value = this
}
}
得到结果后,我们就可以为 SeekBar 设置 progressDrawable 了。下面的代码使用到了 DataBinding。我们将 ViewModel 中定义的 progressDrawable LiveData 传递给 xml。如果为 Binding 设置了 LifecyclerOwner,则当 LiveData 的值改变时,Binding 会自动刷新。下面的代码中 progressWidth、progressDrawable、position 都是 LiveData,一个代表 SeekBar 的宽度,一个代表 SeekBar 的 progressDrawable,一个代表 SeekBar 的当前进度。播放时,我们只要间隔一定时间(比如 20 ms)改变 position,SeekBar 的进度就会不断刷新。
<SeekBar
android:id="@+id/seek_bar"
android:layout_width="@{vm.progressWidth, default=wrap_content}"
android:layout_height="@dimen/dp_18"
android:minWidth="@{vm.progressWidth}"
android:paddingStart="@dimen/dp_1"
android:paddingEnd="@dimen/dp_1"
android:layout_marginStart="@dimen/dp_8"
android:progressDrawable="@{vm.progressDrawable}"
android:thumbTint="@{vm.position == 0 ? @color/transparent : @color/color_0F2128}"
android:thumb="@drawable/voice_seekbar_thumb"
android:splitTrack="false"
android:background="@null"
android:max="100000"
android:progress="@{vm.position}"
tools:visibility="visible"
/>
- android:progressDrawable 用于设置进度条样式
- android:thumb 用于设置进度指针样式
- android:thumbTint 用于设置进度指针的颜色
- android:splitTrack=“false” 用于处理自定义进度样式时可能出现的背景进度显示不全(被裁剪)的问题
- android:background="@null" 当自定义进度样式时,background 需要被清空
图片复用导致加载出错问题处理
上面生成 progressDrawable 的代码会有问题,最终的修复版还是得自己手绘,代码如下:
private fun generateFinalDrawable(backgroundRes: Int,mProgressRes: Int) {
if (backgroundRes == 0 || mProgressRes == 0) {
return
}
val height = R.dimen.dp_18.dp2px
val ratioLevel = 0.273 // 裁剪原图的比例
val bg = getClipDrawable(backgroundRes, ratioLevel)?.apply {
level = 10000
} ?: return
val pg = getClipDrawable(mProgressRes, ratioLevel) ?: return
LayerDrawable(arrayOf(bg, pg)).apply {
setId(0, android.R.id.background)
setId(1, android.R.id.progress)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setLayerHeight(0, height)
setLayerHeight(1, height)
}
progressDrawable = this
}
private fun getClipDrawable(srcDrawable: Int, ratioLevel: Float): ClipDrawable? {
val res = GlobalApplicationAgent.getApplication().resources
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val srcBmp = BitmapFactory.decodeResource(res, srcDrawable) ?: return null
val target = Bitmap.createBitmap(
(srcBmp.width * ratioLevel).toInt(),
srcBmp.height,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(target)
canvas.drawBitmap(srcBmp, 0f, 0f, paint)
if (!srcBmp.isRecycled) {
srcBmp.recycle()
}
return ClipDrawable(BitmapDrawable(res, target), Gravity.START, ClipDrawable.HORIZONTAL)
}
}
kotlin 代码实现多状态 Drawable
xml 中的多状态 Drawable 可以这么编写:
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/img_liked" android:state_selected="true" />
<item android:drawable="@drawable/img_unlike" />
</selector>
其等价的 kotlin 代码实现为:
private fun realGenerateLikeIcon(unlikeIcon: Drawable, likedIcon: Drawable): Drawable {
return StateListDrawable().apply {
addState(intArrayOf(android.R.attr.state_selected), likedIcon)
addState(intArrayOf(), unlikeIcon)
}
}