
In SDL game development, it’s common to need custom UI controls. This post will walk you through building a complete UIButton control from scratch, based on an existing Node/UIControl framework. The button will feature text, state changes, click events, and rendering.
I. What is a UIButton?
A UIButton is one of the most common UI elements. It can respond to mouse clicks and changes its appearance based on different states, such as:
- Normal: The default state.
- Hover: The state when the mouse cursor is over the button.
- Pressed: The state when a mouse button is held down over the button.
- Disabled: The state when the button is inactive and cannot be interacted with.
In SDL, we’ll combine the UIControl
base class, event handling, and custom rendering logic to implement these features.
II. The Core Framework: Inheriting from UIControl
Our UIButton
will inherit from UIControl
, which itself is derived from a Node
class. This provides a set of common properties:
- Position and Size:
Vec2 m_pos
,m_size
- Text, Color, Font
- State Management: The
UIState
enum - Event Handling: Overriding the
Process(SDL_Event* e)
method
We’ll need to modify the base framework to add event handling. Let’s add the following declarations to the Node.h
file:
C++
// Called when the mouse cursor enters the element's area
virtual void OnMouseOver() {}
// Called when the mouse cursor leaves the element's area
virtual void OnMouseOut() {}
// Handles incoming SDL events (returns true if event was consumed)
virtual bool Handle(SDL_Event* e) { return false; };
// Registers a callback function for a specific event type
void AddEvent(EventType et, std::function<void(void*)> _fn);
// Unregisters all callbacks for the specified event type
void RemoveEvent(EventType et);
// Convenience methods for adding click event handlers
void AddClick(std::function<void(void*)> _fn);
void AddRClick(std::function<void(void*)> _fn);
// Enables/disables event propagation to underlying elements
inline void SetAllowEventPassThrough(bool _allowEventPassthrouth) { m_allowEventPassthrouth = _allowEventPassthrouth; }
// Checks if events are allowed to pass through to underlying elements
inline bool IsAllowEventPassThrogh() const { return m_allowEventPassthrouth; }
protected:
// Handles mouse enter/leave detection based on current cursor position
void HandleMouseOverOut(const SDL_Point& pt);
// Gets current mouse cursor position relative to application window
SDL_Point GetMousePosition() const;
// Gets element's absolute position and size in windows coordinates
SDL_Rect GetGlobalRect() const;
// Triggers all callbacks registered for the specified event type
void FireEvent(EventType et, void* args);
// Checks if any callbacks are registered for the specified event type
bool HasEvent(EventType et);
Let’s focus on two key functions: Node::Process
and Node::HandleMouseOverOut
.
Node::Process
is the main event loop. It first checks for SDL_MOUSEMOTION
to manage hover states, and then recursively processes events for child nodes before handling its own events. Event propagation can be controlled with IsAllowEventPassThrogh
.
C++
bool Node::Process(SDL_Event* e) {
if (!m_enabled) return false;
if (e->type == SDL_MOUSEMOTION)
{
// Handle mouse over and mouse out states
SDL_Point point{ e->motion.x, e->motion.y };
HandleMouseOverOut(point);
}
SDL_Point pt = GetMousePosition();
bool eventHandled = false;
SDL_Rect rc = GetGlobalRect();
// Check if the mouse is within the control's area before processing events
if (SDL_PointInRect(&pt, &rc))
{
if (!IsEnabled() || !IsVisible())
return true;
// Process events from top-level child controls first
for (auto it = m_children.rbegin(); it != m_children.rend(); ++it)
{
if ((*it).node->Process(e))
{
eventHandled = true;
// If the control is set to allow event passthrough, continue propagation. Otherwise, stop.
if (!(*it).node->IsAllowEventPassThrogh())
return true;
}
}
// Route the message to the actual handler function
if (Handle(e))
{
eventHandled = true;
if (!IsAllowEventPassThrogh())
return true;
}
}
return eventHandled;
}
HandleMouseOverOut
detects when the mouse enters or leaves a control’s bounding box and calls the appropriate virtual function, OnMouseOver
or OnMouseOut
.
C++
void Node::HandleMouseOverOut(const SDL_Point& pt)
{
if (!IsEnabled() || !IsVisible()) return;
auto pos = GetGlobalPosition();
SDL_Rect rc = { pos.x, pos.y, m_size.x,m_size.y };
if (SDL_PointInRect(&pt, &rc))
{
if (!m_mouseover)
{
m_mouseover = true;
OnMouseOver();
}
}
else
{
if (m_mouseover)
{
m_mouseover = false;
OnMouseOut();
}
}
for (auto& n : m_children)
n.node->HandleMouseOverOut(pt);
}
III. Implementing State Change Logic
Since click events are a common, fundamental event, we’ll handle them in our UIControl
base class by overriding the Handle
function. This function manages the UIState
based on mouse events.
C++
bool UIControl::Handle(SDL_Event* e) {
int mouseX, mouseY;
SDL_GetMouseState(&mouseX, &mouseY);
SDL_Rect bounds = { m_pos.x, m_pos.y, m_size.x, m_size.y };
auto pt = SDL_Point{ mouseX, mouseY };
bool isHovering = SDL_PointInRect(&pt, &bounds);
switch (e->type) {
case SDL_MOUSEMOTION:
if (isHovering && m_state != UIState::Hovered) {
m_state = UIState::Hovered;
}
else if (!isHovering && m_state == UIState::Hovered) {
m_state = UIState::Normal;
}
break;
case SDL_MOUSEBUTTONDOWN:
if (isHovering && e->button.button == SDL_BUTTON_LEFT) {
m_state = UIState::Pressed;
}
break;
case SDL_MOUSEBUTTONUP:
if (m_state == UIState::Pressed && isHovering && e->button.button == SDL_BUTTON_LEFT) {
m_state = UIState::Hovered;
FireEvent(EventType::ET_LCLICK, this);
}
else {
m_state = isHovering ? UIState::Hovered : UIState::Normal;
}
break;
default:
break;
}
return false;
}
IV. Rendering the Button Style
We’ll render the background color and text based on the current m_state
. Our control will have an array m_default_background_color
and an array of textures SDL_Texture* m_tex[UIState::UIStateLength]{}
. The system will prioritize drawing with the texture if it’s available; otherwise, it will fall back to using the default color for that state. The button’s text is then rendered on top.
C++
void UIButton::Render()
{
SDL_Rect rc = GetGlobalRect();
if (m_tex[0])
sRender->DrawImage(m_tex[m_state], rc.x, rc.y);
else
sRender->FillRect( rc, m_default_backgroud_color[m_state]);
if (!m_text.empty())
sRender->DrawString2(m_text.c_str(), 14, rc, SDL_Color{ 0,0,0,255 });
}
You can also set the button’s text properties using the same methods as our previous Label control.
The control also provides a void SetResource(SDL_Texture** _tex)
method for setting custom textures for more visually appealing UI.
V. Binding Click Events
We provide an AddEvent
interface to bind click callbacks, which you can use like this:
C++
button->AddEvent(EventType::ET_LCLICK,[]() {
SDL_Log("Button clicked!");
});
For convenience, you can also use the AddLClick
shortcut function:
C++
button->AddLClick([]() {
SDL_Log("Button clicked!");
});
Using std::function
provides a flexible callback mechanism that can be adapted for different use cases.
VI. Summary and Future Directions
With these steps, we’ve completed a fully functional UIButton
that supports state changes, click events, and custom rendering.
If you are developing a UI system with SDL, this structure can help you quickly build a flexible and extensible set of controls.
You can find the accompanying code for this post here: https://github.com/84378996/simple_sdl_gui_tutorials/tree/main/tutorials03
Here’s the final result in action: