Skip to content

4. Билдер и виджеты

AlexGyver edited this page Feb 25, 2024 · 1 revision

Панель управления (ПУ)

Основные понятия

  • Виджет - компнент графического интерфейса (кнопка, поле ввода..)
  • Панель управления (ПУ) - экран приложения, на котором отображаются виджеты
  • Билдер - функция в программе для устройства, в которой происходит сборка панели управления из виджетов

Билдер

// билдер
void build(gh::Builder& b) {
}

void setup() {
    hub.onBuild(build); // подключаем билдер
}

Библиотека будет вызывать эту функцию для отправки виджетов в приложение, для обработки действий с приложения и для чтения значений с виджетов. Особенности:

  • В билдере не должно быть задержек delay() и прочего блокирующего или долго выполняющегося кода! Билдер вызывается в обработчике ответа приложению, во время выполнения билдера приложение ждёт ответа
  • Во время выполнения билдера собирается строка с ответом приложению, поэтому внутри билдера рекомендуется максимально ограничить действия со String строками и динамическим выделением памяти (malloc(), new) - они будут приводить к фрагментации оперативной памяти

Типы данных

Для удобства в библиотеке используются "универсальные" типы данных. Компилятор сам понимает, что передали в функцию:

  • AnyText - строка в любом виде ("строка", F("строка"), char*, String)
  • AnyValue - любой целочисленный и дробный тип данных + строки в любом виде как у AnyText
  • AnyPtr - указатель (адрес) переменной любого типа (String, char*, все числа) + системные (Colors, Flags, Pos, Button, Log)

А также:

  • Pairs - база данных, библиотека Pairs. Можно передать как экземпляр Pairs, так и PairsFile

Виджеты

Создание и настройка практически всех виджетов производится по следующей схеме: вызывается виджет, у него могут быть личные параметры в скобках, затем через точку можно применить общие параметры:

void build(gh::Builder& b) {
    b.Widget();         // без параметров
    b.Widget(...);      // личные параметры
    b.Widget(...).param1(...).param2(...)...;   // + дополнительные параметры
}

Личные параметры

Все виджеты можно разделить на две группы: виджеты с переменной и виджеты со значением.

С переменной

  • ptr - к такому виджету можно подключить переменную любого типа, библиотека будет брать с неё значение при отправке виджета, а также записывать отправленное из приложения новое значение. К некоторым виджетам можно подключить переменную только определённого типа, см. документацию
  • name - уникальное имя виджета. Нужно для отправки обновлений или чтения из Pairs
Widget(AnyPtr ptr = nullptr);
Widget_(AnyText name = "", AnyPtr ptr = nullptr);

Примеры:

int i;
String s;
Pairs p;

void build(gh::Builder& b) {
    b.Input();              // без параметров
    b.Input_("inp");        // только имя
    b.Input(&i);            // только переменная
    b.Input_("inp2", &s);   // имя и переменная
    b.Input_("inp3", &p);   // имя и значение из Pairs
}

Со значением

  • text - значение, которое отображается на виджете
  • name - уникальное имя виджета. Нужно для отправки обновлений или чтения из Pairs
Widget(AnyValue text = "");
Widget_(AnyText name = "", AnyValue text = "");
Widget_(AnyText name = "", Pairs* pairs = nullptr);

Примеры:

int i;
String s;
Pairs p;

void build(gh::Builder& b) {
    b.Display();                // без параметров
    b.Display_("disp");         // только имя
    b.Display(i);               // только значение
    b.Display_("disp2", s);     // имя и значение
    b.Display_("disp3", &p);    // имя и значение из Pairs
}

Общие параметры

В библиотеке есть общий набор параметров, но виджеты поддерживают не все их одновременно, см. таблицу ниже. Также есть параметры самого блока виджета, они могут применяться к любому виджету.

