В проекте Data Keeper мне удалось в полной мере реализовать концепцию динамического пользовательского интерфейса для отдельно взятой формы редактирования объекта efmObject – внешний вид формы полностью определяется структурой редактируемого объекта и требует минимальной настройки со стороны разработчика или пользователя:

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

Структура данных

Доработана структура данных, добавлены новые поля:

  • vproperty.fieldHeight – высота элемента на форме редактирования в юнитах
  • vproperty.fieldLeft – флаг привязки поля к левой части экрана
  • vproperty.is_required – флаг обязательности ввода данных
  • class.efmWidth – ширина формы редактирования экземпляра класса для данного класса
  • class.efmHeight – высота формы редактирования экземпляра класса для данного класса

Модули

Для улучшения архитектуры приложения я вынес весь функционал по созданию и работе с элементами формы редактирования объекта efmObject в отдельные файлы-модули, которые разместил в новой папке-ветке модулей Script\UI. Чтобы не подключать каждый новый модуль по отдельности, я использовал модуль UIClass.pas как коннектор.

// виртуальные компоненты для формы редактирования и фильтров
// 03.06.2024
// регистрация модулей

uses
  'UI\UIConstVar.pas', // константы и переменные
  'UI\UIEdit.pas', // текст
  'UI\UIEditInteger.pas', // целое число
  'UI\UIEditReal.pas', // число с плавающей запятой
  'UI\UIRef.pas',   // ссылка + модальный справочник
  'UI\UIComboBox.pas', // ссылки + выпадающий список
  'UI\UIChecker.pas', // флажок
  'UI\UIMemo.pas', // многострочный текст
  'UI\UIDate.pas', // даты
  'UI\UIWebLink.pas'; // ссылка на веб-ресурс

begin
end.Code language: Delphi (delphi)

Каждый компонент содержит в себе основу – панель с заголовком и компонентом для ввода текста. Этот компонент используется для сохранения в базу данных значения свойства объекта.

Дополнительные визуальные компоненты (комбобокс, датапикер и т.д.) служат для удобства пользователя. Визуально они перекрывают основной компонент. Результат выбора переводится в строковое значение и записывается в компонент для ввода текста.

Скрипты

Реализация обработчика события на открытие формы редактирования объекта стала более лаконичной и реализует принцип адаптивной верстки формы редактирования: каждый элемент размещается на своей панели-подложке, которые перемещаются по форме редактирования и растягиваются в зависимости от размера самой формы.

procedure efmObject_OnShow(Sender: TObject; Action: string);
// отображение формы редактирования
var
  i: integer;
  tmpSQL: string;
  tmpIDClass: integer;
  tmpCount: integer;
  tmpLabel: TdbLabel;
  tmpEdit: TdbEdit;
  tmpForm: TForm;
  tmpParent: TWinControl;
  tmpDataSet: TDataSet;
  tmpID: string;
  tmpControlID: integer;
  tmpIDClassList: string;
  tmpParentList: string;
  tmpChildList: string;
  tmpTableForm: TForm;
  tmpFirstControl: TWinControl;
  tmpControl: TWinControl;
  tmpLineTop: integer;
  tmpBox: TScrollBox;  
