Android Custom Views — 1 (Matrix & PorterDuffXfermode )

Muhammet Küdür
12 min readOct 3, 2023

--

In this article series, I have translated my mentor Senior Android Developer Halil Özcan’s medium article titled “Android Custom Views — 1 (Matrix & PorterDuffXfermode)”. All technical information and details in the article belong to him. To access the Turkish medium article:

Hello, I am here with a brand new article. In this article, I will talk about Canvas API, Matrix and PorterDuffXfermode under the title of CustomView.

First of all, “What is Custom View?” Let’s answer this question;

Generally, when developing Android applications, we create our user interfaces using components such as ImageView, TextView, CardView that the Android framework offers us. However, in applications with different concepts and providing a different interface experience, we may need to write our own components as these components fall short. In this case, the component written from scratch or created by adding new features to ready-made components is called “Custom View”.

Custom Views can be created in two ways;

  • By inheriting from an existing component (Button, ImageView, etc.) or by inheriting the View class from scratch.
  • By creating a CompoundView (combining two or more existing Views). For example, let’s say you have an Avatar structure consisting of an ImageView and TextView. If you use this on most pages, instead of copying and pasting this layout every time, you can combine these two views in a view called AvatarView and use it on all pages. (We will talk about this subject in our later articles)

In this article, we will show an image containing a person to the user on the screen in the View we will write, without losing its quality and aspect ratio. Then, we will mask that image using PorterDuffXferMode so that only the human part is visible. After the masking process, we will place a background image behind the segmented image without distorting the aspect ratio.

The main image we will use
The segmented image we will use
Background Image

After answering the questions “What is a Custom View and How to Create It”, we can now start getting our hands dirty to create our own View.

First, we will start by inheriting from the View class. Instead of creating all the constructors from scratch as in the Java programming language, we will override all the constructors of the View class thanks to the @JvmOverloads annotation. If you are going to add this View programmatically rather than from XML, we only need to define the constructor that contains the context. If it is defined in XML, all constructors of the class must be overridden in this way. The ‘attributeSet’ object contains our own attributes that we will create for this View.

class CustomImageView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attributeSet, defStyleAttr){


}

Then, we will first override the onSizeChanged() method. This method is called automatically every time the size of our View that we will put in XML changes. Accordingly, we need to recalculate where our image will appear in the View each time. In order to make this calculation, we need to know the width and height of the view. Therefore, we will create a Rectangle object within the class we created. We will determine the height and height of the view in the onSizeChanged() method.

private val viewRectF = RectF()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewRectF.set(0f,0f,w.toFloat(),h.toFloat())
}

A very important information;
In the Android framework, the upper left corner of the View is considered the (0,0) point. The ‘x’ value increases as you move to the right. Unlike the normal coordinate system, the y value increases as you go down the ‘y’ plane, and the y value decreases as you go up.

Pictures are drawn on the screen by creating a Bitmap object. If you want to draw an image on the screen, you must ‘definitely’ send it as a Bitmap to the view you created. This bitmap also provides information such as config values, width-height-alpha channel. Now, in our class, we will create a Bitmap by accessing the file named person, which we have previously placed in the drawable folder.

private val actualBitmap = 
BitmapFactory.decodeResource(context.resources, R.drawable.person)

After creating the Bitmap object, we will find the scale ratio between our Bitmap and our View. According to this scale ratio, we will translate the image to place it in the middle of the View. To do this, we will first define a Rectangle in our class where we can contain the height and width values of our Bitmap.

private val imageRectF = RectF()

After finding out the scale ratio of our Bitmap according to the View and how much it will be translated, we need the Matrix object to make this information meaningful with the Bitmap while drawing the Bitmap on the screen. Therefore, we will define a Matrix object within our class.

private val imageMatrix = Matrix()

Matrices hold scale, skew and translation values within themselves. Accordingly, it indicates where and how things such as images, text etc. will be drawn on the screen, along with the information it has. The values inside can be retrieved or manipulated by creating a 9-element array. The information it contains can be thought of as being kept in the form of a 3*3 matrix.

After understanding what the matrix is, it is time to find out the scale ratio and how much to translate the Bitmap we have. Here, we will define a function called updateImageMatrix() to center and scale the image according to the View whenever the View’s size changes and we will call it in onSizeChanged(). This is where some math comes into play and the code can get confusing. Absolutely do not be afraid. I will explain each line one by one :)

private fun updateImageMatrix() {
actualBitmap.runIfSafe {
imageRectF.set(0f, 0f, it.width.toFloat(), it.height.toFloat())

val widthScale = viewRectF.width() / imageRectF.width()
val heightScale = viewRectF.height() / imageRectF.height()

val scaleFactor = min(widthScale, heightScale)

val translateX = (viewRectF.width() - imageRectF.width() * scaleFactor) / 2f
val translateY = (viewRectF.height() - imageRectF.height() * scaleFactor) / 2f

imageMatrix.setScale(scaleFactor, scaleFactor)
imageMatrix.postTranslate(translateX, translateY)

invalidate()
}
}

