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:

  1. Overrides onMeasure to calculate its own size
  2. Text rendering
  3. Image loading display is circular
    • Optimizations related to image loading (e.g. size, caching)
  4. 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