В одном из сайд-проектов с использованием imgui понадобилась "вертячка" (loader, spinner, анимация загрузки). Из коробки этот ui-фреймворк таких виджетов не предоставляет, поэтому решил сделать свой: код простой, математики почти нет. Показал ocornut-y, ему тоже понравилось, теперь базовый виджет на очереди интеграции в imgui. Поискал интересные спинеры на разных сайтах для веб-интерфейсов - десятки видов на любой вкус и цвет, есть и 3д, но все в основном или пререндеры в виде (gif) или векторные анимации, которые для отрисовки требует отдельного фреймворка вроде cairo, а алгоритмов или описания как это работает, почти нет. Все спинеры сделаны в стиле "что вижу, то и пою", немного математики синусы/косинусы для координат, и тестировать пока не будет похоже на решение от UI дизайнера. Да-да, я понимаю, что когда космические корабли бороздят просторы большого театра DALL·E 2 рисует "улыбку мадонны", писать что-то на плюсах, да еще и UI...
Началось все с простого спинера, который рисует гоняющийся за началом хвост. Уже не помню где я его увидел, но "вертячка" занимательная с логикой на "три копейки". _CalcCircleAutoSegmentCount() подбирает оптимальное число сегментов, для текущего радиуса отрисовки, чтобы окружность казалось плавной, a_min/a_max начальный и конечный углы арки, конечный угол подбираем так, чтобы он всегда недотягивал 3 сегмента до начала. Добавляем немного красок, тогда получается эффект как на анимации.
Код
const size_t num_segments = _CalcCircleAutoSegmentCount(radius);
float start = ImAbs(ImSin(ImGui::GetTime() * 1.8f) * (num_segments - 5));
const float a_min = IM_PI * 2.0f * (start) / num_segments;
const float a_max = IM_PI * 2.0f * (num_segments - 3) / num_segments;
for (size_t i = 0; i < num_segments; i++) {
const float a = a_min + (i / num_segments) * (a_max - a_min);
PathLineTo(ImVec2(centre.x + ImCos(a + ImGui::GetTime() * speed) * radius,
centre.y + ImSin(a + ImGui::GetTime() * speed) * radius));
}
Если заблокировать начало и конец арки на заданных углах, получаются вполне обычные спинеры, хочешь с подложкой, хочешь без. Чтобы не писать сложный код, рисуется в два прохода, сначала подложку, потом само тело спинера, для начала арки используем текущее время.
Код
const size_t num_segments = _CalcCircleAutoSegmentCount(radius);
float start = ImGui::GetTime() * speed;
const float bg_angle_offset = IM_PI * 2.f / num_segments;
for (size_t i = 0; i <= num_segments; i++) {
const float a = start + (i * bg_angle_offset);
PathLineTo(ImVec2(centre.x + ImCos(a) * radius, centre.y + ImSin(a) * radius));
}
PathStroke(bg, false, thickness);
const float angle_offset = angle / num_segments;
for (size_t i = 0; i < num_segments; i++) {
const float a = start + (i * angle_offset);
PathLineTo(ImVec2(centre.x + ImCos(a) * radius, centre.y + ImSin(a) * radius));
}
Если поменять сплошную отрисовку на точки, то без изменения основой части логики, получится уже другой спинер. Поверх можно наложить сплошную линию, тогда прогресс будет более явно отображен.
Код
float start = ImGui::GetTime() * speed;
const float bg_angle_offset = IM_PI * 2.f / dots;
dots = min(dots, 32);
for (size_t i = 0; i <= dots; i++) {
float a = start + (i * bg_angle_offset);
a = ImFmod(a, 2 * IM_PI);
AddCircleFilled(ImVec2(centre.x + ImCos(-a) * radius, centre.y + ImSin(-a) * radius), thickness / 2, color, 8);
}
window->DrawList->PathClear();
const float d_ang = (mdots / dots) * 2 * IM_PI;
const float angle_offset = (d_ang / dots);
for (size_t i = 0; i < dots; i++) {
const float a = start + (i * angle_offset);
PathLineTo(ImVec2(centre.x + ImCos(a) * radius, centre.y + ImSin(a) * radius));
}
Красивый эффект получается, если арку поверх точек отрисовать не полностью, а точками, размер которых зависит от расстояния до центра арки. Смещаем угол начала арки против движения точек и отрегулируем её скорость, визуально кажется что точки перескакивают с одной на другую.
Код
float def_nextdot = 0;
float &ref_nextdot = nextdot ? *nextdot : def_nextdot;
auto thcorrect = [&thickness, &ref_nextdot, &mdots, &minth] (int i) {
const float nth = minth < 0.f ? thickness / 2.f : minth;
return ImMax(nth, ImSin(((i - ref_nextdot) / mdots) * IM_PI) * thickness);
};
for (size_t i = 0; i <= dots; i++) {
float a = start + (i * bg_angle_offset);
a = ImFmod(a, 2 * IM_PI);
float th = minth < 0 ? thickness / 2.f : minth;
if (ref_nextdot + mdots < dots) {
if (i > ref_nextdot && i < ref_nextdot + mdots)
th = thcorrect(i);
} else {
if ((i > ref_nextdot && i < dots) || (i < ((int)(ref_nextdot + mdots)) % dots))
th = thcorrect(i);
}
AddCircleFilled(ImVec2(centre.x + ImCos(-a) * radius, centre.y + ImSin(-a) * radius), th, color, 8);
}
На основе этой логики с дискретным отображением точки, можно придумать еще несколько спинеров, например с изменяющейся прозрачностью, размером точки или расстоянием между ними, или вообще рисовать не точку а линию. А чтобы точки более дискретно смещались надо отсекать дробную часть угла сегмента арки.
Код
float start = (float)ImGui::GetTime() * speed;
float astart = ImFmod(start, IM_PI / dots);
start -= astart; // дискретизация движения точки
const float bg_angle_offset = IM_PI / dots;
dots = ImMin<size_t>(dots, 32);
for (size_t i = 0; i <= dots; i++) {
float a = start + (i * bg_angle_offset);
ImColor c = color;
c.Value.w = ImMax(0.1f, i / (float)dots);
AddCircleFilled(ImVec2(centre.x + ImCos(a) * radius, centre.y + ImSin(a) * radius), thickness, c, 8);
}
Можно разместить точки в ряд и поиграться с синусом времени, завязав его на смещение по оси X\Y, прозрачность или размер точки. Все это будет давать разные эффекты, при практически одинаковой логике. А подменив точки на линии, можно вообще получить другой вид спинера.
Код
// Y
float a = start + (IM_PI - i * offset);
float sina = ImSin(a * heightSpeed);
float y = centre.y + sina * thickness * heightKoeff;
if (y > centre.y)
y = centre.y;
AddCircleFilled(ImVec2(pos.x + style.FramePadding.x + i * (thickness * nextItemKoeff), y), thickness, color, 8);
// Fade
float a = start + (IM_PI - i * offset);
ImColor c = color;
c.Value.w = ImMax(0.1f, ImSin(a * heightSpeed));
AddCircleFilled(ImVec2(pos.x + style.FramePadding.x + i * (thickness * nextItemKoeff), centre.y), thickness, c, 8);
// Radius
const float a = start + (IM_PI - i * offset);
const float th = thickness * ImSin(a * heightSpeed);
ImColor fade_color = color;
fade_color.Value.w = 0.1f;
AddCircleFilled(ImVec2(pos.x + style.FramePadding.x + i * (thickness * nextItemKoeff), centre.y), thickness, fade_color, 8);
AddCircleFilled(ImVec2(pos.x + style.FramePadding.x + i * (thickness * nextItemKoeff), centre.y), th, color, 8);
// Moving
const float a = start + (i * IM_PI / dots);
float th = thickness;
offset = ImFmod(start + i * (size.x / dots), size.x);
if (offset < thickness)
th = offset;
if (offset > size.x - thickness)
th = size.x - offset;
AddCircleFilled(ImVec2(pos.x + style.FramePadding.x + offset, centre.y), th, color, 8);
Если отрисовать подложку неравномерно, постепенно увеличивая ширину линии, то получится почти инь-янь. Можно поиграться с радиусом половинок, реверсивным или прямым движением.
Код
сonst float angle_offset = angle / num_segments;
const float th = thickness / num_segments;
for (size_t i = 0; i < num_segments; i++) {
const float a = startI + (i * angle_offset);
const float a1 = startI + ((i + 1) * angle_offset);
window->DrawList->AddLine(ImVec2(centre.x + ImCos(a) * radius, centre.y + ImSin(a) * radius),
ImVec2(centre.x + ImCos(a1) * radius, centre.y + ImSin(a1) * radius),
colorI,
th * i);
}
Если пустить тонкие арки вокруг подложки, тоже получим интересный эффект.
Код
for (size_t i = 0; i <= num_segments; i++) {
const float a = start + (i * bg_angle_offset);
PathLineTo(ImVec2(centre.x + ImCos(a) * radius1, centre.y + ImSin(a) * radius1));
}
PathStroke(bg, false, thickness);
const float angle_offset = angle / num_segments;
for (size_t arc_num = 0; arc_num < arcs; ++arc_num) {
window->DrawList->PathClear();
float arc_start = 2 * IM_PI / arcs;
for (size_t i = 0; i < num_segments; i++) {
const float a = arc_start * arc_num + start + (i * angle_offset);
PathLineTo(ImVec2(centre.x + ImCos(a) * radius2, centre.y + ImSin(a) * radius2));
}
PathStroke(color, false, thickness);
}
А можно рисовать вместо арок точки, минимум изменений логики, но спинер выглядит уже по другому. Последним затесался вариант с тремя арками, которые вращаются с разной скоростью, его код приводить не буду - он очень банальный. Но из этого эффекта, можно сделать другой, когда арки изменяют свою длину, вращаясь в одну или в противоположные стороны. В динамике выглядит лучше, чем в тексте.
Код
for (size_t i = 0; i <= 2 * num_segments; i++) { // белая арка растет быстрее красной
const float a = start + (i * angle_offset);
if (i * angle_offset > 2 * bofsset)
break;
PathLineTo(ImVec2(centre.x + ImCos(a) * radius1, centre.y + ImSin(a) * radius1));
}
for (size_t i = 0; i < num_segments / 2; i++) { // красная арка растет до половины
const float a = start + (i * angle_offset);
if (i * angle_offset > bofsset)
break;
PathLineTo(ImVec2(centre.x + ImCos(a) * radius2, centre.y + ImSin(a) * radius2));
}
Напоследок выложил оставшиеся виды, интересен может быть, разве что, первый: синус от времени считаем в диапазоне 0 - 720 градусов, пока угол находится в пределах одной арки меняем её прозрачность, или рисуем непрозрачной. Прошли полный круг, теперь делаем тоже самое, но все арки рисуем непрозрачные, а в том секторе, где сейчас находится синус от времени, плавно увеличиваем прозрачность.
Код
for (size_t arc_num = 0; arc_num < arcs; ++arc_num)
{
for (size_t i = 0; i <= num_segments + 1; i++) { // подложк
const float a = arc_angle * arc_num + (i * angle_offset) - IM_PI / 2.f - IM_PI / 4.f;
PathLineTo(ImVec2(centre.x + ImCos(a) * radius, centre.y + ImSin(a) * radius));
}
const float a = arc_angle * arc_num;
ImColor c = color;
if (start < IM_PI * 2.f) { // первый круг проходим на заполнение
c.Value.w = 0.f;
if (start > a && start < (a + arc_angle)) { // заполняем, пока угол в этой секции
c.Value.w = 1.f - (start - a) / arc_angle;
} else if (start < a) { // угол больше этой секции
c.Value.w = 1.f;
}
c.Value.w = ImMax(0.05f, 1.f - c.Value.w);
} else { // второй круг проходим на угасание
const float startk = start - IM_PI * 2.f;
c.Value.w = 0.f;
if (startk > a && startk < (a + arc_angle)) { // угасаем пока угол в этой секции
c.Value.w = 1.f - (startk - a) / arc_angle;
} else if (startk < a) {
c.Value.w = 1.f; // полностью угасли
}
c.Value.w = ImMax(0.05f, c.Value.w);
}
PathStroke(c, false, thickness);
}
Декларативный конструктор Александреску
Еще когда я только учился (ш)кодить, году эдак в 2000-01, наткнулся на статью Александреску про декларативный конструктор в журнале (MSDN magazine вроде, точно не помню). Суть такая - реализуем специальный тип конструктора, который принимает произвольное число параметров определенных типов и обрабатывает их в соответсвии с типом, а не положением в аргументах. Тогда это выглядело дико и непонятно и особого применения этой технике я не увидел, да и реализовано было через черную магию gcc и макросы, а в студии не завелось. Сейчас, на с++14, это делается в несколько строк кода.
В итоге получаем вот такого вида выражение:
ImSpinner::Spinner<e_st_angle>("SpinnerAng",
Radius{16.f},
Thickness{2.f},
Color{255, 255, 255},
BgColor{255, 255, 255, 128},
Speed{8 * velocity},
Angle{IM_PI});
и если поменять порядок аргументов в функции, то результат не меняется
ImSpinner::Spinner<e_st_angle>("SpinnerAng",
Angle{IM_PI},
Speed{8 * velocity},
BgColor{255, 255, 255, 128},
Color{255, 255, 255},
Thickness{2.f},
Radius{16.f});
Как набралось с десяток функций, подумал что declarative ctor вполне жизнеспособен в этом случае. Минусов тоже достаточно, взять хотя бы необходимость использоваться strong types, но статья была не об этом.
Благодарю, что дочитали.
З.Ы. не претендую на какую-то техническую значимость статьи и кода, иногда "мелкая залипательная фигня" пишется за пару вечеров, выложил на github (https://github.com/dalerank/imspinner) под MIT лицензией.
Комментарии (4)
perfect_genius
13.10.2022 20:31+1Нравится информативный спиннер Телеграмма, когда что-то загружается: длина полоски соответствует проценту загруженного.
AndreyDmitriev
Спасибо, у меня как раз маленький проект есть, где пригодится. Короткий вопрос, прежде чем я сам попробую - если опустить часть параметров, то они примут значение по умолчанию?
dalerank Автор
Верно, но надо проверить, чето я забыл потестировать это :(