Wednesday, August 26, 2026

Building 2D Animation with LVGL Pro and STM32

Introduction: What Are We Building?

Have you ever wanted to add lively, interactive 2D animations to your embedded projects, but weren't sure how to handle multiple moving objects, transparent sprites, and character walk-cycles without bogging down your microcontroller?

In this comprehensive tutorial, we will build a complete, synchronized outdoor animated scene from scratch on an STM32F7 microcontroller running LVGL (Light and Versatile Graphics Library) version 9.5 and FreeRTOS.

By the end of this guide, you will be able to:

  • Design a clean UI layout using LVGL Pro (declarative XML).
  • Handle transparent PNG images properly on embedded displays without ugly black background boxes.
  • Master the LVGL 9.5 C Animation API (lv_anim_t) to create continuous forward and reverse movement.
  • Synchronize multiple independent moving objects along a unified 12-second timeline.
  • Build a direction-aware animated character (a walking dog) that steps naturally and turns around seamlessly.
Whether you are a beginner looking to understand LVGL animations or an experienced embedded developer looking for production-ready code patterns, this tutorial will walk you through every single step.

The Animation Scene & Goals

Before writing any code, let's look at the visual breakdown of what we want to achieve on our 800x480 display:


Our scene consists of a beautiful outdoor landscape with three independently animated elements:

  • Drifting Cloud (img_cloud)
    • What it does: Glides horizontally across the sky from left (x = 0) to right (x = 670 px) over 6 seconds, then glides back from right to left over 6 seconds.
    • Cycle: 12 seconds (12000 ms) in a continuous, smooth loop.
  • 2. Hot Air Balloon (img_balloon)
    • What it does: Performs a 2D "box-like" flight trajectory with altitude changes:
    • 0s to 5s: Glides from Right to Left (x: 736 to 0 px) at normal height (y = 150 px).
    • 5s to 6s: Holds at the left edge and descends 40 px (y: 150 to 190 px).
    • 6s to 11s: Glides from Left to Right (x: 0 to 736 px) at lower height (y = 190 px).
    • 11s to 12s: Holds at the right edge and ascends 40 px (y: 190 to 150 px) back to normal altitude.
    • Cycle: 12 seconds (12000 ms) in a continuous loop.
  • Walking Dog Sprite (img_dog)
    • What it does: A character that walks across the grass:
    • 0s to 6s (Walking Right): Moves from x = 0 to x = 632 px. As it moves, it alternates between two right-facing leg frames (dog_walk1 and dog_walk2) every 20 px so the dog appears to be walking.
    • 6s to 12s (Walking Left): Reaches the right edge, turns around to face left, moves from x = 632 back to x = 0 px, and alternates between two left-facing leg frames (dog_walk1_r and dog_walk2_r) every 20 px.
    • Cycle: 12 seconds (12000 ms) in a continuous loop.

Hardware & Software Requirements

Here is the setup used in this project:
  • Microcontroller Board: STM32F769I-DISCO (ARM Cortex-M7 @ 216 MHz, with hardware Chrom-ART DMA2D graphics accelerator).
  • Display: 800x480 capacitive touch screen (DSI interface).
  • Graphics Library: LVGL v9.5.
  • UI Design Tool: LVGL Pro (XML-based editor).
  • Operating System: FreeRTOS.
  • Toolchain / IDE: STM32CubeIDE / GCC / CMake with Ninja.
Step 1: Preparing Your Image Assets & Color Formats
To create this scene, we need the following image assets saved in our project:
  • background.png (800x480 background illustration).
  • cloud.png (Transparent cloud image).
  • balloon.png (Transparent hot air balloon).
  • dog1_walk1.png (Right-facing dog, Step Frame 1).
  • dog1_walk2.png (Right-facing dog, Step Frame 2).
  • dog1_walk1_r.png (Left-facing dog, Step Frame 1 - horizontally mirrored).
  • dog1_walk2_r.png (Left-facing dog, Step Frame 2 - horizontally mirrored).
The Golden Rule of Embedded Image Formats: RGB565 vs ARGB8888
When using PNGs on microcontrollers, choosing the right color format in your asset declarations is crucial:
  • RGB565 (16-bit color): 5 bits Red, 6 bits Green, 5 bits Blue. It has no Alpha channel. If you convert a transparent PNG to RGB565, all transparent areas become solid black (0x0000), leaving an ugly black box around your sprite!
  • ARGB8888 (32-bit color): 8 bits Alpha, 8 bits Red, 8 bits Green, 8 bits Blue. It preserves smooth, per-pixel transparency and anti-aliased edges.