Here ‘runIfSafe’ is an extension function. So why did we need something like this? Bitmaps can take up a lot of memory space. Therefore, the operating system can recycle bitmaps to save memory. In this case, if the bitmap is not ‘null’ and not recycled, the higher order function sent to it will be executed.

private fun Bitmap?.runIfSafe(function: (Bitmap) -> Unit) {
this ?: return

if (isRecycled.not()) {
function(this)
}
}

Now comes the part where ‘scale’ and ‘translate’ are located;

val widthScale = viewRectF.width() / imageRectF.width()
val heightScale = viewRectF.height() / imageRectF.height()

val scaleFactor = min(widthScale, heightScale)
val translateX = (viewRectF.width() - imageRectF.width() * scaleFactor) / 2f
val translateY = (viewRectF.height() - imageRectF.height() * scaleFactor) / 2f

imageMatrix.setScale(scaleFactor, scaleFactor)
imageMatrix.postTranslate(translateX, translateY)

invalidate()

Here we find the ratio of the View’s width to the bitmap’s width and the ratio of the View’s height to the bitmap’s height. So why do we choose the minimum value? Let’s say you have a photo measuring 760 * 1080. You need to place this photo so that it fits perfectly into a frame measuring 1520 * 1620. For this example, we can follow the steps below:

Width ratio = 1520 / 760 = 2
Height ratio = 1620 / 1080 = 1.5

Now we must enlarge the photo ‘with the smaller ratio’ so that when placing the photo in the frame, the height or width of the photo does not extend beyond the frame.

New Width : 760 * 1.5 = 1140
New Height: 1080 * 1.5 = 1620

Now, as you can see above, there is an empty space in the horizontal plane in the frame. We need to translate this area by half of the remaining space. Because we want the photo to be in the middle of the horizontal and vertical planes.

val translateX = (viewRectF.width() - imageRectF.width() * scaleFactor) / 2f

According to the calculation here;
translateX = (1520–760 * 1.5) /2 = 190
translateY = (1620–1080 * 1.5) /2 = 0

In other words, since the photo fits perfectly in terms of height, it will not be translated in the Y direction at all, but will be translated 190 pixels in the ‘X direction’.

Now it’s time to assign values to our matrix object and invalidate our View.

imageMatrix.setScale(scaleFactor, scaleFactor)
imageMatrix.postTranslate(translateX, translateY)

invalidate()

So why didn’t we use the setTranslate() function instead of postTranslate() here? Setter functions on a Matrix assign new values by resetting the values already in the Matrix. Therefore, by calling the postTranslate() function on the Matrix, we say that after translating, perform the scaling operation, as you can see in the code snippet below. I strongly recommend that you repeat the Matrix multiplication topic that we saw in high school and university :)

Finally, we want the View to redraw itself by calling the invalidate function. Well, we have done all the calculations, but where is the View drawn? The answer to this is the function called onDraw(). By overriding this function, we can draw our drawings on the screen.

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}

The onDraw() function presents us with the Canvas object in its parameter. You can think of this Canvas as a white drawing paper. We can draw our drawings on the screen using the Canvas object.

You can find a detailed article about Canvas and its functions here.

While drawing on Canvas, we need another object besides the Canvas. This object is a pen. The Paint class will serve as the pen here. With Paint, we can determine the color, stroke etc. properties of the things to be drawn. For this case, we will create a Paint object within our class.

private val imagePaint = Paint(Paint.ANTI_ALIAS_FLAG)

The reason we give the Paint.ANTI_ALIAS_FLAG parameter in Paint is to ensure that the drawing does not appear jagged. Now we can draw the Bitmap we have with our Matrix object in the onDraw() function.

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.drawBitmap(actualBitmap, imageMatrix, imagePaint)
}

Finally, the way we write the View that we wrote in our layout file is as in the code snippet below. After running your application, you can produce results like the following by changing the dimensions of your View.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:context=".MainActivity">

<com.example.customview.CustomImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</FrameLayout>
View Sizes sequentially (match_parent match_parent) (200dp match_parent) (200dp 100dp)

PorterDuffXFerMode

We drew the Bitmap in a way to scale it properly in the middle of the View, without changing the aspect ratio. Now it’s time for the masking process. We will use the PorterDuffXferMode class for the masking process. PorterDuffXferMode deals with the overlap of pixels of two images according to the alpha channels of the bitmaps.

You can find a detailed article about PorterDuffXferMode here.
Also you can review the outputs of PorterDuff models below.

Manipulations that can be done with PortefDuffModes

For the masking process, we will first define the segmented bitmap in our class.

private val segmentedBitmap =
BitmapFactory.decodeResource(context.resources, R.drawable.person_segmented)

Next we will define Paint to which we will assign our PorterDuffXferMode.

private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

