状态:✅ 已完成
难度:⭐⭐
代码行数:~283 行
Radio 是一个交互式单选框组件,演示了互斥选择、圆形绘制和分组管理技术。
- ✅ 互斥选择:同一组内只能选中一个选项
- ✅ 圆形外观:使用圆形代替方形复选框
- ✅ 分组管理:支持多个独立的单选框组
- ✅ 点击切换:鼠标点击单选框改变选择
- ✅ 悬停效果:鼠标悬停时的高亮反馈
- ✅ 键盘控制:数字键快速选择
- ✅ 实时预览:可视化显示当前选择
| 状态 | 描述 | 视觉效果 |
|---|---|---|
| 未选中 | 默认状态 | 空的圆圈 |
| 选中 | 激活状态 | 圆圈内显示蓝色圆点 |
| 悬停 | 鼠标悬停 | 高亮圆圈 + 半透明外圈 |
┌─────────────────────────────────────┐
│ Radio Button Component │
│ [1-3] Select color [ESC] Quit │
│ │
│ Color: Speed: │
│ ◉ Red ○ Slow │
│ ○ Green ○ Normal │
│ ○ Blue ◉ Fast │
│ │
│ Size: ┌───────────┐ │
│ ○ Small │ Preview: │ │
│ ◉ Medium │ │ │
│ ○ Large │ ● │ │
│ │ Speed:... │ │
│ └───────────┘ │
└─────────────────────────────────────┘
// 1. 获取鼠标位置
float mouseX, mouseY;
input->getMousePosition(mouseX, mouseY);
// 2. 检测点击
if (isMousePressed && !wasMousePressed) {
// 检查是否点击在单选框内
for (int i = 0; i < 9; i++) {
if (isPointInRadio(mouseX, mouseY, radios[i])) {
// 设置该组的选中索引(互斥)
*(radios[i].groupSelection) = radios[i].index;
break;
}
}
}| 按键 | 功能 |
|---|---|
| 1 | 选择颜色:红色 |
| 2 | 选择颜色:绿色 |
| 3 | 选择颜色:蓝色 |
| ESC | 退出程序 |
struct RadioData {
float x, y, radius; // 位置和半径
int* groupSelection; // 指向组选中索引的指针
int index; // 当前选项在组内的索引
const char* label; // 标签文本
};
// 组选中状态
int colorChoice = 0; // 0=红, 1=绿, 2=蓝
int sizeChoice = 1; // 0=小, 1=中, 2=大
int speedChoice = 2; // 0=慢, 1=中, 2=快
// 单选框数组(3组 x 3选项 = 9个)
RadioData radios[9] = {
{x, y, r, &colorChoice, 0, "Red"},
{x, y, r, &colorChoice, 1, "Green"},
{x, y, r, &colorChoice, 2, "Blue"},
// ... 其他组
};bool isPointInRadio(float x, float y, const RadioData& radio) const {
// 计算点到圆心的距离
float dx = x - radio.x;
float dy = y - radio.y;
float distSq = dx * dx + dy * dy;
// 判断是否在圆内
return distSq <= radio.radius * radio.radius;
}// 当点击单选框时
if (isPointInRadio(mouseX, mouseY, radios[i])) {
// 将该组的选中索引设置为当前选项的索引
// 同一组的其他选项自动变为未选中
*(radios[i].groupSelection) = radios[i].index;
}
// 渲染时判断是否选中
bool isSelected = (*(radio.groupSelection) == radio.index);void drawRadio(const RadioData& radio, bool isHovered) {
// 1. 绘制外圆背景
renderer->drawCircle(radio.x, radio.y, radio.radius, colorCircleBg);
// 2. 绘制边框(双层圆圈模拟)
float borderWidth = 2.0f;
renderer->drawCircle(radio.x, radio.y, radio.radius, borderColor);
renderer->drawCircle(radio.x, radio.y, radio.radius - borderWidth, colorCircleBg);
// 3. 如果选中,绘制内部圆点
bool isSelected = (*(radio.groupSelection) == radio.index);
if (isSelected) {
float dotRadius = radio.radius * 0.5f;
renderer->drawCircle(radio.x, radio.y, dotRadius, colorDot);
}
// 4. 绘制标签
renderer->drawText(radio.label, ...);
// 5. 如果悬停,绘制高亮
if (isHovered) {
renderer->drawCircle(radio.x, radio.y, radio.radius + 4.0f, highlightColor);
}
}void drawPreview() {
// 根据选择确定预览圆的属性
// 颜色
float previewColor[4];
if (colorChoice == 0) {
// 红色
previewColor = {0.9f, 0.3f, 0.3f, 1.0f};
} else if (colorChoice == 1) {
// 绿色
previewColor = {0.3f, 0.9f, 0.3f, 1.0f};
} else {
// 蓝色
previewColor = {0.3f, 0.5f, 0.9f, 1.0f};
}
// 大小
float previewRadius;
if (sizeChoice == 0) {
previewRadius = 15.0f; // 小
} else if (sizeChoice == 1) {
previewRadius = 25.0f; // 中
} else {
previewRadius = 35.0f; // 大
}
// 绘制预览
renderer->drawCircle(centerX, centerY, previewRadius, previewColor);
}点对圆碰撞:
distance² = (x - cx)² + (y - cy)²
if distance² <= radius²:
点在圆内
优化:使用距离平方避免开方计算。
关键思路:
- 每个组有一个
int类型的选中索引 - 所有单选框共享该索引的指针
- 点击时只需修改索引值
- 渲染时比较索引判断是否选中
// 同一组的单选框共享选中索引
int groupSelection = 0;
RadioData option1 = {..., &groupSelection, 0, ...};
RadioData option2 = {..., &groupSelection, 1, ...};
RadioData option3 = {..., &groupSelection, 2, ...};
// 点击 option2
groupSelection = 1; // option1 和 option3 自动未选中由于 drawCircle 绘制实心圆,使用双层圆圈模拟边框:
- 绘制外圆(边框颜色)
- 绘制稍小的内圆(背景颜色)
- 外圆和内圆的半径差即为边框宽度
renderer->drawCircle(x, y, radius, borderColor); // 外圆
renderer->drawCircle(x, y, radius - 2.0f, bgColor); // 内圆
// 结果:2 像素宽的边框每帧渲染调用(3 组 x 3 选项 = 9 个单选框):
- 外圆背景:9 次
- 外圆边框:9 次
- 内圆遮罩:9 次
- 选中圆点:3 次(每组 1 个选中)
- 悬停高亮:0-1 次
- 文本渲染:13 次(9 个标签 + 3 个组标题 + 1 个说明)
- 预览框:~8 次(背景 + 边框 + 圆 + 文本)
总计:约 50-60 次渲染调用/帧
-
互斥选择逻辑
- 如何实现"只能选一个"的约束
- 使用共享变量管理组状态
-
圆形碰撞检测
- 点对圆的距离计算
- 距离平方优化技巧
-
分组管理
- 多个独立组的数据结构
- 指针在分组中的应用
-
组件设计模式
- 数据驱动的组件设计
- 状态共享与隔离
-
图形技巧
- 双层圆圈模拟边框
- 圆形 UI 元素的实现
-
交互反馈
- 实时预览的联动效果
- 多种输入方式的支持
- 默认选项:初始化时指定默认选中
- 禁用状态:灰色显示且无法选择
- 动画效果:圆点出现/消失的缩放动画
- 动态分组:运行时添加/删除选项
- 自定义样式:不同组使用不同颜色
- 描述文本:在标签下方添加详细说明
- 键盘导航:Tab 键切换焦点,方向键移动,Enter 确认
- 数据绑定:与数据模型双向绑定
- 事件回调:选择改变时触发回调函数
# 1. 配置项目
cd examples/cpp/radio
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
# 2. 编译
cmake --build build --config Release
# 3. 复制 DLL 到运行时目录
copy build\Release\Release\radio.dll ..\..\..\..\native\build\Release\
# 4. 编译着色器
cd assets\shaders
compile_shaders.batcd ..\..\..\..\native
.\build\Release\bitui_native.exe .\build\Release\radio.dllradio/
├── radio.cpp # 组件实现(~283 行)
├── CMakeLists.txt # 构建配置
├── README.md # 本文档
├── assets/
│ └── shaders/ # 着色器目录
│ ├── triangle.vert # 顶点着色器(GLSL)
│ ├── triangle.frag # 片段着色器(GLSL)
│ ├── compile_shaders.bat # 着色器编译脚本
│ └── spv/ # 编译后的 SPIR-V
│ ├── triangle_vert.spv
│ └── triangle_frag.spv
└── build/ # 构建输出
└── Release/
└── Release/
└── radio.dll # 组件库
| 组件 | 关系 | 说明 |
|---|---|---|
| Checkbox | 兄弟组件 | 复选框允许多选,单选框只能选一个 |
| Button | 兄弟组件 | 相似的点击交互模式 |
| Switch | 兄弟组件 | 同样是状态切换组件 |
IInput::isMouseButtonPressed(int button)- 检测鼠标按键状态IInput::getMousePosition(float& x, float& y)- 获取鼠标位置IInput::isKeyJustPressed(int keyCode)- 检测键盘按键IRenderer::drawCircle()- 绘制圆形IRenderer::drawRectangle()- 绘制矩形IRenderer::drawText()- 绘制文本
- 距离公式:
distance = sqrt((x2-x1)² + (y2-y1)²) - 距离平方:
distSq = (x2-x1)² + (y2-y1)²(避免开方) - 圆形碰撞:
distSq <= radius²
-
互斥选择实现
- 使用共享索引而非布尔值
- 便于扩展到更多选项
-
指针使用
- 通过指针共享组状态
- 减少数据冗余
-
碰撞检测优化
- 使用距离平方避免开方
- 先检查矩形包围盒再精确检测
-
视觉反馈
- 提供悬停、选中多种状态
- 实时预览增强用户体验
-
点击无响应
- 检查圆形碰撞检测公式
- 验证鼠标坐标系统
- 确认
wasMousePressed状态更新
-
选中状态不正确
- 检查指针是否正确指向组索引
- 验证索引比较逻辑
- 确认组的初始值
-
边框显示异常
- 检查双层圆圈的半径差
- 验证颜色 alpha 通道
- 确认绘制顺序(外圆在前,内圆在后)
-
多个选项同时选中
- 检查是否正确使用同一个组索引
- 验证指针是否指向正确的变量
- v1.0 (2025-10-12) - 初始版本
- 实现基础单选框功能
- 支持 3 组独立单选框(9 个选项)
- 添加悬停和点击交互
- 实现圆形边框绘制
- 添加实时预览功能
- 项目主页:
Bit HCI - 文档:
docs/guides/ - 示例:
examples/cpp/
| 特性 | Radio | Checkbox |
|---|---|---|
| 形状 | 圆形 | 方形 |
| 选择模式 | 单选(互斥) | 多选(独立) |
| 选中标记 | 实心圆点 | 勾选符号 ✓ |
| 状态管理 | 组索引 | 独立布尔值 |
| 适用场景 | 单项选择题 | 多项选择题 |
Happy Coding! 🎉