Skip to content

LandscapeDrawable

ZieIony edited this page Apr 19, 2020 · 2 revisions

In 2017 I implemented a view drawing a foggy, animated landscape based on a background I saw on one of the Material Design websites. Since it's a reusable Canvas drawing, it should rather be a Drawable than a View. You can find the original library here.

Making custom Canvas drawings is difficult because Android Studio doesn't have tools for that. Some of the graphics-focused libraries provide additional help in that matter - for example, DirectX comes with a dedicated debugger that allows very precise pixel history tracking. On Android, you have to use your imagination and custom tools, usually built for your specific case.

LandscapeDrawable's code consists of two main elements: the LandscapeDrawable class connecting my drawing code with Android's API, and Item classes doing most of the actual drawing. The Drawable class fits nicely into Android UI, but it's too complex and heavy for composing animations. My animation contains a couple of smaller elements: landscape layers with trees, sky (with clouds, sun and stars), and fog. These elements are very basic and extend my base class instead of Drawable:

abstract class LandscapeItem(val paint: Paint) {
    var x: Float = 0f
    var y: Float = 0f
    var width: Float = 0f
    var height: Float = 0f

    fun setSize(width: Float, height: Float) {
        this.width = width
        this.height = height
        onSizeChanged()
    }

    open fun onSizeChanged() {}

    fun draw(canvas: Canvas) {
        canvas.save()
        canvas.translate(x, y)
        onDraw(canvas)
        canvas.restore()
    }

    abstract fun onDraw(canvas: Canvas)
}

It's all about optimizations and simplicity. Such a class is present in many 2D game engines and serves as a basic drawable item. In my case, it also allows to reuse a Paint object but that's not necessary. To be able to see item classes on screen I wrote a simple Drawable wrapper:

class ItemDrawable(val item: LandscapeItem) : Drawable() {

    override fun onBoundsChange(bounds: Rect) {
        super.onBoundsChange(bounds)
        item.setSize(bounds.width().toFloat(), bounds.height().toFloat())
    }

    override fun draw(canvas: Canvas) {
        item.onDraw(canvas)
    }

    override fun setAlpha(alpha: Int) {
        // not supported
    }

    override fun getOpacity() = PixelFormat.TRANSLUCENT

    override fun setColorFilter(colorFilter: ColorFilter?) {
        // not supported
    }
}

This class allows me to simply add an ImageView to a layout and display an item I'm working on there. Now the interesting part - the item classes. I started with a cloud and a tree by extending LandscapeItem and implementing drawing. The algorithms to generate shapes, curves, gradients, and others are not physically correct by any means. I wanted my animation to be aesthetic and pleasing - clouds should be fluffy and have some lighting, trees should bend a little with the wind, stars should be smaller and bigger, lighter and darker, etc.

Drawing

The Tree class is very simple:

class Tree : LandscapeItem {

    private val path = Path()

    var wind: Float

    // constructors

    override fun onSizeChanged() {
        path.reset()
    }

    private fun init() {
        if(path.isEmpty) {
            // init here
        }
    }

    override fun onDraw(canvas: Canvas) {
        init()
        // draw here
    }
}

I decided to initialize item classes lazily to minimize initialization count in case the user would like to change a couple of parameters. Tree initialization contains a bunch of fudge factors and is kind-of write only code:

val cx = width / 2f
path.addRect(cx - width / 10, height * 5 / 6, cx + width / 10, height * 2, Path.Direction.CCW)
path.moveTo(0f, height * 5 / 6)
path.quadTo(cx - width / 6, height * 5 / 12, cx + wind, 0f)
path.quadTo(cx + width / 6, height * 5 / 12, width, height * 5 / 6)
path.close()

Trees are drawn using paths. Each path can represent an arbitrary shape with straight lines and curves. You can control these curves and path filling. You can also specify your own shader to use when drawing. In my case, the drawing code is very simple and reuses params already set on the Paint object:

override fun onDraw(canvas: Canvas) {
    init()
    canvas.drawPath(path, paint)
}

The Cloud class is a little bit more interesting:

class Cloud : LandscapeItem {

    private class Puff(val x: Float, val y: Float, val size: Float, val shader: RadialGradient)

    var z: Float = 0f
    var color: Int
    var puffCount: Int

    private val random: Random
    private val puffs = ArrayList<Puff>()

    // constructors and onSizeChanged

    fun init() {
        if (puffs.isEmpty()) {
            // init here
        }
    }

