GUI not refreshing on ESP32 with ESP-IDF


TL;DR: Calling lv_obj_add_state( ui_playBtn, LV_STATE_PRESSED ) does not change the appearance of a image button at runtime, but works during lvgl setup.

I’m using ESP-IDF to build an app running on a ESP32. While the GUI properly renders at startup, including the pressed status of the “play” button; it does not render status changes of the “play” and other buttons after startup, triggered by a separate FreeRTOS task on some events (turning a rotary encoder).

The main app structure (and LVGL setup) comes from the esp_lcd_ili9488 example, which works. The esp_lcd_ili9488 example has been (heavily) refactored of course, but the way LVGL and the SPI display are initialized is the same.

Does anyone have any idea what’s wrong in what I am doing?

What MCU/Processor/Board and compiler are you using?

MCU: ESP32-WROVER (Freenove)
DISPLAY: ILI9488 SPI, 480x320
ESP-IDF: 5.2
ESP Components:

What LVGL version are you using?


What do you want to achieve?

Change the appearance of image buttons in the GUI when triggering the rotary encoder callback.

What have you tried so far?

  • moving lv_tick_inc from an esp_timer_start_periodic callback to a separate FreeRTOS task in a loop with vTaskDelay
  • moving lv_tick_inc basically everywhere (sorry for the lack of details)
  • sending LV_EVENT_REFRESH events to all buttons and to the root object
  • 2-3 more weeks of different attempts I lost track of

Code to reproduce

The code is sitting in this repo in the ui_logic branch:

static void IRAM_ATTR lvgl_tick_cb(void *param)

void initialize_lvgl(...) {
  ESP_LOGI(TAG, "Creating LVGL tick timer");
  const esp_timer_create_args_t lvgl_tick_timer_args =
    .callback = &lvgl_tick_cb,
    .name = "lvgl_tick"
  esp_timer_handle_t lvgl_tick_timer = NULL;
  ESP_ERROR_CHECK(esp_timer_create(&lvgl_tick_timer_args, &lvgl_tick_timer));
  ESP_ERROR_CHECK(esp_timer_start_periodic(lvgl_tick_timer, LVGL_UPDATE_PERIOD_MS * 1000));
  • GUI setup (automatically generated with SquareLine Studio):
lv_obj_t * ui_Screen1;
lv_obj_t * ui_playBtn;

void ui_Screen1_screen_init(void)
    ui_Screen1 = lv_obj_create(NULL);
    lv_obj_clear_flag(ui_Screen1, LV_OBJ_FLAG_SCROLLABLE);      /// Flags

    ui_screenBackground = lv_obj_create(ui_Screen1);
    ui_playBtn = lv_imgbtn_create(ui_Screen1);
    lv_imgbtn_set_src(ui_playBtn, LV_IMGBTN_STATE_RELEASED, NULL, &ui_img_play_png, NULL);
    lv_imgbtn_set_src(ui_playBtn, LV_IMGBTN_STATE_PRESSED, NULL, &ui_img_play_border_png, NULL);

void ui_init(void)
    lv_disp_t * dispp = lv_disp_get_default();
    lv_theme_t * theme = lv_theme_default_init(dispp, lv_palette_main(LV_PALETTE_BLUE), lv_palette_main(LV_PALETTE_RED),
                                               true, LV_FONT_DEFAULT);
    lv_disp_set_theme(dispp, theme);
  • Setup initial UI state and update logic (works at startup, does not at runtime, update_ui_components gets correctly triggered by rotary encoder callback, but lv_obj_add_state has no effect):
static void update_ui_components( state_t * state )
  lv_obj_clear_state( ui_playBtn,     LV_STATE_PRESSED );
  switch( state->active_component )
    case PLAY_PAUSE:
      lv_obj_add_state( ui_playBtn, LV_STATE_PRESSED );
      lv_event_send( ui_playBtn, LV_EVENT_REFRESH, NULL );
  lv_event_send( ui_Screen1, LV_EVENT_REFRESH, NULL );
void app_main(void)
  // Setup everything
  while( 1 )
    vTaskDelay( pdMS_TO_TICKS( 10 ) );
  • rotary encoder position change callback:
static void IRAM_ATTR encoder_position_cb( position_event_t event )
  // indirectly call update_ui_components

Screenshot and/or video

(top-left corner CPU usage gets updated, media player buttons don’t)

You missunderstand inc_tick. This is lvgl info about real time took. Optim every 1ms . Technicaly your code seems be ok, then try check if it realy works. Place into interrupt some pin toggle and check with scope. Or you tasks priorities is problem. Same method to check … Maybe esp_timer_handle_t lvgl_tick_timer require static…

It turns out that the solution is to use lv_imgbtn_set_state instead of lv_obj_add_state. The weird part is that lv_imgbtn_set_state works the first time it’s called, as you can see in the screenshot where the play button is hollow.

Thanks @Marian_M for your tip. esp_timer_start_periodic accepts a period in microseconds, in this case LVGL_UPDATE_PERIOD_MS * 1000 is 5000us or 5ms (200fps), which so far works well for this app.

I’d change the title to lv_obj_add_state not updating state of an image button on ESP32 with ESP-IDF (which actually points out to what the problem was) but I don’t seem to have permissions for it.