В одном из сайд-проектов с использованием 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)


  1. AndreyDmitriev
    12.10.2022 10:45

    Спасибо, у меня как раз маленький проект есть, где пригодится. Короткий вопрос, прежде чем я сам попробую - если опустить часть параметров, то они примут значение по умолчанию?

    ImSpinner::Spinner<e_st_angle>("SpinnerAng", 
                                    Angle{IM_PI}, 
                                    Speed{8 * velocity},  
                                    Thickness{2.f},
                                    Radius{16.f});
    


    1. dalerank Автор
      12.10.2022 10:49

      Верно, но надо проверить, чето я забыл потестировать это :(


  1. iliketech
    12.10.2022 11:33

    Плюсую за круглый лоадер! :)


  1. perfect_genius
    13.10.2022 20:31
    +1

    Нравится информативный спиннер Телеграмма, когда что-то загружается: длина полоски соответствует проценту загруженного.