4. Билдер и виджеты
- Виджет - компнент графического интерфейса (кнопка, поле ввода..)
- Панель управления (ПУ) - экран приложения, на котором отображаются виджеты
- Билдер - функция в программе для устройства, в которой происходит сборка панели управления из виджетов
// билдер
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()
- он вернёт true
при действии. Вызывать его нужно последним в цепочке, т.к. он ломает цепочку (возвращает bool
):
if (b.Button().click()) Serial.println("click 1");
Подключить bool
переменную - флаг:
bool flag = 0;
b.Button().attach(&flag);
if (flag) Serial.println("click 2");
Подключить gh::Flag
переменную - флаг, данный флаг сам сбросится в false
при проверке!
gh::Flag gflag;
b.Button().attach(&gflag);
if (gflag) Serial.println("click 3");
Подключить функцию-обработчик:
// обработчик кнопки
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()
для обозначения контейнера
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(Builder& b, uint16_t width = 1)
. Назвать его можно как угодно, объект сам откроет контейнер и закроет его при выходе за область определения:
void build(gh::Builder& b) {
{
gh::Row r(b); // контейнер сам создастся здесь
b.Button();
b.Button();
} // контейнер сам закроется здесь
}
Использовать макрос 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();
}
Также 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 сам инициирует обновление и сохранение файла.