Best Practice: Use rgb565 for large, opaque backgrounds (saving 50% flash memory) and argb8888 for all moving sprites that require transparency:

In your ui/globals.xml file:
<globals>
  <api>
    <!-- Add <enumdefs> here -->
  </api>

  <consts>
    <!-- Add <px>, <int>, <color> etc here -->
  </consts>

  <styles>
    <!-- Add <style> tags here -->
  </styles>

  <subjects>
    <!-- Add <int>, <string>, or <float> subjects here -->
  </subjects>

  <images>
    <data name="dog_walk1" color_format="argb8888" src_path="images/dog1_walk1.png" />
    <data name="dog_walk1_r" color_format="argb8888" src_path="images/dog1_walk1_r.png" />
    <data name="dog_walk2" color_format="argb8888" src_path="images/dog1_walk2.png" />
    <data name="dog_walk2_r" color_format="argb8888" src_path="images/dog1_walk2_r.png" />
    <data name="background" color_format="rgb565" src_path="images/background.png" />
    <data name="cloud" color_format="argb8888" src_path="images/cloud.png" />
    <data name="balloon" color_format="argb8888" src_path="images/balloon.png" />
  </images>

  <fonts>
    <!-- Add <bin> , <tiny_ttf>, <freetype> tags here -->
  </fonts>
</globals>


Step 2: Designing the Screen Layout in LVGL Pro (XML)
With our assets declared, we define the visual hierarchy in ui/screens/main_screen/main_screen.xml.


Avoiding the Variable Shadowing Trap

In generated C code, if a widget's name attribute is identical to the image asset's src attribute (for example <lv_image name="cloud" src="cloud" />), the generator creates a local C pointer lv_obj_t * cloud that shadows the global image descriptor extern const void * cloud. This breaks image rendering!
Rule: Always give widget instances a descriptive prefix (for example img_cloud, img_balloon, img_dog_r1):
<screen>
  <view extends="lv_obj" width="100%" height="100%">
    <!-- background image -->
    <lv_image name="img_bg" src="background" width="100%" height="100%" align="center" />

    <!-- Cloud in the Sky -->
    <lv_image name="img_cloud" src="cloud" x="0" y="30" />

    <!-- Hot Air Baloon -->
    <lv_image name="img_balloon" src="balloon" x="736" y="150" />

    <!-- dog frames/images -->
    <lv_image name="img_dog_r1" src="dog_walk1" align="bottom_left" x="0" y="-30" />
    <lv_image name="img_dog_r2" src="dog_walk2" align="bottom_left" x="0" y="-30" style_opa="0%" />
    <lv_image name="img_dog_l1" src="dog_walk1_r" align="bottom_left" x="0" y="-30" style_opa="0%" />
    <lv_image name="img_dog_l2" src="dog_walk2_r" align="bottom_left" x="0" y="-30" style_opa="0%" />
  </view>
</screen>


Step 3: Mastering LVGL 9.5 C Animations (lv_anim_t)
Now comes the fun part: bringing our scene to life with C code!
In LVGL, every animation is managed by an lv_anim_t structure. You configure it with 6 key parameters:

lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, target_widget);                    // 1. Target object
lv_anim_set_values(&a, start_val, end_val);            // 2. Start & End values
lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)callback); // 3. Callback (e.g. lv_obj_set_x)
lv_anim_set_duration(&a, 6000);                        // 4. Forward duration (ms)
lv_anim_set_reverse_duration(&a, 6000);                // 5. Reverse playback duration (ms)
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); // 6. Repeat forever
lv_anim_start(&a);                                     // 7. Launch!

Part A: Cloud Animation (Smooth 12s Gliding)
The cloud moves horizontally from x = 0 to x = 670 px over 6 seconds, and glides back from x = 670 to x = 0 px over 6 seconds.
Add this function in your Core/gui_mng/gui_mng_cfg.c:

/**
 * @brief Initialize and start the cloud gliding animation.
 *        Moves the cloud horizontally from left to right (0 -> 670 px) over 6 seconds,
 *        then reverses from right to left (670 -> 0 px) over 6 seconds in an infinite loop.
 *
 * @param screen Pointer to the active screen object containing the cloud widget.
 */
static void gui_mng_cloud_animation( lv_obj_t *screen )
{
  /* Find the cloud widget created by LVGL Pro */
  lv_obj_t * cloud = lv_obj_get_child_by_name( screen, "img_cloud" );
  if ( NULL == cloud )
  {
    return;
  }

  /* Initialize the Animation Structure */
  lv_anim_t a;
  lv_anim_init( &a );

  /* Set the target widget to animate */
  lv_anim_set_var( &a, cloud );

  /* Set Start and End horizontal coordinates (0 to 670 px) */
  lv_anim_set_values( &a, 0, 670 );

  /* Set the callback function that updates the widget X-coordinate */
  lv_anim_set_exec_cb( &a, (lv_anim_exec_xcb_t)lv_obj_set_x );

  /* Set forward duration: 6000 ms (Left -> Right) */
  lv_anim_set_duration( &a, 6000 );

  /* Set reverse playback duration: 6000 ms (Right -> Left) */
  lv_anim_set_reverse_duration( &a, 6000 );

  /* Repeat continuously */
  lv_anim_set_repeat_count( &a, LV_ANIM_REPEAT_INFINITE );

  /* Start the animation */
  lv_anim_start( &a );
}

Part B: Hot Air Balloon (2D Flight Path & Altitude Changes)
The balloon trajectory requires coordinating two independent animation handles (a_x and a_y) over the exact same 12-second period.
Crucial Tip on repeat_delay: lv_anim_set_delay() only applies once at system startup. To ensure delays repeat on Cycle 2, Cycle 3, etc., you must also configure lv_anim_set_repeat_delay()!
/**
 * @brief Initialize and start the Hot Air Balloon animation.
 *        Coordinates horizontal and vertical movement across a 12-second cycle:
 *        - 0s to 5s:   Glides from Right to Left (736 -> 0 px) at initial height (y = 150).
 *        - 5s to 6s:   Holds at left edge and descends 40 px (150 -> 190 px).
 *        - 6s to 11s:  Glides from Left to Right (0 -> 736 px) at lower height (y = 190).
 *        - 11s to 12s: Holds at right edge and ascends 40 px (190 -> 150 px) back to start.
 *
 * @param screen Pointer to the active screen object containing the balloon widget.
 */
static void gui_mng_balloon_animation( lv_obj_t *screen )
{
  /* Find the balloon widget created by LVGL Pro */
  lv_obj_t * balloon = lv_obj_get_child_by_name( screen, "img_balloon" );
  if ( NULL == balloon )
  {
    return;
  }

  /* ------------------------------------------------------------- */
  /* 1. Horizontal Animation (X coordinate)                        */
  /* ------------------------------------------------------------- */
  lv_anim_t a_x;
  lv_anim_init( &a_x );
  lv_anim_set_var( &a_x, balloon );

  /* Move from Right edge (736 px) to Left edge (0 px) */
  lv_anim_set_values( &a_x, 736, 0 );
  lv_anim_set_exec_cb( &a_x, (lv_anim_exec_xcb_t)lv_obj_set_x );

  /* Forward movement: Right to Left over 5000 ms */
  lv_anim_set_duration( &a_x, 5000 );

  /* Hold at left edge for 1000 ms while balloon descends */
  lv_anim_set_reverse_delay( &a_x, 1000 );

  /* Reverse movement: Left to Right over 5000 ms */
  lv_anim_set_reverse_duration( &a_x, 5000 );

  /* Hold at right edge for 1000 ms while balloon ascends before repeating */
  lv_anim_set_repeat_delay( &a_x, 1000 );

  /* Repeat continuously */
  lv_anim_set_repeat_count( &a_x, LV_ANIM_REPEAT_INFINITE );
  lv_anim_start( &a_x );

  /* ------------------------------------------------------------- */
  /* 2. Vertical Animation (Y coordinate)                          */
  /* ------------------------------------------------------------- */
  lv_anim_t a_y;
  lv_anim_init( &a_y );
  lv_anim_set_var( &a_y, balloon );

  /* Move from normal height (150 px) down to lower height (190 px) */
  lv_anim_set_values( &a_y, 150, 190 );
  lv_anim_set_exec_cb( &a_y, (lv_anim_exec_xcb_t)lv_obj_set_y );

  /* Initial delay: wait 5000 ms while balloon travels across the sky */
  lv_anim_set_delay( &a_y, 5000 );

  /* Repeat delay: wait 5000 ms on every subsequent cycle */
  lv_anim_set_repeat_delay( &a_y, 5000 );

  /* Descend 40 px over 1000 ms */
  lv_anim_set_duration( &a_y, 1000 );

  /* Hold at bottom height for 5000 ms while balloon travels right */
  lv_anim_set_reverse_delay( &a_y, 5000 );

  /* Ascend 40 px back to 150 over 1000 ms */
  lv_anim_set_reverse_duration( &a_y, 1000 );

  /* Repeat continuously */
  lv_anim_set_repeat_count( &a_y, LV_ANIM_REPEAT_INFINITE );
  lv_anim_start( &a_y );
}

Part C: Animated Walking Character (Direction & Stepping Engine)
Creating an animated walking character requires solving two challenges:
  • Direction Detection: Knowing whether the character is moving left or right so we display the correct mirrored sprite.
  • Stepping Stride: Swapping leg frames without a separate timer so the feet never slide across the ground.
The Distance-Based Stepping Formula
Instead of swapping frames on a fixed timer, we calculate the stride based on horizontal position:

bool step_toggle = ( ( (x < 0 ? -x : x) / 20 ) % 2 ) != 0;

  • 0 to 19 px: (x / 20) is 0 (Even) -> Show Step Frame 1 (r1 / l1).
  • 20 to 39 px: (x / 20) is 1 (Odd) -> Show Step Frame 2 (r2 / l2).
  • 40 to 59 px: (x / 20) is 2 (Even) -> Show Step Frame 1 (r1 / l1).
Because the leg steps are locked directly to the distance walked, the animation looks 100% natural at any display refresh rate!

Complete Dog Animation Code:

/* Structure holding pointers to the 4 dog sprite images */
typedef struct
{
  lv_obj_t *r1;
  lv_obj_t *r2;
  lv_obj_t *l1;
  lv_obj_t *l2;
  int32_t   last_x;
} dog_ctx_t;
static dog_ctx_t dog_ctx = { 0 };
/* Forward prototypes */
static void gui_mng_dog_animation_callback( lv_anim_t * a, int32_t x );
static void gui_mng_dog_animation( lv_obj_t * screen );

/**
 * @brief Initialize and start the dog walking and stepping animation.
 *        Finds all 4 dog sprite frames, sets up a 12-second bidirectional animation
 *        (0 -> 632 px in 6s, and 632 -> 0 px in 6s), and registers the custom callback
 *        to handle leg stepping and direction switching.
 *
 * @param screen Pointer to the active screen object containing the dog widgets.
 */
static void gui_mng_dog_animation( lv_obj_t * screen )
{
  /* Find all 4 dog frames created by LVGL Pro */
  dog_ctx.r1 = lv_obj_get_child_by_name( screen, "img_dog_r1" );
  dog_ctx.r2 = lv_obj_get_child_by_name( screen, "img_dog_r2" );
  dog_ctx.l1 = lv_obj_get_child_by_name( screen, "img_dog_l1" );
  dog_ctx.l2 = lv_obj_get_child_by_name( screen, "img_dog_l2" );
  dog_ctx.last_x = 0;

  /* Verify that all 4 sprite frames exist */
  if ( (dog_ctx.r1 == NULL) || (dog_ctx.r2 == NULL) || (dog_ctx.l1 == NULL) || (dog_ctx.l2 == NULL) )
  {
    return;
  }

  /* Configure horizontal translation animation (0 to 632 px and reverse over 12 seconds) */
  lv_anim_t a_dog;
  lv_anim_init( &a_dog );
  lv_anim_set_var( &a_dog, &dog_ctx );
  lv_anim_set_values( &a_dog, 0, 632 );
  lv_anim_set_duration( &a_dog, 6000 );
  lv_anim_set_reverse_duration( &a_dog, 6000 );
  lv_anim_set_repeat_count( &a_dog, LV_ANIM_REPEAT_INFINITE );
  lv_anim_set_custom_exec_cb( &a_dog, gui_mng_dog_animation_callback );
  lv_anim_start( &a_dog );
}

/**
 * @brief Custom animation execution callback for dog walking motion.
 *        Called continuously by LVGL's animation engine as 'x' changes between 0 and 632.
 *
 *        Logic Breakdown:
 *        1. Compares current 'x' with 'last_x' to determine direction (Right vs Left).
 *        2. Calculates stride toggle: alternates stepping frame every 20 pixels of travel.
 *        3. When walking Right:
 *           - Hides left-facing images (l1, l2).
 *           - Updates position of right-facing images (r1, r2) to current 'x'.
 *           - Toggles opacity between r1 (Frame 1) and r2 (Frame 2) to animate leg steps.
 *        4. When walking Left:
 *           - Hides right-facing images (r1, r2).
 *           - Updates position of left-facing images (l1, l2) to current 'x'.
 *           - Toggles opacity between l1 (Frame 1) and l2 (Frame 2) to animate leg steps.
 *
 * @param a Pointer to the animation structure (contains dog_ctx in a->var).
 * @param x Current animated X-coordinate value (0 to 632).
 */
static void gui_mng_dog_animation_callback( lv_anim_t * a, int32_t x )
{
  dog_ctx_t *ctx = (dog_ctx_t *)a->var;

  if ( NULL == ctx )
  {
    return;
  }

  /* Detect walking direction based on position delta */
  static bool walking_right = true;
  if ( x > ctx->last_x )
  {
    walking_right = true;
  }
  else if ( x < ctx->last_x )
  {
    walking_right = false;
  }
  ctx->last_x = x;

  /* Alternate stepping frame every 20 pixels of horizontal distance */
  bool step_toggle = ( ( (x < 0 ? -x : x) / 20 ) % 2 ) != 0;

  if ( walking_right )
  {
    /* Hide left-facing images */
    lv_obj_set_style_opa( ctx->l1, LV_OPA_0, 0 );
    lv_obj_set_style_opa( ctx->l2, LV_OPA_0, 0 );

    /* Update right-facing positions */
    lv_obj_set_x( ctx->r1, x );
    lv_obj_set_x( ctx->r2, x );

    /* Alternate between right-facing Frame 1 and Frame 2 */
    if ( step_toggle )
    {
      lv_obj_set_style_opa( ctx->r1, LV_OPA_0, 0 );
      lv_obj_set_style_opa( ctx->r2, LV_OPA_COVER, 0 );
    }
    else
    {
      lv_obj_set_style_opa( ctx->r1, LV_OPA_COVER, 0 );
      lv_obj_set_style_opa( ctx->r2, LV_OPA_0, 0 );
    }
  }
  else
  {
    /* Hide right-facing images */
    lv_obj_set_style_opa( ctx->r1, LV_OPA_0, 0 );
    lv_obj_set_style_opa( ctx->r2, LV_OPA_0, 0 );

    /* Update left-facing positions */
    lv_obj_set_x( ctx->l1, x );
    lv_obj_set_x( ctx->l2, x );

    /* Alternate between left-facing Frame 1 and Frame 2 */
    if ( step_toggle )
    {
      lv_obj_set_style_opa( ctx->l1, LV_OPA_0, 0 );
      lv_obj_set_style_opa( ctx->l2, LV_OPA_COVER, 0 );
    }
    else
    {
      lv_obj_set_style_opa( ctx->l1, LV_OPA_COVER, 0 );
      lv_obj_set_style_opa( ctx->l2, LV_OPA_0, 0 );
    }
  }
}

Part D: Launching All Animations on Screen Startup
In gui_startup:

/**
 * @brief Build a simple LVGL starter screen.
 *
 * @param data Pointer to event data structure.
 */
static void gui_startup( const gui_mng_event_data_t *data )
{
  (void)data;
  lv_obj_t *screen = main_screen_create();
  lv_screen_load( screen );

  gui_mng_cloud_animation( screen );
  gui_mng_balloon_animation( screen );
  gui_mng_dog_animation( screen );
}

Testing & Verification on Hardware

Once you compile and flash your STM32 board, you will see:
  • The Cloud seamlessly drifting back and forth across the mountain peaks.
  • The Balloon flying from right to left, dipping down by 40 px at the left edge, flying back across the sky, and ascending back to its starting height on every cycle.
  • The Dog walking across the foreground with natural stepping legs, smoothly turning around at the right edge, and walking back in reverse.
  • All three elements operate in phase-locked harmony on an infinite 12-second cycle with 0 memory leaks and fluid performance!

No comments:

Post a Comment