Параметры блока

  • size(uint16_t width, uint16_t height = 0) - Ширина (относительно) и высота (px) виджета
  • label(AnyText str) - Заголовок виджета
  • noLabel(bool nolabel = true) - Убрать заголовок виджета
  • suffix(AnyText str) - Дополнительный заголовок виджета справа
  • noTab(bool notab = true) - Убрать задний фон виджета
  • square(bool square = true) - Сделать виджет квадратным
  • disabled(bool disable = true) - Отключить виджет
  • hint(AnyText str) - подсказка виджета. Пустая строка - убрать подсказку

Параметры виджета

Виджет/параметр text icon maxLen rows regex align range unit fontSize color click attach
Input
InputArea
Pass
Confirm
Prompt
Date
Time
DateTime
Slider
Spinner
Select
Color
Switch
SwitchIcon
Tabs
Button
Flags
Joystick
Dpad
Title
Label
Text
TextFile
Display
Image
Log
LED
Icon
Gauge
GaugeRound
GaugeLinear
Table

Также у каждого виджета есть скрытый параметр value - он входит в "личные" параметры в скобках функции виджета и является значением виджета

Например:

b.Input().label("My input");
b.Button().color(gh::Colors::Red).icon("f6ad");

Подключение переменной

Переменная передаётся по указателю. Для всех типов данных, кроме char[] для этого используется оператор &, т.к. массив уже является указателем на себя:

int num;
String str;
char charr[16];

b.Input(&num);
b.Input(&str);
b.Input(charr);

При использовании массивов всё аналогично:

int num[10];
String str[10];
char charr[10][16];

for (int i = 0; i < 10; i++) {
    b.Input(&num[i]);
    b.Input(&str[i]);
    b.Input(charr[i]);
}

Обработка действия

С активных виджетов (доступны параметры click и attach) можно получать сигнал об изменении значения в приложении.

click

Вызвать параметр click() - он вернёт true при действии. Вызывать его нужно последним в цепочке, т.к. он ломает цепочку (возвращает bool):

if (b.Button().click()) Serial.println("click 1");

attach bool

Подключить bool переменную - флаг:

bool flag = 0;

b.Button().attach(&flag);

if (flag) Serial.println("click 2");

attach Flag

Подключить gh::Flag переменную - флаг, данный флаг сам сбросится в false при проверке!

gh::Flag gflag;

b.Button().attach(&gflag);

if (gflag) Serial.println("click 3");

attach handler

Подключить функцию-обработчик:

// обработчик кнопки
void btn_cb() {
    Serial.println("click 4");
}

// обработчик кнопки с информацией о билде
void btn_cb_b(gh::Build& b) {
    Serial.print("click 5 from client ID: ");
    Serial.println(b.client.id);
}

void build(gh::Builder& b) {
    // подключить функцию-обработчик
    b.Button().attach(btn_cb);

    // подключить функцию-обработчик с инфо о билде
    b.Button().attach(btn_cb_b);
}

Вёрстка ПУ

В GyverHub виджеты располагаются по сетке, заполнение сетки идёт слева направо сверху вниз. Таким образом нельзя расположить виджет по конкретным координатам или указать конкретные ячейки сетки, также нельзя задать конкретный размер виджета. Это может показаться некоторым "ограничением" свободы действий, но в то же время сильно упрощает сборку и позволяет ПУ выглядеть одинаково хорошо на дисплеях разного размера. Система поддерживает вложенные контейнеры, что даёт возможность собирать интересные ПУ под свои задачи.

Контейнер ПУ

Основной контейнер ПУ - вертикальный (см. ниже):

void build(gh::Builder& b) {
    // мы находимся в вертикальном контейнере
    b.Button();
    b.Button();
}
Button
Button

Горизонтальный контейнер

Виджеты и контейнеры в нём будут располагаться слева направо по мере появления в программе. Есть несколько способов создания контейнера:

beginRow/endRow

Использовать функции beginRow() и endRow() для обозначения контейнера

