-
Notifications
You must be signed in to change notification settings - Fork 55
LandscapeDrawable
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.
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()
}
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.
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: