Computer scienceMobileAndroidUser InterfaceGraphics

Graphics overview. Implementing a Drawable

16 minutes read

You are already familiar with some basic Drawables provided out of the box, but what if you need to draw something special? Let's move to a lower level and implement one of our own!

Minimal Drawable

When we extend the Drawable class, it's necessary to implement all its abstract methods so our code can compile:

class MinimalDrawable : Drawable() {
    override fun setAlpha(alpha: Int) {}
    override fun setColorFilter(colorFilter: ColorFilter?) {}
    override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
    override fun draw(canvas: Canvas) {}
}

Let's take a closer look at these methods.

setAlpha and setColorFilter are called if we want to change the appearance of our Drawable.

getOpacity allows us to specify whether our Drawable is TRANSLUCENT (fills some pixels within its bounds), TRANSPARENT (draws nothing), or OPAQUE (fills every pixel within its bounds). This used to be necessary for some graphics optimizations in Android. Starting with API 29, the method is no longer used, but we must still implement it because it's abstract and required to support older versions properly.

The most interesting method is draw. It accepts a parameter of type Canvas, which is a drawing surface. This enables us to grab a paint can and draw something!

To show a Drawable, we need a View. You can add a plain <View to your layout and set the Drawable as View background programmatically: someView.background = MinimalDrawable().

Basic GradientTextDrawable

Let's do what neither Drawables out of the box nor TextView can accomplish: draw text using a gradient instead of a solid color.

To achieve this, we need the text we want to draw and its size:

class GradientTextDrawable(@Px size: Float, text: String) : Drawable() {

All the graphics APIs work directly with pixels, so @Px is a hint that it's the caller's responsibility to convert the desired dimension from SP or DP.

We'll begin by drawing some plain text. To do this, we need an instance of TextPaint, which is a subclass of Paint specifically for drawing text. We will also use the ANTI_ALIAS_FLAG to make glyph edges smooth. The canvas.drawText function does what we want:

class GradientTextDrawable(
    @Px size: Float,
    private val text: String,
) : Drawable() {

    private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = size
    }

    override fun draw(canvas: Canvas) {
        canvas.drawText(text, x, y, paint)
    }

But we still need some x and y values. x just defines our text's horizontal start point, but y isn't simply the top — it specifies the baseline of our text. Some glyphs just "stand" on that line: abcdefhiklmnorstuvwxz. Others have descenders that are drawn below the baseline: gjpqy. So, if the value of y is set to 0, we will only see the descenders. The main part of each glyph will be clipped because it's above the Drawable's top bound. Fortunately, we can use FontMetrics.ascent: a negative number holding the distance from the baseline to the top edge of a glyph.

bounds is simply a tuple of (left, top, right, bottom) that defines our bounding box:

    private val metrics = paint.fontMetrics
    override fun draw(canvas: Canvas) {
        val x = bounds.left.toFloat()
        val y = bounds.top - metrics.ascent // metrics are all Float, Int - Float = Float
        canvas.drawText(text, x, y, paint)
    }

Currently, we have a Drawable that draws plain black text because paint.color == Color.BLACK by default. Along with color, Paint has a shader property that allows us to use more sophisticated fills, like a gradient or a bitmap.

Now let's create a gradient! We can borrow one from Hyperskill's website header. CSS: linear-gradient(270deg, #21d789 0%, #18b2b7 27.6%, #408bcf 64.06%, #8c5aff 100%). CSS rotation works clockwise: 0° is bottom to top, 90° is left to right, 180° is top to bottom, and 270° is right to left. We'll just enumerate the colors in reverse order, from left to right: #8c5aff, #408bcf, #18b2b7, #21d789. The percentage values are the color positions inside the gradient. We need them reversed, inverted, and scaled to the 0..1 range: 0, 0.36, 0.724, 1.

We also need gradient bounds — starting from x and spanning the width of our text. This will give us a horizontal line. So, our gradient will be horizontal, meaning that pixels change their colors when moving along the x-axis but stay the same along y. With this orientation, we can use whatever float value we like for the y-coordinates, so simply pass 0f.

To begin with, we create and assign a gradient shader to paint before drawText. paint.measureText lets us know how wide our text is, so we use it to specify that the gradient will have the same width:

paint.shader = LinearGradient(
    x, 0f, x + paint.measureText(text), 0f,
    intArrayOf(
        0xFF8c5aff.toInt(),
        0xFF_408bcf.toInt(),
        0xFF_18b2b7.toInt(),
        0xFF_21d789.toInt(),
    ),
    floatArrayOf(0f, .36f, .724f, 1f),
    Shader.TileMode.CLAMP
)

TileMode defines how the shader behaves beyond its bounds. The value can also be REPEAT or MIRROR but that's not relevant in this case as the gradient and text widths are equal, and the gradient is unbounded vertically. Alternatively, you can imagine this shader as a 1-pixel high horizontal line. So repeating, clamping, or mirroring gives us exactly the same result.

Below, you can see a render for GradientTextDrawable(42f * resources.displayMetrics.scaledDensity, "JetBrains Academy"):

"JetBrains Academy" in gradient color

The desired result has been achieved, but there are several more steps that we must complete to create a production-quality Drawable.

Optimize drawing

The shader is reallocated unconditionally on every draw(), but drawing should be fast, so caching everything that we can is advised.

We can start by writing if (paint.shader == null) paint.shader = LinearGradient(…) to lazy initialize it. But shader depends on the Drawable's bounds which could be changed externally, and we must support having a different size and position. The trivial solution is to null out shader when our bounds are changed:

override fun onBoundsChange(bounds: Rect) {
    paint.shader = null
}

This is acceptable, but let's go another way. Instead, we will create shader for the case when bounds.left is 0 and translate canvas, changing the origin of the coordinates temporarily. The pattern looks like this:

  • save() canvas, meaning "remember the current state so we can revert later";
  • apply transformations with any combination of translate(), rotate(), scale(), or skew();
  • draw anything;
  • restore(), which means "revert all the transformations to the last checkpoint." Anything that was drawn to the canvas remains.

So, instead of re-creating shader every time bounds change, we just make it independent from bounds, and translate canvas to the bounds origin during draw():

if (paint.shader == null) paint.shader = LinearGradient(
    0f, 0f, paint.measureText(text), 0f,

)
canvas.withTranslation(bounds.left.toFloat(), bounds.top.toFloat()) {
    canvas.drawText(text, 0f, -metrics.ascent, paint)
}

withTranslation is an AndroidX extension that calls save–translate–draw–restore internally.

Become mutable

Immutable classes are great in many ways, but we've already seen the onBoundsChange method, which implies that Drawables are mutable by design. There is a good reason for this: updating existing Drawables is generally faster and more memory efficient than allocating new ones.

We have defined our own properties: size and text. Let's also make them mutable, so anyone can change our contents when necessary. We will use the invalidateSelf method to signal that the contents have been modified and must be redrawn.

Changing text size will affect FontMetrics and, therefore, gradient bounds. Instead of asking Paint to create FontMetrics every time when text size gets changed, we will allocate FontMetrics once and ask Paint to update it, thus avoiding extra allocations.

Changing text will affect our gradient length as well:

class GradientTextDrawable(
    @Px size: Float = 0f,
    text: String = "",
) : Drawable() {

    private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = size
    }

    private val metrics = Paint.FontMetrics()
    
    var size: Float
        @Px get() = paint.textSize
        set(@Px size) { 
            if (paint.textSize != size) {
                paint.textSize = size
                paint.shader = null
                invalidateSelf()
            }
        }
    
    var text: String = text
        set(text) {
            if (field != text) {
                field = text
                paint.shader = null
                invalidateSelf()
            }
        }
    
    override fun draw(canvas: Canvas) {
        if (paint.textSize == 0f || text.isBlank())
            return // Draw nothing. Immediately!

        paint.getFontMetrics(metrics) // copy metrics from Paint to our own FontMetrics

Deeper support of Drawable APIs

We still have empty setAlpha() and setColorFilter() methods. Let's obey the Drawable contract and support them.

Alpha, also called opacity, is a value from 0 (transparent) to 255 (opaque). Any value in between make the drawable semitransparent.

ColorFilter is a tool for transforming pixel colors. Many effects including tint, inversion, grayscale, and sepia can be implemented with it.

The task is easy in our case: we just need to pass everything through to our paint:

override fun getAlpha(): Int = paint.alpha
override fun setAlpha(alpha: Int) {
    if (paint.alpha != alpha) {
        paint.alpha = alpha
        invalidateSelf()
    }
}

override fun getColorFilter(): ColorFilter? = paint.colorFilter
override fun setColorFilter(colorFilter: ColorFilter?) {
    paint.colorFilter = colorFilter
    invalidateSelf()
}

ColorFilters should not be checked for equality: Android SDK can mutate some filter instances it uses and call setColorFilter again. This means that paint.colorFilter === colorFilter but we must still call invalidateSelf().

When Paint.shader == null, Paint.color is used. Paint.alpha is not a separate value; it's the alpha component of color, so assigning alpha wipes the original alpha away. We must therefore store color and alpha separately and combine them before assigning to Paint.color. Here's a pattern borrowed from Android SDK to multiply alphas super fast:
import androidx.core.graphics.alpha

@ColorInt fun @receiver:ColorInt Int.modulateAlpha(
    @IntRange(from=0, to=255) alpha: Int,
): Int {
    val ownAlpha = this.alpha
    val scale = ownAlpha + (ownAlpha shr 7) // convert to 0..256
    val multipliedAlpha = alpha * scale shr 8 // multiply alphas and divide by 256, it's 0..255 again
    return (this and 0xFFFFFF) or (multipliedAlpha shl 24)
}

The @ColorInt annotation hints that the given Int value is an AARRGGBB color, not a color resource ID or anything else.

@IntRange is just another hint, indicating that values beyond 0..255 are incorrect. Android graphics APIs think of alpha as a single unsigned byte.

Drawables also have the concept of intrinsic bounds that define their preferred size. Let's implement a pair of methods to report our intrinsic width and height. This should affect how the caller measures our bounds:

override fun getIntrinsicWidth(): Int =
    paint.measureText(text).roundToInt()

override fun getIntrinsicHeight(): Int =
    metrics.run {
        paint.getFontMetrics(this)
        descent - ascent
    }.roundToInt()

But measureText() is potentially slow and depends on text length, so let's cache the result:

    private var textWidth = Float.NaN
    private var textHeight = Float.NaN

    var size: Float
        @Px get() = paint.textSize
        set(@Px size) {
            if (paint.textSize != size) {
                paint.textSize = size
                paint.shader = null
                textWidth = Float.NaN
                textHeight = Float.NaN
                invalidateSelf()
            }
        }

    var text: String = text
        set(text) {
            if (field != text) {
                field = text
                paint.shader = null
                textWidth = Float.NaN
                // don't touch textHeight, it stays unaffected
                invalidateSelf()
            }
        }

    override fun draw(canvas: Canvas) {
        if (size == 0f || text.isBlank())
            return // Draw nothing. Immediately!

        // refresh textWidth
        intrinsicWidth

        // refresh metrics (and cache textHeight, as a bonus)
        intrinsicHeight

        if (paint.shader == null) paint.shader = LinearGradient(
            0f, 0f, textWidth, 0f,

        )
        canvas.withTranslation(…)
    }

    override fun getIntrinsicWidth(): Int {
        if (textWidth.isNaN())
            textWidth = paint.measureText(text)
        return textWidth.roundToInt()
    }
    override fun getIntrinsicHeight(): Int {
        if (textHeight.isNaN())
            textHeight = metrics.run {
                paint.getFontMetrics(this);
                descent - ascent
            }
        return textHeight.roundToInt()
    }

Now we have a Drawable that draws some text using a gradient, allows text and size to be changed, and reports its preferred bounds. It also caches everything efficiently.

There are more options to explore. Canvas can draw some geometric primitives like rectangles, circles, arcs, and even vector paths and raster bitmaps. There are many Paint features and several Shaders, too. They should be easy to find in the docs or IDE.

Conclusion

Previously, you learned that a Drawable is a graphic component used as a building block. Now you understand its anatomy as well: alpha, color filter, bounds, and intrinsic bounds.

You have also become familiar with some basic graphics APIs:

  • Canvas — capable of drawing and transforming.
  • Paint and TextPaint — responsible for color and style.
  • Shader — for applying more sophisticated fills.
  • LinearGradient — for creating a shader that draws a gradient along a line.

Now it's time for some practice!

17 learners liked this piece of theory. 8 didn't like it. What about you?
Report a typo