void build(gh::Builder& b) {
    b.beginRow();   // начать контейнер
    b.Button();
    b.Button();
    b.endRow();     // ВАЖНО НЕ ЗАБЫТЬ ЕГО ЗАВЕРШИТЬ
}
void build(gh::Builder& b) {
    // для удобства можно обернуть контейнер в блок {}
    // функции beginRow() и beginCol() всегда возвращают true
    if (b.beginRow()) {
        b.Button();
        b.Button();

        b.endRow();  // завершить
    }
}

gh::Row

Создать объект класса gh::Row(Builder& b, uint16_t width = 1). Назвать его можно как угодно, объект сам откроет контейнер и закроет его при выходе за область определения:

void build(gh::Builder& b) {
    {
        gh::Row r(b);  // контейнер сам создастся здесь
        b.Button();
        b.Button();
    }  // контейнер сам закроется здесь
}

GH_ROW

Использовать макрос GH_ROW(builder, width)

void build(gh::Builder& b) {
    GH_ROW(b, 1,
            b.Button();
            b.Button();
    );
}

Все примеры выше дают такой вид:

Button Button

Вертикальный контейнер

Виджеты и контейнеры в нём будут располагаться сверху вниз по мере появления в программе. Для вертикального контейнера справедлив такой же синтаксис: beginCol(), endCol(), gh::Col(), GH_COL(). Например:

void build(gh::Builder& b) {
    {
        gh::Row r(b);
        b.Button();

        {
            gh::Col c(b);
            b.Button();
            b.Button();
        }
    }
}
Button Button
Button

Ширина виджета

Ширина виджетов задаётся в "долях" - отношении их ширины друг к другу: виджеты займут пропорциональное место во всю ширину контейнера:

void build(gh::Builder& b) {
    {
        gh::Row r(b);
        b.Slider().size(3);  // слайдер шириной 3
        b.Button().size(1);  // кнопка шириной 1
        b.Button();          // тоже 1
    }
}
    Slider     Button Button

Контейнеру тоже можно задать ширину

void build(gh::Builder& b) {
    {
        gh::Row r(b);
        {
            // этот контейнер будет в 2 раза шире...
            gh::Row r(b, 2);
            b.Button();
            b.Button();
        }
        {
            // ...чем этот
            gh::Row r(b, 1);
            b.Button();
            b.Button();
        }
    }
}
   Button       Button    Button Button

Динамические виджеты

Билдер собирает виджеты последовательно, поэтому вызов виджетов в цикле также будет работать, позволяя создавать динамические панели управления. Например:

void build(gh::Builder& b) {
    {
        gh::Row r(b);
        for (int i = 0; i < 5; i++) {
            if (b.Button().click()) {
                Serial.print("Button #");
                Serial.println(i);
            }
        }
    }
}

Выведет 5 кнопок с обработкой кликов на каждой.

Имена также можно задавать вручную, используя сложение строк. Но делать это не рекомендуется:

for (int i = 0; i < 5; i++) {
  b.Switch_(String(F("sw")) + i);
}

Для подключения переменных в таком случае разумно использовать массив:

uint16_t sliders[5];

void build() {
  for (int i = 0; i < 5; i++) {
    b.Slider(&sliders[i]);
  }
}

Меню

В приложении на экране устройства есть выпадающее меню, в него можно добавить свои пункты. Для этого нужно вызвать виджет Menu() с указанием списка пунктов через ;. Виджет можно вызвать только один раз за билдер! Можно сделать это в самом начале:

void build(gh::Builder& b) {
    b.Menu("Spinner;Slider;Input");
}

Выбор пункта меню можно отследить через опрос .click() как у обычного виджета

Клик по меню автоматически отправляет запрос на обновление ПУ

Текущий выбранный пункт меню можно прочитать из переменной GyverHub::menu (можно изменять вручную) или gh::Builder::menu() (только для чтения). По значению пункта меню можно выводить и скрывать виджеты. Самый очевидный способ - обернуть номер пункта меню в switch:

switch (b.menu()) {
    case 0:
        b.Spinner();
        break;
    case 1:
        b.Slider();
        break;
    case 2:
        b.Input();
        break;
}

Это будет работать, но с некоторыми особенностями:

  • Если ПУ открыто одновременно в двух и более приложениях и активны разные меню - значения виджетов "пересекутся", например спиннер будет управлять слайдером, так как виджеты с автоматическими именами получат одинаковые имена
  • Билдер не сможет прочитать и установить значение виджета из другого пункта меню, даже именованного

Поэтому рекомендуется применять другой механизм выборочной отрисовки виджетов - через b.show(). В функцию передаётся true, если нужно разрешить вывод виджетов, false - чтобы запретить. Вызов без аргумента - по умолчанию true:

b.show(b.menu() == 0);
b.Spinner();

b.show(b.menu() == 1);
b.Slider();

b.show(b.menu() == 2);
b.Input();

b.show();

Теперь билдер "знает" о всех виджетах, которые находятся в ПУ, и раздаёт им корректные автоматические имена. Состояние show просто показывает билдеру, нужно ли выводить виджеты в данный момент, а так все виджеты доступны билдеру для чтения и записи.

Вкладки

Также есть виджет Tabs, с помощью которого можно организовать "вкладки" на ПУ. Логика разбития ПУ на вкладки абсолютно такая же, как при работе с меню, но переменную с номером вкладки нужно создать самостоятельно:

void build(gh::Builder& b) {
    static byte tab;
    if (b.Tabs(&tab).text("Spinner;Slider;Input").click()) b.refresh(); // обновить по клику

    // далее switch(tab) или b.show(tab == x)...
}

Сохранение значений

Библиотека не занимается сохранением значений виджетов в энергонезависимой памяти, это полностью отдано под свободу действий программиста. Но есть некоторые инструменты, которые могут с этим помочь.

Сигнал об изменении

Билдер может просигналить о том, что значение какого то виджета изменилось. Это можно использовать для сохранения данных в памяти, например хранить в структуре и писать в EEPROM (например моя библиотека EEManager). Использование EEPROM на ESP крайне не рекомендуется, поэтому лучше использовать библиотеку FileData. Пример:

#include <Arduino.h>
#include <FileData.h>
#include <LittleFS.h>

struct Data {
  uint8_t spinner;
  uint16_t slider;
  char str[20];
};
Data data;

FileData data(&LittleFS, "/data.dat", 'A', &mydata, sizeof(mydata));

void build(gh::Builder& b) {
    b.Spinner(&data.spinner);
    b.Slider(&data.slider);
    b.Input(data.str);

    // если что то изменилось - запускаем обновление
    if (b.changed()) data.update();
}

void setup() {
    // .......

    data.read();
}

void loop() {
    // ......

    // сохранение в файл происходит по таймауту здесь
    data.tick();
}

Поддержка Pairs

Также GyverHub нативно поддерживает библиотеку Pairs - хранение данных в текстовом виде в формате ключ:значение, а версия PairsFile автоматически пишет данные в файл (по таймауту, чтобы снизить износ памяти). Использование очень простое: делается именованный виджет, а вместо переменной подключается экземпляр PairsFile:

#include <PairsFile.h>
PairsFile data(&LittleFS, "/data.dat", 3000);

void build(gh::Builder& b) {
    b.Input_("input", &data);

    // ещё парочку
    {
        gh::Row r(b);
        b.Slider_("slider", &data);
        b.Spinner_("spinner", &data);
        b.Switch_("switch", &data);
    }

    // выведем содержимое базы данных как текст
    // (Pairs также сама конвертируется в AnyText)
    b.Text(data);
}

void setup() {
    // ........

    // запустить и прочитать базу из файла
    data.begin();
}

void loop() {
    // ........

    // файл сам обновится по таймауту
    data.tick();
}

GyverHub будет сам читать значения из базы Pairs и сохранять в неё новые по указанным ключам (имя виджета). При изменении в приложении GyverHub сам инициирует обновление и сохранение файла.