首先看需要做成的效果,如下所示😻:
 
如上所示:我们需要做到如下效果,在图片加载出来之前或者加载失败需要展示一个自定义的背景色+文字的样式,加载完成之后显示对应的图片,且包含动画效果。 由于:需要项目本身包含有:com.github.open-android:RoundedImageView:v1.0.0 库。 所以直接基于RoundedImageView来自定义HeadImageView,这样就不需要处理圆形了,只需要关注背景色、文字、动画以及可配置性就可以了。 现在就可以简单的,我们可以暂时写这么多:
class HeadImageView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
RoundedImageView(context, attrs, defStyle) {
}
分析:应该怎么做🧐?
- 定义可配置的属性:我们生成的背景图由背景色和文字组成,另外是否显示自定义绘制的样式需要有一个开关,所以我们这里就定义:文字颜色、文字大小、背景色以及是否显示自定义的样式。
- 绘制背景色
- 绘制文字
- 增加动画效果
- 考虑全局可配置性
- 扫尾处理(recyclerView图片错乱、glide加载动画去除等)
下面,就按照以上6步,简简单单的撸起来吧 😁
开动(为了看起来舒服一点,这里就不放注释了。最后会提供git源码demo)😉
1.定义可配置的属性👀
新建attrs.xml资源文件,增加对应的属性
<resources>
<declare-styleable name="HeadImageView">
<attr name="font_color" format="color" />
<attr name="font_size" format="dimension" />
<attr name="background_font_color" format="color"/>
<attr name="is_default_pic" format="boolean"/>
</declare-styleable>
</resources>
在HeadImageView中,对属性进行解析,核心代码如下
@ColorInt
var fontColor: Int = -1
set(value) {
field = value
invalidate()
}
@Px
var fontSize: Float = 0.0F
set(value) {
field = value
invalidate()
}
var isDefaultPic: Boolean = false
set(value) {
field = value
invalidate()
}
@ColorInt
private var backgroundFontColorInt: Int = -1
init {
val attributeSet = context.obtainStyledAttributes(attrs, R.styleable.HeadImageView)
fontColor = attributeSet.getColor(
R.styleable.HeadImageView_font_color,
ContextCompat.getColor(context, R.color.white)
)
fontSize =
attributeSet.getDimension(R.styleable.HeadImageView_font_size, 16F.spToPx)
backgroundFontColorInt = attributeSet.getColor(
R.styleable.HeadImageView_background_font_color,
Color.parseColor("#2E6BE5")
)
isDefaultPic = attributeSet.getBoolean(R.styleable.HeadImageView_is_default_pic, false)
attributeSet.recycle()
}
其他的初始化操作可自行查看源码噢,比如paint、动画控制相关等,这里就不一一列举了🐷。
2.绘制背景色👀
我们需要考虑圆形以及正方形不同的绘制 需要判断当前是否是圆形,根据RoundedImageView已经提供的方法isOval()来进行判断
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
drawDefaultPic(canvas!!)
}
private fun drawDefaultPic(canvas: Canvas) {
mPaint.alpha = 255
mPaint.color = backgroundFontColorInt
if (isOval) {
canvas.drawCircle(width / 2F, height / 2F, width / 2F, mPaint)
} else {
canvas.drawRect(0F, 0F, width.toFloat(), height.toFloat(), mPaint)
}
}
3.绘制文字👀
这里我需要关注的是,怎么样让文字居中显示绘制 先看一张图🤩。  上面的参数很多是吧,是不是感觉有点懵😫,不慌,只需要关注这几个参数: base:字符基线,这个就比如我们规定,在一个100*100的空间里,在点(50,50)的位置绘制abcdefg,那么abcdef这几个字母全在横坐标50的上方,而g则上半部分在上方,下半部分在下方。因为绘制坐标是基于base来的。 descent:字符最低点到base的推荐距离 ascent:字符最高点到base的推荐距离 bottom:字符最低点到base的最大距离
以上便是需要知道的参数,所以怎么保证,字体绘制在最中间呢? 水平居中 定义一个矩形,设置文字对其方式设置为居中对齐,然后设置x轴坐标为矩形的中心,那水平方向就居中了。 垂直居中 垂直的需要计算出从base到垂直水平点的偏移量,那么偏移量怎么计算呢? descent - ascent 就是字体整体推荐的高度,除以2就是推荐高度的一半。考虑还有字体最低点,可以减去bottom 核心实现代码如下😋
private fun onDrawFont(canvas: Canvas) {
val rect = Rect(0, 0, width, height)
mPaint.color = fontColor
mPaint.textSize = fontSize
mPaint.textAlign = Paint.Align.CENTER
val baseLine = mPaint.fontMetrics.let {
val distance = (it.descent - it.ascent) / 2 - it.bottom
rect.centerY() + distance
}
canvas.drawText(displayText, rect.centerX().toFloat(), baseLine, mPaint)
}
其实这个时候基本绘制就完成了,加上控制开关的话,就可以使用了😄。
if (isDefaultPic) {
drawDefaultPic(canvas)
}
em,这样的话基本就可以使用了,如果不需要动画等其他的效果,上面的代码粘贴粘贴基本就可以了。
4.增加动画效果👀
这怎么做到呢?
思考一下,到可以使用属性动画+离屏绘制+图层混合完成。 属性动画:可以简单的理解为,动画间隔时间内不断的更改对应属性的值,重绘。实现动画效果。 离屏绘制:简单理解使用全新的canvas,绘制完成后,盖在了原来的canvas上面。 图层混合:将所绘制的像素与canvas中对应位置的像素按照一定规则进行混合,形成新的像素值,最终更新canvas中最终显示的像素值。
这几个概念呢?如果有不清楚的,可以简单Google了解一下哈,这里不做引申了,直接看怎么使用吧😋。
大致的实现原理呢😃? 首先背景色加文字是通过canvas直接绘制出来,而图层混合有一个模式可以让绘制的交际变透明。就需要在绘制一块,而这一块和上面的绘制变成交际,就会透明了,实际的图像就会显示。自定义一个属性通过属性动画不断变化,然后达到控制第二块绘制区域的效果,实现透明区域的缩放。以上操作均在离屏绘制中完成。考虑到圆形和正方形两种情况,所以针对这两种情况分别做了不同的绘制。
下面看看绘制的代码核心实现吧😍。
val saveLayerId = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), mPaint)
mPaint.color = backgroundFontColorInt
if (isOval) {
canvas.drawCircle(width / 2F, height / 2F, width / 2F, mPaint)
} else {
canvas.drawRect(0F, 0F, width.toFloat(), height.toFloat(), mPaint)
}
onDrawFont(canvas)
mPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
mPaint.color = ContextCompat.getColor(AppUtil.application, R.color.white)
if (isOval) {
canvas.drawCircle(width / 2F, height / 2F, (width / 2) * (1 - animationControl), mPaint)
} else {
canvas.drawRect(
(width / 2F) * animationControl,
(height / 2F) * animationControl,
(width / 2F) + ((1 - animationControl) * (width / 2F)),
(height / 2F) + ((1 - animationControl) * (height / 2F)),
mPaint
)
}
mPaint.xfermode = null
canvas.restoreToCount(saveLayerId)
下面在看看动画的代码核心实现吧😍。因为把动画放到了,view里面所以使用软引用持有(内存不够,大不了就被回收了嘛,非必须)。
var animationControl: Float = 1F
set(value) {
field = if (animationControl < 0) 0F else if (animationControl > 1) 1F else value
invalidate()
}
private var objectAnimatorSoft: SoftReference<ObjectAnimator?>? = null
if (objectAnimatorSoft == null) {
objectAnimatorSoft = SoftReference(ObjectAnimator.ofFloat(this, "animationControl", 1F, 0F))
}
objectAnimatorSoft?.get()?.let {
it.duration = animatorTime
it.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator?) {
}
override fun onAnimationEnd(animation: Animator?) {
this@HeadImageView?.isDefaultPic = false
}
override fun onAnimationCancel(animation: Animator?) {
this@HeadImageView?.isDefaultPic = false
}
override fun onAnimationRepeat(animation: Animator?) {
}
})
}
提供一个方法等图片加载出来后,取消默认图的显示。isDefaultPic 的set里面调用了 invalidate() 所以会直接重绘了。
fun cancelDefaultPic() {
if (isDefaultPic) {
if (mBackgroundDrawable != null) {
isDefaultPic = false
return
}
if (enableAnim) {
objectAnimatorSoft?.get()?.start()
} else {
isDefaultPic = false
}
}
}
好像就,差不多了。。。 下面进行其他的扫尾工作
5.考虑全局可配置性👀
上面的颜色以及对应的显示字体都是根据content自动生成的,所以需要一个默认的算法,同时也允许提供定制算法。 上面的默认图是背景色+文字。也有可能是图片呀 , 怎么破😃 可自定设置动画时常、判断启用动画
综上:我们需要额外提供一个object类,用来注册这些东西。太细节就不说了,简单展示一下默认提供的计算字体和背景色的方法以及默认图是图片的问题。
背景色计算😃
private fun defaultObtainImageFontBackgroundColorInt(name: String): Int {
val bgColors = intArrayOf(
Color.parseColor("#FA7976"),
Color.parseColor("#B7A0F1"),
Color.parseColor("#6890F3"),
Color.parseColor("#57BAB3"),
Color.parseColor("#61C7F1"),
Color.parseColor("#FAA77D")
)
return name.toByteArray().fold(0) { acc: Int, byte: Byte ->
acc + byte
}.let {
bgColors[it.absoluteValue % bgColors.size]
}
}
取内容的后两个字显示😃
private fun defaultObtainImageFontText(name: String): String {
val length = name.length
return if (length > 2) {
name.substring(length - 2, length)
} else {
name
}
}
至于显示默认背景为图片就更简单了,直接在object中防止一个Drawable,绘制的实现先从object中取,取不到的则继续正常的背景绘制,不然就直接绘制drawable就好了🙂。
if (mBackgroundDrawable != null) {
mBackgroundDrawable?.draw(canvas)
return
}
drawDefaultPic(canvas)
6.扫尾处理(recyclerView图片错乱、glide加载动画去除等)👀
写工具方法加载图片了,这里使用glide进行网络图片的加载。 设置tag防止图片错乱 使用自定义ViewTarget,手动设置图片,去除动画
fun HeadImageView.loadImage(url: String, name: String) {
val text = HeadImageViewHelp.obtainImageFontText(name)
val colorInt = HeadImageViewHelp.obtainImageFontBackgroundColorInt(name)
val tag = getTag(R.id.imageload_tag)
if (tag == null || !TextUtils.equals(tag.toString(), url)) {
setBackgroundFontColorAndText(colorInt, text)
}
Glide.with(this).load(url).into(ImageViewTarget(this))
}
class ImageViewTarget(ivPic: HeadImageView) : CustomViewTarget<HeadImageView, Drawable>(ivPic) {
override fun onLoadFailed(errorDrawable: Drawable?) {
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
this.view.setImageDrawable(resource)
this.view.cancelDefaultPic()
}
override fun onResourceCleared(placeholder: Drawable?) {
}
}
至此:基本就完成了,使用的话只需要调用loadImage方法就可以了。
源码地址:觉得不错给个star吧!🧐 https://github.com/zhangnangua/grocery-store/tree/master/HeadImageViewDemo
|