The same effect that was achieved in the Demo with ObjectAimator can be achieved with a View. Demo project address: Click here
The following problems need to be solved to implement this custom View:
- Overrides onMeasure to calculate its own size
- Text rendering
- Image loading display is circular
- Optimizations related to image loading (e.g. size, caching)
- Animation effects
- Message appears
- The message is pushed up
- News closed
In this article we will implement the basic drawing of a message, that is, the first three (except for image caching) and the animation effect in the next article.
The basic data structure of notification messages consists of three parts: avatar, nickname, status (enter/exit); To expand, we define a data type to hold:
data class Message(
val avatar: String,
val nickname: String,
val status: Int.// 1=join,2=leave
val shader: BitmapShader? = null.val bitmap: Bitmap? = null
)
Copy the code
Because only one message will be drawn for now, we will store the data in the member variable mMessage for now.
OnMeasure: If you want to measure your own size, you need to know what takes up space, right? Avatar, nickname, status (entry/exit text), and the space between them.
Look at the diagram and feel that the height is calculated based on the height of the prompt text. And nicknames can be up to six characters long (a three-dot ellipsis is roughly the width of a word).
The height of each message = the height of the status text in and out + the padding of the text. This View can hold up to two notifications, so the height of the View = the height of the two messages + the padding between them. View width = maximum number of message characters (I counted 11) + head diameter + various padding.
With the width and height defined, the code is ready to write:
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 = 0
private var avatarHeight = 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// At most two lines of the message, calculate the height of the line first, plus the padding between the notification is the total height
messageHeight = fontSize + statusTextPadding.shl(1)
avatarHeight = messageHeight - avatarPadding.shl(1)
val width = 11/* A maximum of 11 characters */ * 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)
)
/* The above variables, such as maximum number of words, word spacing, and various padding, would be better to use dependency injection */
}
Copy the code
Start by implementing a simple image loading function, which can be implemented using open source libraries. I have written a simple HTTP loading function here.
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()
}
Copy the code
Then you can achieve the drawing method, drawing order is: background – text – picture; Since the message length appears to be getting longer (actually the maximum length is already set in the onMeasure), the message width needs to be calculated again.
override fun onDraw(canvas: Canvas) {
if (mMessage == null)
return
val msg = mMessage!!
paint.textSize = fontSize.toFloat()
paint.color = Color.parseColor("#F3F3F3")
// The 0 on the y axis of the font is not at the top or bottom, but based on something called baseline
// So you need to calculate the distance from the baseline to the actual center first and add the difference when drawing
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) "Into the studio." else "Exit the studio."
val nickname = if (msg.nickname.length > 5) msg.nickname.substring(0.5) + "..." else msg.nickname
// The statusTextWidth measurement can be saved for initialization, since the length is fixed, there is no need to measure every time.
val statusTextWidth = paint.measureText(statusText)
val nicknameWidth = paint.measureText(nickname)
// Calculate how far this message is actually from the left side of the View
// View width - messageLeft = message width
val messageLeft = measuredWidth - nicknameWidth - statusTextWidth - statusTextPadding * 3 - avatarPadding.shl(1) - avatarHeight
// Draw the background
// Add a left semicircle
path.addArc(messageLeft, 0f, messageLeft + avatarPadding + avatarHeight.toFloat(), messageHeight.toFloat(), 90f.180f)
// Add a rectangle to connect to the circle above
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())
/ / fill
paint.style = Paint.Style.FILL
paint.color = Color.parseColor("# 434343")
canvas.drawPath(path, paint)
// Draw text in and out of state
paint.color = Color.WHITE
canvas.drawText(statusText, measuredWidth - statusTextWidth - statusTextPadding, messageHeight.shr(1) + fontCenterOffset, paint)
// Draw the nickname
paint.color = Color.parseColor("#BCBCBC")
canvas.drawText(nickname, measuredWidth - statusTextWidth - statusTextPadding.shl(1) - nicknameWidth, messageHeight.shr(1) + fontCenterOffset, paint)
// Draw a circular image, using BitmapShadermsg.bitmap? .let {// add shader and the image is fixed at 0,0
// So I moved the canvas directly here, finished before painting and then restored
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()
}
}
Copy the code
Finally, add a method to add data, and a notification without animation is complete.
fun addMessage(avatar: String, nickname: String) {
mMessage = Message(avatar, nickname, 1)
// Draw the text first without waiting for the image, otherwise the image is too large or the server latency is too high to display the notification in time
invalidate()
loadImage(avatar) { bitmap, success ->
if(! success)return@loadImage
valshader = BitmapShader(bitmap!! , Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) mMessage? .let { it.bitmap = bitmap it.shader = shader } }// loadImage already maintains the thread switch itself
invalidate()
}
Copy the code
In the next article, we’ll implement two more notification messages and animate them