begin
  try
    tmpFirstControl := nil;
    tmpForm := TForm(Sender);
    CForm(ActiveGrid, tmpTableForm);
    if Action = 'NewRecord' then
    begin
      FindC(tmpTableForm, 'labIDClass', tmpLabel);
      efmObject.cmbClass.dbItemID := tmpLabel.Tag;
    end;
    // название класса - в заголовке окна
    tmpForm.Caption := efmObject.cmbClass.Text;
    // скроллбокс - для вертикальной прокрутки области редактирования
    FindC(tmpForm,'srbMain',tmpBox,False);
    if tmpBox = nil then
    begin
      tmpBox := TScrollBox.Create(tmpForm);
      with tmpBox do
      begin
        parent := efmObject.panEdit;
        name := 'srbMain';
        align := alClient;
        borderStyle := bsNone;
        VertSCrollBar.Tracking := True;
      end;
      // это нужно для корректной работы ограничений размеров ( Constraints.MinWidth )
      efmObject.panToolbar.align := alBottom;
      efmObject.panEdit.align := alClient;
    end;
    tmpParent := tmpBox;
    // Удалить все компоненты - вариант исключает повторное использование компонент, но упрощает логику их работы.
    for i := tmpParent.ControlCount - 1 downto 0 do
    begin
      tmpParent.Controls[i].Free;
    end;
    tmpIDClass := efmObject.cmbClass.dbItemID;
    // восстанавливаем размер
    tmpSQL := 'SELECT coalesce(efmWidth,600) as width, coalesce(efmHeight,400) as height FROM class WHERE id = '+IntToStr(tmpIDClass);
    SQLQuery(tmpSQL, tmpDataSet);
    tmpForm.ClientWidth := tmpDataSet.FieldByName('width').asInteger;
    tmpForm.ClientHeight := tmpDataSet.FieldByName('height').asInteger;
    tmpDataSet.Free;
    // выборка основных данных
    GetChildAndParent(tmpIDClass, tmpParentList, tmpChildList);
    tmpCount := 0;
    tmpSQL := //
      ' SELECT ' + CR + //
      '   coalesce(vproperty.is_detail,0) as is_detail, ' + CR + //
      '   coalesce(vproperty.fieldWidth,1) as fieldWidth, ' + CR + //
      '   coalesce(vproperty.fieldHeight,1) as fieldHeight, ' + CR + //
      '   coalesce(vproperty.fieldLeft,0) as fieldLeft, ' + CR + //
      '   cproperty.id_ptype, ' + CR + //
      '   cproperty.id, ' + CR + //
      '   cproperty.name, ' + CR + //
      '   cproperty.is_name, ' + CR + //
      '   class.name as cname, ' + CR + //
      '   class.id_uicontrol, ' + CR + //
      '   class.id as ClassID ' + CR + //
      ' FROM cproperty ' + CR + //
      ' LEFT JOIN class ON class.id = cproperty.id_class1 ' + CR + //
      ' LEFT JOIN vproperty ON vproperty.id_cproperty = cproperty.id AND vproperty.id_class = ' +
      IntToStr(tmpIDClass) + CR + //
      ' WHERE cproperty.id_class in (' + tmpParentList + ') AND vproperty.fieldVisible = 1 ' + CR + //
      ' ORDER BY vproperty.FieldOrderNum';
    tmpLineTop := 0;
    SQLQuery(tmpSQL, tmpDataSet);
    while not tmpDataSet.EOF do
    begin
      tmpControlID := tmpDataSet.FieldByName('id_uicontrol').asInteger;
      // сначала создаем базовый TdbEdit
      tmpControl := UIEdit_Create( tmpEdit, tmpCount, efmObject.btnSave.dbGeneralTableId, tmpDataSet.FieldByName('id').asInteger, tmpDataSet.FieldByName('id_ptype').asInteger,  tmpParent,
        tmpLineTop, App_Scale(8), App_Scale(tmpDataSet.FieldByName('fieldWidth').asInteger * UI_CELL_WIDTH), App_Scale(tmpDataSet.FieldByName('fieldHeight').asInteger * UI_CELL_HEIGHT),
        tmpDataSet.FieldByName('fieldLeft').asInteger = 1, tmpDataSet.FieldByName('name').asString, tmpDataSet.FieldByName('is_name').asInteger = 1 );
      // затем добавляем нужные настройки или элементы UI
      case tmpControlID of
      UI_EDIT_INTEGER: tmpControl := UIEditInteger_Create( tmpEdit );
      UI_EDIT_REAL: tmpControl := UIEditReal_Create( tmpEdit );
      UI_CHECKER: tmpControl := UIChecker_Create( tmpEdit, tmpCount );
      UI_REF: tmpControl := UIRef_Create( tmpEdit, tmpCount, tmpDataSet.FieldByName('ClassID').asInteger, efmObject.btnSave.dbGeneralTableId, tmpDataSet.FieldByName('id').asInteger );
      UI_COMBOBOX: tmpControl :=  UIComboBox_Create( tmpEdit, tmpCount, tmpDataSet.FieldByName('ClassID').asInteger, efmObject.btnSave.dbGeneralTableId, tmpDataSet.FieldByName('id').asInteger, tmpDataSet.FieldByName('is_detail').asInteger = 1, Action );
      UI_DATE: tmpControl :=  UIDate_Create( tmpEdit, tmpCount );
      UI_WEBLINK: tmpControl :=  UIWebLink_Create( tmpEdit, tmpCount );
      UI_MEMO: tmpControl :=  UIMemo_Create( tmpEdit, tmpCount );
      end; // case
      if tmpCount = 0 then  // фокус на первый элемент
        tmpFirstControl := tmpControl;
      inc(tmpCount);
      tmpLineTop := tmpLineTop + tmpEdit.Height * 2;
      tmpDataSet.Next;
    end;
    tmpDataSet.Free;
    UI_Resize(Sender); // корректировка размещения элементов
    if tmpFirstControl <> nil then
      tmpFirstControl.SetFocus; // фокус на первый элемент
  except
    RaiseException('efmObject_OnShow() ' + ExceptionMessage);
  end;
end;Code language: Delphi (delphi)

Улучшения коснулись процедуры создания элементов фильтрации PrepareFilterPanel(), которая представляла собой наглый копипаст efmObject_OnShow(). Методы efmObject_btnSave_OnAfterClick() и GetTextFilterValue() также были переделаны для работы с новой архитектурой элементов редактирования и отображения данных.

Логика работы процедуры UI_Resize подробно описана в статье “Адаптивная верстка“, отмечу лишь, что данные для работы алгоритма (ширина и высота элемента, а также его привязка к левому краю) хранятся в свойстве компонентов, куда они попадают при создании основного элемента в методе UIEdit_Create().