    override fun onDraw(canvas: Canvas) {
        init()
        // draw here
    }
}

Clouds consist of smaller puffs - basically white-ish circles. I wanted clouds to be thicker in the center and shaded a little from the bottom. I addressed the first requirement using a custom distribution function, and the second one using a RadialGradient:

fun init() {
    if (puffs.isEmpty()) {
        val maxPuffSize = min(width, height) / 2
        for (i in 0 until puffCount) {
            val size = random.nextFloat() * maxPuffSize / 2 + maxPuffSize / 2
            val x = nextPos() * (width - 2 * size) + size
            val y = (nextPos() * (height - 2 * size) + size) / 0.8f
            val shader = RadialGradient(x, y - size / 2, size, intArrayOf(-0x1, color), floatArrayOf(0.9f, 1f), Shader.TileMode.CLAMP)
            puffs.add(Puff(x, y, size, shader))
        }
        puffs.sortWith(Comparator { o1, o2 ->
            val dist1 = MathUtils.dist(width / 2, height / 2, o2.x, o2.y)
            val dist2 = MathUtils.dist(width / 2, height / 2, o1.x, o1.y)
            (dist1 - dist2).toInt()
        })
    }
}

private fun nextPos(): Float {
    return (((random.nextDouble() - 0.5).pow(3) + 0.125) * 4).toFloat()
}

Distribution functions are quite abstract and you may not be familiar with them. The idea is very simple - I'm getting random numbers using the Random class and I would like to get more values around the center. In other words, I would like to change the value distribution to achieve my desired cloud shape. To find an equation I opened Google Sheets and made a chart with a couple of values I needed.

Cloud drawing is very simple - I'm going through the puffs and drawing them on the canvas. The only unusual thing is the scale transformation. I wanted the puffs to be a little squashed vertically. That's why divided y by 0.8 before.

override fun onDraw(canvas: Canvas) {
    init()

    canvas.save()
    canvas.scale(1f, 0.8f)
    for (p in puffs) {
        paint.shader = p.shader
        canvas.drawCircle(p.x, p.y, p.size, paint)
    }
    canvas.restore()
}
Animation

To animate drawing you have to update drawing params and draw once again. The most simple animations use the difference between the drawing time of the current and the previous frame. This is called time-based animation and is important to have the same animation pace on different devices. There are other approaches allowing animation recording and physics simulation, but for a simple Android drawable this one is sufficient. Sky has wind that pushes clouds from left to right side of the item:

class Sky : LandscapeItem {

    private val clouds: MutableList<Cloud> = ArrayList()
    private var time = System.currentTimeMillis()

    init {
        clouds.clear()
        for (i in 0 until cloudCount) {
            val cloud = Cloud(puffCount, cloudColor, random, paint)
            cloud.z = random.nextFloat() * 0.25f + 0.75f
            cloud.setSize(cloudSize * cloud.z, cloudSize / 2 * cloud.z)
            clouds.add(cloud)
        }
    }

    // constructors and onSizeChanged

    override fun onDraw(canvas: Canvas) {
        // animate and draw here
    }
}

The most important new thing here is the time field. It holds the time of the previous drawing and is used to compute animation advancement every time the item is drawn. If the item is not drawn (for example because it's invisible), the time will go forward anyway and the animation advancement will simply be larger during the next drawing.

override fun onDraw(canvas: Canvas) {
    val currentTime = System.currentTimeMillis()
    val dt = (currentTime - time) / 1000f
    for (cloud in clouds) {
        cloud.x += wind * dt * cloud.z
        if (cloud.x > width)
            cloud.x = -cloud.width
    }
    time = currentTime

    for (cloud in clouds)
        cloud.draw(canvas)
}

The drawing code gets the current time, moves clouds using the wind value, time increment and cloud's z position, and updates the previous time. Then it draws clouds as usual.

Drawable

LandscapeDrawable is quite big but actually very simple. It has a ton of parameters but uses them only to set up the items discussed before. It doesn't have a shared state, doesn't support tinting, filtering, translucency, reading from XML, etc. There's only one interesting feature present in onDraw:

override fun draw(canvas: Canvas) {
    wind += randomForce(windStrength)
    wind = MathUtils.constrain(wind, 0f, maxWind)

    // other drawing

    clouds.wind = wind
    clouds.draw(canvas)

    if (isVisible)
        invalidateSelf()
}

The wind is changed every frame by a tiny bit. Then the drawing is performed and if the drawable is visible, it asks its owner to refresh.

The final result:

Clone this wiki locally