SDL2 UI Patterns for Embedded Displays
Time: 45 min | Prerequisites: SDL2 Rotating Cube, Touch Paint
This tutorial covers common UI patterns for building interactive applications on embedded displays using SDL2 — without a widget toolkit. You'll learn to build modal overlays, touch buttons, draggable sliders, and hit-test logic using techniques from the Audio Visualizer Full demo.
1. When SDL2 vs Qt
| SDL2 | Qt Quick/QML | |
|---|---|---|
| Best for | Real-time rendering, games, visualizers, single-screen apps | Multi-page apps, forms, complex layouts |
| UI approach | Manual: you draw every pixel | Declarative: you describe the layout |
| Touch | SDL_FINGERDOWN/MOTION/UP with normalized 0.0–1.0 coords |
Built-in gesture recognizers, Flickable, SwipeView |
| Text | Bitmap font or SDL2_ttf | Full font rendering, rich text |
| Binary size | ~200 KB | ~20 MB (Qt6 + QML) |
| Startup | < 50 ms | ~500 ms |
Rule of thumb: if your app has more than 2 screens of UI with text input, use Qt. If it's a real-time display with a few buttons, SDL2 is simpler and faster.
2. Bitmap Font Rendering
Without SDL2_ttf, embed a small font as a lookup table. A 5x7 pixel font covers ASCII and renders at any integer scale:
#define FONT_W 5
#define FONT_H 7
/* Each row is 5 bits wide, MSB = leftmost pixel */
static const uint8_t font5x7[128][FONT_H] = {
['A'] = {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11},
['0'] = {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E},
/* ... fill in needed characters ... */
};
static void draw_text(SDL_Renderer *r, const char *str,
int x0, int y0, int scale)
{
int cx = x0;
for (const char *p = str; *p; p++) {
int ch = (unsigned char)*p;
if (ch >= 128) ch = ' ';
for (int row = 0; row < FONT_H; row++) {
uint8_t bits = font5x7[ch][row];
for (int col = 0; col < FONT_W; col++) {
if (bits & (0x10 >> col)) {
SDL_Rect px = { cx + col * scale,
y0 + row * scale,
scale, scale };
SDL_RenderFillRect(r, &px);
}
}
}
cx += (FONT_W + 1) * scale;
}
}
Why Not SDL2_ttf?
SDL2_ttf adds a FreeType dependency (~1 MB), requires .ttf font files, and renders to surface textures. For a handful of labels at fixed sizes, a bitmap font is smaller, faster, and has zero external dependencies. Use SDL2_ttf when you need variable font sizes, Unicode, or anti-aliased text.
Usage: Set the draw color before calling:
SDL_SetRenderDrawColor(ren, 200, 200, 220, 255);
draw_text(ren, "Hello", 10, 10, 2); /* 2x scale = 10x14 px */
3. Touch Buttons
3.1 Button Data Structure
Define buttons with label, state, color, and normalized touch bounds:
#define BTN_COUNT 4
#define BTN_H 36
typedef struct {
const char *label;
int active; /* toggle state */
Uint8 r, g, b; /* accent color */
float x0, x1; /* normalized X bounds (0.0 – 1.0) */
} touch_btn_t;
static touch_btn_t buttons[BTN_COUNT] = {
{ "REC", 0, 255, 60, 60, 0, 0 },
{ "FILTER", 0, 60, 200, 255, 0, 0 },
{ "GATE", 1, 60, 255, 120, 0, 0 },
{ "EQ", 0, 255, 200, 50, 0, 0 },
};
3.2 Layout Calculation
Compute button positions once at startup (adapts to screen width):
static void layout_buttons(int win_w, int win_h)
{
int margin = 10, spacing = 6;
int total_w = win_w - 2 * margin;
int btn_w = (total_w - (BTN_COUNT - 1) * spacing) / BTN_COUNT;
for (int i = 0; i < BTN_COUNT; i++) {
int bx = margin + i * (btn_w + spacing);
buttons[i].x0 = (float)bx / win_w;
buttons[i].x1 = (float)(bx + btn_w) / win_w;
}
}
3.3 Drawing
Active buttons get a filled background; inactive ones get an outline:
static void draw_buttons(SDL_Renderer *r, int win_w, int win_h)
{
int margin = 10, spacing = 6;
int total_w = win_w - 2 * margin;
int btn_w = (total_w - (BTN_COUNT - 1) * spacing) / BTN_COUNT;
int by = win_h - BTN_H - 4;
for (int i = 0; i < BTN_COUNT; i++) {
int bx = margin + i * (btn_w + spacing);
SDL_Rect rect = { bx, by, btn_w, BTN_H };
if (buttons[i].active) {
SDL_SetRenderDrawColor(r, buttons[i].r, buttons[i].g,
buttons[i].b, 200);
SDL_RenderFillRect(r, &rect);
SDL_SetRenderDrawColor(r, 255, 255, 255, 255);
} else {
SDL_SetRenderDrawColor(r, 40, 40, 45, 255);
SDL_RenderFillRect(r, &rect);
SDL_SetRenderDrawColor(r, buttons[i].r, buttons[i].g,
buttons[i].b, 255);
SDL_RenderDrawRect(r, &rect);
}
/* Center the label */
int tw = strlen(buttons[i].label) * (FONT_W + 1) * 2;
draw_text(r, buttons[i].label,
bx + (btn_w - tw) / 2,
by + (BTN_H - FONT_H * 2) / 2, 2);
}
}
3.4 Hit Testing
Convert normalized touch coordinates to button index:
static int hit_test_button(float fx, float fy, int win_h)
{
float btn_top = (float)(win_h - BTN_H - 4) / win_h;
if (fy < btn_top) return -1;
for (int i = 0; i < BTN_COUNT; i++) {
if (fx >= buttons[i].x0 && fx <= buttons[i].x1)
return i;
}
return -1;
}
3.5 Tap vs Swipe Disambiguation
On touch displays, you need to distinguish taps (button presses) from swipes (gestures). The trick: track the finger start position and only fire a button on FINGERUP if the finger didn't travel far:
float touch_start_y = 0;
int swipe_active = 0;
/* In event loop: */
if (ev.type == SDL_FINGERDOWN) {
touch_start_y = ev.tfinger.y;
if (ev.tfinger.y > 0.85f)
swipe_active = 1; /* potential swipe-up exit */
}
if (ev.type == SDL_FINGERMOTION && swipe_active && ev.tfinger.y < 0.5f)
running = 0; /* swipe-up = exit */
if (ev.type == SDL_FINGERUP) {
float dy = fabsf(ev.tfinger.y - touch_start_y);
if (dy < 0.05f) {
/* Small movement = tap */
int btn = hit_test_button(ev.tfinger.x, ev.tfinger.y, WIN_H);
if (btn >= 0)
buttons[btn].active = !buttons[btn].active;
}
swipe_active = 0;
}
Warning
Common mistake: handling buttons on FINGERDOWN. This makes swipe gestures impossible because every swipe starting over a button triggers it. Always fire actions on FINGERUP with distance check.
4. Modal Overlays
A modal overlay draws on top of the main content with a semi-transparent background. The pattern:
- Draw the main scene (spectrum, waveform, etc.)
- Draw buttons (always visible)
- If overlay is active, draw it last so it covers the main scene
4.1 Semi-Transparent Background
static void draw_overlay(SDL_Renderer *r, int win_w, int win_h)
{
int margin = 30;
int ox = margin, oy = margin;
int ow = win_w - 2 * margin;
int oh = win_h - 2 * margin;
/* Dark semi-transparent background */
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(r, 10, 10, 15, 220);
SDL_Rect bg = { ox, oy, ow, oh };
SDL_RenderFillRect(r, &bg);
/* Border */
SDL_SetRenderDrawColor(r, 80, 80, 100, 255);
SDL_RenderDrawRect(r, &bg);
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
/* Title */
SDL_SetRenderDrawColor(r, 200, 200, 220, 255);
draw_text(r, "SETTINGS", ox + 10, oy + 8, 2);
/* ... draw overlay content ... */
}
Tip
Always restore SDL_BLENDMODE_NONE after drawing translucent elements. Leaving blend mode on causes all subsequent drawing to be alpha-composited, which is slower and produces unexpected results.
4.2 Rendering Order
/* Main render loop */
SDL_RenderClear(ren);
draw_waveform(ren, ...);
draw_spectrum(ren, ...);
draw_buttons(ren, WIN_W, WIN_H);
/* Overlays drawn LAST — on top of everything */
if (overlay_active)
draw_overlay(ren, WIN_W, WIN_H);
SDL_RenderPresent(ren);
4.3 Input Routing
When an overlay is active, touch events should go to the overlay, not the main content:
if (ev.type == SDL_FINGERDOWN) {
if (overlay_active) {
/* Handle overlay interaction */
int slider = overlay_hit_test(ev.tfinger.x, ev.tfinger.y);
if (slider >= 0) dragging = slider;
} else {
/* Normal button handling */
}
}
5. Draggable Sliders
Sliders are vertical (or horizontal) bars that the user drags to set a value. The interaction model:
FINGERDOWN— hit-test to see if a slider was touched, start draggingFINGERMOTION— update the value based on finger positionFINGERUP— stop dragging
5.1 Drawing a Vertical Slider
void draw_slider(SDL_Renderer *r, int cx, int top, int bot,
float value, /* -1.0 to +1.0 */
Uint8 cr, Uint8 cg, Uint8 cb)
{
int h = bot - top;
int mid = top + h / 2;
int bw = 20; /* bar width */
/* Track line */
SDL_SetRenderDrawColor(r, 50, 50, 60, 255);
SDL_RenderDrawLine(r, cx, top, cx, bot);
/* Filled bar from center to value */
int val_y = mid - (int)(value * h / 2);
int bar_top = val_y < mid ? val_y : mid;
int bar_bot = val_y < mid ? mid : val_y;
SDL_SetRenderDrawColor(r, cr, cg, cb, 180);
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
SDL_Rect bar = { cx - bw/2, bar_top, bw, bar_bot - bar_top };
SDL_RenderFillRect(r, &bar);
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
/* Thumb (drag handle) */
SDL_SetRenderDrawColor(r, 255, 255, 255, 255);
SDL_Rect thumb = { cx - bw/2, val_y - 3, bw, 6 };
SDL_RenderFillRect(r, &thumb);
}
5.2 Drag Handling
static int dragging = -1; /* slider index, -1 = none */
/* FINGERDOWN: start drag */
if (ev.type == SDL_FINGERDOWN) {
dragging = slider_hit_test(ev.tfinger.x, ev.tfinger.y);
}
/* FINGERMOTION: update value */
if (ev.type == SDL_FINGERMOTION && dragging >= 0) {
int py = (int)(ev.tfinger.y * WIN_H);
float norm = (float)(slider_mid - py) / (slider_height / 2.0f);
if (norm > 1) norm = 1;
if (norm < -1) norm = -1;
sliders[dragging].value = norm;
}
/* FINGERUP: stop drag */
if (ev.type == SDL_FINGERUP) {
dragging = -1;
}
6. Putting It Together: EQ Overlay Example
The Audio Visualizer Full demo combines all these patterns into a graphic equalizer overlay:
┌──────────────────────────────────────────────────┐
│ EQUALIZER tap EQ to close│
│ │
│ 60 150 400 1K 2.5K 6K 12K 16K DELAY │
│ ██ ██ │
│ ██ ██ ██ ██ │
│ ─██───────██──────────────────── ██ ──────██─ 0dB│
│ ██ ██ ██ ██ ██ │
│ ██ ██ ██ │
│ +3 0 -2 -6 -4 -3 0 +6 200ms │
│ │
│ EQ ON Delay ON Playback OFF Latency 45ms │
└──────────────────────────────────────────────────┘
Each band is a draggable slider controlling a biquad peaking filter. The spectrum view updates in real time so students can see the EQ effect. See audio_viz_full.c for the complete implementation.
7. Performance Tips
| Technique | Impact | When to use |
|---|---|---|
Avoid SDL_RenderDrawPoint in loops |
10–50x faster | Use SDL_RenderDrawLine or SDL_RenderFillRect instead |
| Batch draw calls by color | ~2x faster | Group all draws of the same color before calling SDL_SetRenderDrawColor |
| Use textures for static content | ~5x faster | Pre-render complex backgrounds to a texture, SDL_RenderCopy each frame |
| Minimize blend mode switches | ~1.5x faster | Draw all opaque content first, then all translucent content |
PRESENTVSYNC flag |
Consistent | Always use — prevents tearing and limits CPU to display refresh rate |
Challenges
Tip
Challenge 1: Toggle Buttons with Visual Feedback
Add a brief "press" animation: when a button is tapped, flash it brighter for 2 frames before settling to the toggled state. Track a flash_timer per button.
Tip
Challenge 2: Horizontal Slider
Modify the vertical slider code to create a horizontal volume slider. The value should map to the gain variable.
Tip
Challenge 3: Multi-Page Overlay Create an overlay with two pages (tab-style) — one for EQ and one for effects. Draw two tab buttons at the top of the overlay that switch which content is shown.
See also: Audio Visualizer Challenges | Signal Processing Reference | Qt Quick for Embedded