procedure UI_Resize(Sender: TObject);
var
  tmpBox: TScrollBox;
  tmpForm: TForm;
  tmpPan: TdbPanel;
  tmpPredPan: TdbPanel;
  tmpNumber: integer;
  tmpX: integer;
  tmpY: integer;
  tmpWidth: integer;
  tmpHeight: integer;
  tmpHeightStep: integer;
  tmpMaxWidth: integer;
  tmpBreak: boolean;
  tmpStBar: TStatusBar;
  tmpButton: TdbButton;

  procedure StretchW( APan:TdbPanel);
  // растянуть панель по ширине до правого края
  begin
    APan.Width := tmpBox.Width - 20 - APan.Left;
  end;

  procedure StretchH( APan:TdbPanel);
  // растянуть панель по высоте до нижнего края
  begin
    APan.Height := tmpBox.Height - APan.Top;
  end;

begin
  tmpForm := TForm(Sender);
  FindC(tmpForm,'srbMain',tmpBox,False);
  if tmpBox = nil then // первый вызов resize для формы происходит до её отображения, поэтому скроллбокса может не быть
    exit; // в этом случае происходит досрочное завершение работы данной процедуры
  tmpBox.VertSCrollBar.Position := 0; // чтобы не было глюка при растягивании формы
  tmpMaxWidth := 0;
  tmpNumber := 0;
  tmpPredPan := nil;
  tmpX := 0;
  tmpY := 0;
  tmpHeightStep := 0;
  FindC(tmpForm,'panData_'+inttostr(tmpNumber),tmpPan, False);
  while tmpPan <> nil do
  begin
    with tmpPan do
    begin
      // вытаскиваем нужные для алгоритма данные из свойств компонентов
      tmpWidth := constraints.MinWidth; // ширина
      if tmpWidth > tmpMaxWidth then
        tmpMaxWidth := tmpWidth;
      tmpHeight := constraints.MinHeight; // высота
      tmpBreak := ShowCaption; // привязка к левому краю
      if (tmpX <> 0) and ( tmpBreak or ((tmpX + tmpWidth) > (tmpBox.Width - 20) )) then // условие переноса на следующую линию
      begin
        tmpX := 0;
        tmpY := tmpY + tmpHeightStep;
        tmpHeightStep := tmpHeight;
        StretchW( tmpPredPan );
      end
      else
      begin
        if tmpHeight > tmpHeightStep then
         tmpHeightStep := tmpHeight;
      end;
      top := tmpY;
      left := tmpX;
      width := tmpWidth;
      height := tmpHeight;
      tmpX := tmpX + width;
    end;
    tmpPredPan := tmpPan;
    inc(tmpNumber);
    FindC(tmpForm,'panData_'+inttostr(tmpNumber),tmpPan, False);
  end;
  if tmpPredPan <> nil then
    StretchW( tmpPredPan );
  if (tmpPredPan <> nil) and (tmpPredPan.Height > UI_CELL_HEIGHT) and ( (tmpPredPan.Top + tmpHeight) < tmpBox.Height  ) then
    StretchH( tmpPredPan );
  efmObject.panEdit.Constraints.MinWidth := tmpMaxWidth + 20; // добавить 20 для вертикального скроллбара
  tmpBox.Constraints.MinHeight := UI_CELL_HEIGHT;
end;

procedure efmObject_OnResize (Sender: TObject);
begin
  UI_Resize(Sender);
end;

Code language: Delphi (delphi)

Компоненты UI

Добавлен компонент для редактирования многострочного текста – UIMemo. Реализован с помощью двух методов:

  • UIMemo_Create() – создание компонента
  • UIMemo_OnChange() – обработка ввода данных

Создание подразумевает добавление на панель-подложку экземпляра компонента TdbMemo таким образом, чтобы он перекрыл TdbEdit и растянулся на всю доступную часть подложки. UIMemo может растягиваться по вертикали, для этого в настройках свойства нужно указать высоту компонента больше, чем 1 юнит.

Результат

У свойства с типом “Многострочный текст” можно указать высоту больше 1 (в данном случае – 4), а также прижать его к левому краю
В результате элементы расположились оптимальным образом, а компонент Memo занял максимальный размер по высоте и ширине.
Другой класс имеет свои настройки для свойств.
И эти настройки определяют внешний вид формы редактирования для экземпляра данного класса.

Что дальше?

С точки зрения отработки технологических приемов при работе с объектной структурой Data Keeper будет полезным создание отображение древовидной структуры. Само по себе это не сложно, но могут быть нюансы, связанные с работой фильтрации данных (когда дерево разрушается и превращается в обычную таблицу), а также с алгоритмами частичной загрузки дерева (аналог пейджинга для таблиц).

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

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

Можно поэкспериментировать с языком сборки синтетических объектов. Это когда значения свойств объектов определяется вычислениями и различными манипуляциями с другими объектами. В просторечии это называется отчетами и аналитикой.

Нужно сделать пользовательскую документацию – хотя Data Keepr и претендует на дружественность, как минимум нужны методологические рекомендации по использованию программы. Проектная документация тоже нужна.

Но, по хорошему, давно пора переводить все эти наработки на рельсы Delphi, потому как My Visual Database больше не развивается, а имеющиеся в MVDB ограничения плохо вписываются в концепции, которые мне хочется реализовать в проекте Data Keeper.

Ссылки

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *