写点什么

Android 技术分享| 【自习室】自定义 View 代替通知动画(1)

发布于: 刚刚


在 Demo 中通过 ObjectAimator 实现的效果,使用一个 View 同样可以实现。<br>Demo 项目地址:点击这里


实现这个自定义 View 需要解决的问题:


  1. 重写 onMeasure 计算自己的大小

  2. 文本绘制

  3. 图片加载展示为圆形

  4. 图片加载涉及到的优化(如大小、缓存)

  5. 动画效果

  6. 消息出现

  7. 消息被顶上去

  8. 消息关闭


本篇文章我们先实现一条消息的基本绘制,也就是前三条(除图片缓存)下一篇文章中再加上动画效果。


通知消息基本数据结构由 3 个部分组成:头像、昵称、状态(进入/退出);为了便于拓展,我们定义一个数据类型来保存:


data class Message(    val avatar: String,    val nickname: String,    val status: Int,// 1=join,2=leave    val shader: BitmapShader? = null,    val bitmap: Bitmap? = null)
复制代码


因为暂时只实现一条消息的绘制,我们暂时用成员变量mMessage将数据保存起来。


完成 View 的测量(onMeasure):<br>想要测量自身大小,得要先知道自己都有什么东西占地方,对吧。<br>头像、昵称、状态(进入/退出的提示文字),这些再加上它们之间的间距。


观察一下这个示意图,感觉高度以提示文本的高度为基准来计算就可以了。并且昵称最多只有 6 个字(三个点的省略号可以粗略算是一个字的宽度)


那么每条 message 的高度=进出状态文本的高度+文本上下 padding。<br>本 View 最多容纳两条通知,所以 View 的高度=两条 message 的高度+它们之间的 padding。<br>View 的宽度=本条 message 最多的字符数(我数了一下一共 11 个)+头像直径+各种 padding。<br>


宽高都明确了,代码也就好写了:


private val fontSize = context.resource.getDimensionPixelSize(R.dimen.sp12)private val statusTextPadding = context.resource.getDimensionPixelSize(R.dimen.dp5)private val avatarPadding = context.resource.getDimensionPixelSize(R.dimen.dp2)private val messagePadding = context.resource.getDimensionPixelSize(R.dimen.dp8)
private var messageHeight = 0private var avatarHeight = 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // 提示消息最多两行,先计算好一行的高度,加上通知之间的padding就是总高度 messageHeight = fontSize + statusTextPadding.shl(1) avatarHeight = messageHeight - avatarPadding.shl(1) val width = 11/*最多一共11个字*/ * fontSize + avatarPadding.shl(1) + statusTextPadding.shl(1) + avatarHeight val height = messageHeight.shl(1) + messagePadding
setMeasuredDimension( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) ) /* 以上的变量,如最多几个字、字间距、各种padding,改为依赖注入的方式会更好 */}
复制代码


先实现一个简单的图片加载功能,可以使用开源库来实现,我这里写了个简单的 http 加载。


private fun loadImage(uri: String, callback: (BitmapShader?, Boolean) -> Unit) {    Thread {        try {            var http = URL(uri).openConnection() as HttpURLConnection            http.connectTimeout = 5000            http.readTimeout = 5000            http.requestMethod = "GET"            http.connect()                                                                                                                              var iStream = http.inputStream            val options = BitmapFactory.Options()            options.inJustDecodeBounds = true                                                                                                                              BitmapFactory.decodeStream(iStream, null, options)            val outWidth = options.outWidth            val outHeight = options.outHeight                                                                                                                              val minDimension = outWidth.coerceAtMost(outHeight)            options.inSampleSize = floor((minDimension.toFloat() / avatarHeight).toDouble()).toInt()            options.inPreferredConfig = Bitmap.Config.RGB_565            options.inJustDecodeBounds = false                                                                                                                              iStream.close()                                                                                                                              http = URL(uri).openConnection() as HttpURLConnection            http.connectTimeout = 5000            http.readTimeout = 5000            http.requestMethod = "GET"            http.connect()            iStream = http.inputStream                                                                                                                              val bitmap = BitmapFactory.decodeStream(iStream, null, options) ?: throw IOException("bitmap is null")            iStream.close()                                                                                                                              post { callback.invoke(bitmap, true) }        } catch (e: IOException) {            callback.invoke(null, false)            e.printStackTrace()        } catch (e: SocketTimeoutException) {        }    }.start()}
复制代码


接下来就可以实现绘制方法了,绘制顺序为:背景——文本——图片;由于消息长短看起来像是变长的(实际上在 onMeasure 里已经定好了最大长度),所以要再计算一次这条 message 的宽度。<br>


override fun onDraw(canvas: Canvas) {    if (mMessage == null)        return                                                                                                                                                             val msg = mMessage!!    paint.textSize = fontSize.toFloat()    paint.color = Color.parseColor("#F3F3F3")
// 字体的y轴的0并不是最上方或最下方,而是基于一个叫baseline的东西 // 所以需要先计算出baseline距离实际中心点的距离,在绘制时加上这个差值 val metrics = paint.fontMetrics // 计算公式为(bottom - top) / 2 - bottom // = abs(top) / 2 - bottom / 2 // = (abs(top) - bottom) / 2 val fontCenterOffset = (abs(metrics.top) - metrics.bottom) / 2 val statusText = if (msg.status == 1) "进入直播间" else "退出直播间" val nickname = if (msg.nickname.length > 5) msg.nickname.substring(0, 5) + "..." else msg.nickname
// statusTextWidth的测量可以放到初始化的时候,反正长度固定,没必要每次都测量。 val statusTextWidth = paint.measureText(statusText) val nicknameWidth = paint.measureText(nickname) // 计算这条消息实际与View左边距离多远 // view宽度 - messageLeft = message的宽度 val messageLeft = measuredWidth - nicknameWidth - statusTextWidth - statusTextPadding * 3 - avatarPadding.shl(1) - avatarHeight
// 绘制背景 // 添加一个左侧的半圆 path.addArc(messageLeft, 0f, messageLeft + avatarPadding + avatarHeight.toFloat(), messageHeight.toFloat(), 90f, 180f) // 添加一个长方形,与上面的圆连接起来 path.moveTo(messageLeft + avatarHeight.shr(1).toFloat(), 0f) path.lineTo(measuredWidth.toFloat(), 0f) path.lineTo(measuredWidth.toFloat(), messageHeight.toFloat()) path.lineTo(messageLeft + avatarHeight.shr(1).toFloat(), messageHeight.toFloat())
// 填充 paint.style = Paint.Style.FILL paint.color = Color.parseColor("#434343") canvas.drawPath(path, paint)
// 绘制进出状态的文字 paint.color = Color.WHITE canvas.drawText(statusText, measuredWidth - statusTextWidth - statusTextPadding, messageHeight.shr(1) + fontCenterOffset, paint)
// 绘制昵称 paint.color = Color.parseColor("#BCBCBC") canvas.drawText(nickname, measuredWidth - statusTextWidth - statusTextPadding.shl(1) - nicknameWidth, messageHeight.shr(1) + fontCenterOffset, paint)
// 绘制圆形图片,这里用BitmapShader实现 msg.bitmap?.let { // 加了shader之后图片就固定在0,0的位置了 // 所以我这里直接移动了画布,绘制前完成后再恢复回去 canvas.save() paint.shader = msg.shader val translateOffset = (messageHeight - it.width).shr(1) canvas.translate(messageLeft + translateOffset, translateOffset.toFloat()) canvas.drawCircle(it.width.shr(1).toFloat(), it.width.shr(1).toFloat()/*messageHeight.shr(1).toFloat()*/, avatarHeight.shr(1).toFloat(), paint) paint.shader = null canvas.restore() }}
复制代码


最后增加一个添加数据的方法,一个没有动画效果的通知就完成了。


fun addMessage(avatar: String, nickname: String) {    mMessage = Message(avatar, nickname, 1)    // 这里先将文本绘制上去,不等待图片,否则图片过大或服务器延迟过高会导致通知显示不及时    invalidate()    loadImage(avatar) { bitmap, success ->        if (!success)            return@loadImage
val shader = BitmapShader(bitmap!!, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) mMessage?.let { it.bitmap = bitmap it.shader = shader } }
// loadImage已经自己维护好线程切换了,这里直接主线程调用更新即可 invalidate()}
复制代码


下篇文章我们再来实现两条通知消息并加上动画效果

发布于: 刚刚阅读数: 2
用户头像

实时交互,万物互联! 2020.08.10 加入

实时交互,万物互联,全球实时互动云服务商领跑者!

评论

发布
暂无评论
Android技术分享| 【自习室】自定义View代替通知动画(1)