Chapter 5:
Animate the Canvas
In this chapter, we’re going to add some animation to the Canvas. The user can already remove individual objects by clicking on them. We are going to add an animation effect to make it look like the objects above the deleted one are dropping down to fill the empty space.
Step 1 - Animation concepts and adding a Timer
Animation on a Canvas is no different from animation in a movie or video game. Each frame displays a slightly different image than the one before it, creating the illusion of animation. In a Canvas, the reset handler function is what displays the current frame. We can modify the model slightly over time and redraw the Canvas in order to create the animation effect.
We use a Timer component in Anvil to run client-side code periodically. Add a Timer to your Form. Timers are in the More Components section. Also, use the Object Palette to add a tick
handler function for the Timer.

Adding a Timer to the Form and setting a tick
event
The interval
property for the Timer is how often the Timer will execute its tick
handler function, in seconds. Set the interval
property to 0
so that the Timer does not execute automatically. We’ll start the Timer as needed in code.

Setting the Timer interval
from the Properties Panel
Step 2 - Finding a block by row and column
As part of dropping objects we’re going to need to find specific objects in our model by row and column. We’ll be doing that often enough we should have a function to do it. Add this function to your Form:
def find_object(self, row, col):
for obj in self.model:
if obj['x'] // self.IMAGE_SIZE == col and obj['y'] // self.IMAGE_SIZE == row:
return obj
return None
Note where we adjust the actual coordinates of the object to be the upper left corner of the grid space. This will become important as we start to drop objects and their coordinates will no longer be in the upper left of the grid. What we’re trying to do is find the object whose upper left corner is in the specific grid, even if part of it extends beyond the grid.
Step 3 - Dropping blocks using the Timer
To actually create the animated effect, the tick
handler function must modify the model and then redraw the Canvas. Remember that the user can remove an object by clicking on it. The tick
handler will look for that empty space and then cause the objects above that space to fall down to fill it.
First we need to find the object that was removed. The Canvas mouse down handler already identified that object, so we’ll modify the mouse_down
handler to remember that information. We’ll also start the Timer for the animation so it runs every half a second once the user has removed an object:
def canvas_1_mouse_down(self, x, y, button, keys, **event_args):
if button == 1:
row = y // self.IMAGE_SIZE
col = (x - self.canvas_offset)// self.IMAGE_SIZE
obj_y = row * self.IMAGE_SIZE
obj_x = col * self.IMAGE_SIZE
for shape in self.model:
if shape['type'] not in self.images:
continue
if obj_x == shape['x'] and obj_y == shape['y']:
self.model.remove(shape)
self.canvas_1.reset_context()
# The new code to remember the deleted object
# and start the timer
self.deleted_obj = shape
self.timer_1.interval = 0.5
break
The self.deleted_obj
variable must also be initialized to None
in the __init__
function:
def __init__(self, **properties):
...
self.deleted_obj = None
Now in the Timer’s tick
handler we can make objects fall by adjusting the positions of all the objects above the deleted one. We’ll start adjusting their y coordinate by 1, and modify that later if needed:
def timer_1_tick(self, **event_args):
deleted_row = self.deleted_obj['y'] // self.IMAGE_SIZE
deleted_col = self.deleted_obj['x'] // self.IMAGE_SIZE
for row in reversed(range(deleted_row)):
obj = self.find_object(row, deleted_col)
if obj:
obj['y'] += 1
We also need to recognize when the objects are done falling so we can turn off the Timer and add a new object to the top of the stack. We’ll do that by looking at the top of the Canvas column to see if it’s empty. Add this to the Timer’s tick
handler after the previous code:
def timer_1_tick(self, **event_args):
deleted_row = self.deleted_obj['y'] // self.IMAGE_SIZE
deleted_col = self.deleted_obj['x'] // self.IMAGE_SIZE
for row in reversed(range(deleted_row)):
obj = self.find_object(row, deleted_col)
if obj:
obj['y'] += 1
# What follows is the new code for this step
# Look for an object at the top of the stack
obj = self.find_object(0, deleted_col)
# If there is no object on the top of the stack, we know we're done dropping them
if not obj:
self.model.append({
'type': self.image_types[random.randint(0, self.num_images-1)],
'y': 0,
'x': deleted_col * self.IMAGE_SIZE
})
self.deleted_obj = None
self.timer_1.interval = 0
self.canvas_1.reset_context()
At this point if you run the app and remove a block you’ll discover that the falling animation is painfully slow. You can do two things to speed it up. One is to reduce the interval
property of the Timer component so that the blocks fall more often. The other is to increase the amount of movement of the blocks each tick.
Be careful when increasing the amount of movement since you want the blocks to end up in new row and column positions. The amount that you increase it by must evenly divide into the image sizes. For 32x32 images, moving by 2 or 4 works fine.
Play around with the combination of tick interval
and movement distance that looks good for your app. In the following example the movement distance is 2 and the tick interval
is 0.01:

Icons now drop down to fill in the empty space
Step 4 - Disabling user clicks during block falling
Since the blocks fall over time it’s possible the user may click on other blocks in the meantime. Our Timer code is only designed to deal with one block being removed at a time. So we’ll prevent user clicks during the falling animation from having any effect.
def canvas_1_mouse_down(self, x, y, button, keys, **event_args):
if button == 1:
# Don't process the mouse click if blocks are falling
# This is the new code for this step
if self.deleted_obj:
return
row = y // self.IMAGE_SIZE
col = (x - self.canvas_offset)// self.IMAGE_SIZE
obj_y = row * self.IMAGE_SIZE
obj_x = col * self.IMAGE_SIZE
for shape in self.model:
if shape['type'] not in self.images:
continue
if obj_x == shape['x'] and obj_y == shape['y']:
self.model.remove(shape)
self.canvas_1.reset_context()
self.deleted_obj = shape
self.timer_1.interval = 0.5
break
We’ve now learned how to animate the Canvas. Next, we’ll look at how to support drag and drop on the Canvas.