LVGL Custom Draw Events: Keep Draw-Time Decoration from Mutating the Widget Tree
A draw callback should describe pixels from already-prepared state. It must not change widget properties, create or delete widgets, or request another invalidation while rendering is in progress. Being on the correct UI thread does not make those operations legal during the drawing phase.
Applicability: LVGL v9.3.0, a serialized UI owner, the ordinary software draw path and a small rectangular decoration inside a base object’s bounds. The example is a proposed state-and-rendering fixture. It contains no executed application code, performance measurement or cross-backend compatibility claim.

1. Separate state updates from pixel description
Consider a hypothetical status card containing a label and a narrow colored quality marker. Application data changes the quality level. The marker’s draw callback reads that level and emits a fill task; it does not increment the level, refresh the label or decide whether the screen should close.
Prepare a UI-owned snapshot containing the marker’s position, color and model revision before rendering. Update any label text and other widget state in the same normal update phase. Then invalidate the affected object or reviewed damage region. During the subsequent rendering pass, the snapshot remains stable.
This distinction prevents a partial-buffer redraw from becoming an accidental application clock. An object can be visited for multiple clipped regions, or not drawn when nothing is dirty. Counting draw callbacks to advance progress makes application behavior depend on buffer size, clipping and refresh workload.
2. Choose the phase that gives the intended ordering
The event reference distinguishes main and post drawing events. The tagged redraw traversal sends the main phase before traversing children; the post phase follows child drawing in the applicable path.
Use a main-end decoration when the children should appear over it. Use a post decoration when the marker should cover a portion of the children. State that choice in the component contract. A post event is still part of rendering, not a general-purpose “after rendering” callback where arbitrary UI changes become safe.
For the initial fixture, place a label so its visible bounds overlap the marker. Draw once with the marker in main-end and once in post, keeping the snapshot unchanged. Compare the overlap pixels to verify the intended ordering rather than inferring it from callback names alone.
Obtain the current drawing layer through lv_event_get_layer for the supported drawing event codes. Use that supplied layer for the drawing operation. Do not retain the layer or event pointer for later use, and do not substitute a global framebuffer pointer that bypasses the renderer’s clip and task handling.
3. Build transient descriptors, not transient widgets
Initialize the appropriate draw descriptor, set the required fields, calculate the decoration’s rectangle from the current object coordinates and submit the primitive. The v9.3.0 fill implementation copies the scalar fill descriptor into its draw task. A local initialized descriptor is appropriate for this simple fill-only case.
That copy is not a universal deep-copy promise for image sources, text, gradients or other referenced storage. If the decoration later uses pointer-bearing resources, verify their exact lifetime through task execution. Keep this first fixture free of those additional dependencies.
Do not create a label inside the draw callback merely to display a number. Create persistent child widgets during setup, then update them before rendering. Repeated object creation would modify the tree being traversed, create extra ownership obligations and potentially trigger another invalidation.
| Operation | Phase for this component |
|---|---|
| Read stable quality snapshot and object coordinates | Draw callback |
| Initialize fill descriptor and submit fill to supplied layer | Draw callback |
| Change label text, widget size or style | Normal UI update before rendering |
| Create/delete children or navigate to another page | Application update/navigation phase |
| Request redraw after model change | Outside active rendering |
4. Respect clipping and declared drawing extent
Keep the marker inside the object’s bounds for the baseline. Do not enlarge the supplied clip area to force pixels through a parent boundary. The draw pipeline uses tasks and layers to coordinate rendering; manually overriding shared layer state can affect later drawing.
If a future decoration extends beyond the object, declare the required external drawing extent through LV_EVENT_REFR_EXT_DRAW_SIZE. The event helper takes the maximum of the current size and the requested size, preserving another contributor’s larger requirement. Answering that geometry query is different from setting a widget style in a pixel-drawing callback.
The object drawing code stores the computed external size and may invalidate when it changes. Refresh that requirement through the normal update path, not by calling its refresh operation from DRAW_MAIN or DRAW_POST. Enlarging declared extent also does not automatically override every ancestor’s clipping policy.
5. Detect reentrant invalidation without hiding it
The exact lv_inv_area guard asserts that rendering is not in progress. Its source comment explicitly identifies property/style changes inside drawing events as one cause. A single-threaded program can therefore trigger this failure without any concurrent worker.
For a recoverable host diagnostic build, enable the relevant assertions and deliberately invalidate the card from its draw callback. Treat the assertion as the expected negative-control outcome. Run this isolated case separately from normal acceptance tests, and never disable assertions merely to make the symptom disappear.
A production callback should not schedule one update on every redraw as a workaround. That can create a self-sustaining refresh loop. If an exceptional condition needs later handling, record bounded application intent with a stable identity, coalesce duplicates and process it after the rendering call stack returns. Prefer discovering and handling the condition in the normal update phase.
6. Qualify stable output under repeated drawing
- Freeze one snapshot and render a full card. Save the image, child count, model revision and command count.
- Use a smaller partial buffer and several dirty subregions that cross the marker. Require the same final pixels and unchanged application state.
- Request repeated redraws without new data. The decoration may be drawn again, but no business action or progress increment may occur.
- Move the marker through the normal update phase. Invalidate enough of the old and new coverage to remove stale pixels.
- Hide and reveal the card, then recreate it through normal navigation. Check that drawing registrations and persistent children are not duplicated.
Retain both pixel comparisons and state invariants. The acceptance criterion is deterministic decoration from a prepared snapshot, with no tree mutation or application progress caused by how often LVGL needs to draw it.