Here we used PorterDuff.Mode.SRC_IN mode. Using this mode, we will first draw the normal Bitmap on the screen. Then, we will draw the segmented Bitmap on the screen and ensure that the overlapping point is the segmented part. This process was carried out within the onDraw() function as follows.

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.saveLayer(viewRectF,imagePaint)
canvas?.drawBitmap(actualBitmap, imageMatrix, imagePaint)
canvas?.drawBitmap(segmentedBitmap, imageMatrix, maskPaint)
canvas?.restore()
}

Here, we first save the Canvas with saveLayer. The reason we save is to ensure that the image is segmented with PorterDuffXFerMode since it works like a stack after restoring. After making our drawing, our screen output will be like this.

Adding Background Image
We will make all the definitions we made for the normal Bitmap for the background Bitmap.

private val backgroundBitmap =
BitmapFactory.decodeResource(context.resources, R.drawable.background)

private val backgroundMatrix = Matrix()

private val backgroundBitmapRectF = RectF()

Then, we will define a function called updateImageBackgroundMatrix() and calculate the scale ratio of the background Bitmap and how much it will be translated.

private fun updateBackgroundImageMatrix() {
backgroundBitmap.runIfSafe {
backgroundBitmapRectF.set(0f, 0f, it.width.toFloat(), it.height.toFloat())

val widthScale = viewRectF.width() / backgroundBitmapRectF.width()
val heightScale = viewRectF.height() / backgroundBitmapRectF.height()

val scaleFactor = min(widthScale, heightScale)

val translateX =
(viewRectF.width() - backgroundBitmapRectF.width() * scaleFactor) / 2f
val translateY =
(viewRectF.height() - backgroundBitmapRectF.height() * scaleFactor) / 2f

backgroundMatrix.setScale(scaleFactor, scaleFactor)
backgroundMatrix.postTranslate(translateX, translateY)

invalidate()
}
}

We will call the function that we prepared, in onSizeChanged().

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewRectF.set(0f, 0f, w.toFloat(), h.toFloat())
updateImageMatrix()
updateBackgroundImageMatrix()
}

Then, we will draw the background Bitmap on the screen along with the matrix we calculated in the onDraw() function.

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.drawBitmap(backgroundBitmap,backgroundMatrix,imagePaint)
canvas?.saveLayer(viewRectF, imagePaint)
canvas?.drawBitmap(actualBitmap, imageMatrix, imagePaint)
canvas?.drawBitmap(segmentedBitmap, imageMatrix, maskPaint)
canvas?.restore()
}
result

If you notice, there is a black area left under the background Bitmap. We said before that Canvas is like a white drawing paper. Here, when drawing on Canvas, we should only draw the areas where the background image is visible. Therefore, we need to define an area to clip the Canvas. We will create a rectangle for this.

private val imageClipRectF = RectF()

We have also calculated the Matrix for the Background Bitmap. We will map the Rectangle of the background Bitmap with this Matrix.

backgroundMatrix.mapRect(imageClipRectF,backgroundBitmapRectF)

Therefore, the content of the updateBackgroundImageMatrix() function will be as follows.

private fun updateBackgroundImageMatrix() {
backgroundBitmap.runIfSafe {
backgroundBitmapRectF.set(0f, 0f, it.width.toFloat(), it.height.toFloat())

val widthScale = viewRectF.width() / backgroundBitmapRectF.width()
val heightScale = viewRectF.height() / backgroundBitmapRectF.height()

val scaleFactor = min(widthScale, heightScale)

val translateX = (viewRectF.width() - backgroundBitmapRectF.width() * scaleFactor) / 2f
val translateY =
(viewRectF.height() - backgroundBitmapRectF.height() * scaleFactor) / 2f

backgroundMatrix.setScale(scaleFactor, scaleFactor)
backgroundMatrix.postTranslate(translateX, translateY)

backgroundMatrix.mapRect(imageClipRectF,backgroundBitmapRectF)

invalidate()
}
}

Okay, we calculated the Clip Rectangle. How do we clip the canvas? Within the onDraw() function, we can give imageClipRectF as a parameter to the clipRect() function via canvas and call it.

canvas?.clipRect(imageClipRectF)

Therefore, the final version of the onDraw() function will be as follows;

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.clipRect(imageClipRectF)
canvas?.drawBitmap(backgroundBitmap,backgroundMatrix,imagePaint)
canvas?.saveLayer(viewRectF, imagePaint)
canvas?.drawBitmap(actualBitmap, imageMatrix, imagePaint)
canvas?.drawBitmap(segmentedBitmap, imageMatrix, maskPaint)
canvas?.restore()
}
The result after of all these operations.
final version of the Custom View

We have come to the end of this article. I hope it was useful to you. In my next article, I will explain how to zoom, translate and rotate a segmented Bitmap with finger movements.

I would like to express my gratitude to my team leader Sedat Atlı, my teammate Salih Akşit and my former teammate Mert Şimşek, who conveyed to me what I have learned so far while writing this article. Thank you dears :) See you in the next article. Stay well…

My Custom View Resources:

--

--