Net-Base Списание

03.06.2026

Отзивчиви оформления в Delphi FMX: Breakpoints без хаос в Designer-а (с Layout-Router като фрагмент от изходен код)

Responsive Layouts FMX в практическа употреба бързо стават нестабилни: бурни преоразмерявания, смени на DPI, ротации и „Visible-Layouts“ създават дублирано състояние и Reflows, трудни за диагностика. Тази статия показва един Layout-Router с Breakpoints, който контролира UI-блокове по време на изпълнение...

03.06.2026

От темата в списанието към проектната практика

Подходящи страници за услуги и технологии към публикацията

Който в Delphi FireMonkey трябва да поддържа няколко форм-фактора, бързо стига до Responsive Layouts FMX – и не по-малко бързо до смес от Align-каскади, скрити layout-контейнери и дизайнерски workaround-и, които при следващата смяна на DPI или ротация се срутват. В развита бизнес софтуерна клиентска част това е особено неприятно: UI-то продължава да се развива, екипите се сменят и изведнъж логиката е закачена за визуални детайли.

Сърцевината на проблема: FireMonkey предлага много градивни елементи (напр. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), но няма „родна“ Breakpoint-система като в уеба. Възможно е да се реагира на промени в размера, но без ясна архитектура това води до „if Width < … then …“ разпилени из много форми.

Тази статия показва един Layout-Router: малък компонент, който централизира управлението на Breakpoints и прехвърля Controls (или цели layout-блокове) между предварително подготвени слотове. Целта: състоянията да се запазят, кодът да остане поддържим, а гранични случаи като ротация, вложени layout-и и re-entrancy да бъдат омекотени. Добавени са и няколко по-малко очевидни трика, които на практика правят разликата между „работи в демото“ и „работи стабилно в експлоатация“.

Защо Breakpoints в FMX са различни от тези в уеб

В уеб-лейаути Breakpoints обикновено са декларативни (CSS Media Queries). В FMX решенията за оформление обикновено се взимат императивно по време на изпълнение: при OnResize се превключва. Към това се прибавят специфики за платформата:

  • Физически пиксели vs. логически пиксели: ClientWidth/ClientHeight са в логически единици (зависят от скалирането). Смени на DPI (напр. Windows Per-Monitor-DPI) могат да предизвикат повторно пресмятане на оформленията, без „физически“ нещо да се е променило.
  • Ротация и Safe Areas: Мобилните платформи предоставят Insets (Notch/Safe Area) – в зависимост от ОС и устройство. „Breakpoint, базиран само на ширина“ често е късоглед, защото използваемата площ може да е по-малка от чистия размер на прозореца.
  • Layout-процес: FireMonkey изчислява оформленията в етапи. Ако промените Parent/Align в неправилен момент, възникват странични ефекти (напр. многократно пренареждане или трепкащи размери).

Layout-Router адресира това, като (1) раздвързва „кога“ (Resize/Scale/Rotation) от „как“ (правила за оформление) и (2) концентрира правилата на едно място. За техническите лидери най-важният ефект е: получават ясно, проверимо централизирано решение вместо множество локални частни случаи.

Архитектура: Layout-Router със слотове вместо създаване на контроли

Чистият трик за FMX: не създавайте динамично нови Controls, а прехвърляйте съществуващите Controls между Slots. Слотът е просто контейнер (напр. TLayout), който представлява област от UI-то: Sidebar, Toolbar, Content, Footer, Details-Pane.

Предимства в индивидуален корпоративен софтуер:

  • Състоянията се запазват (Edit-Text, позиция на скрол, селектирани елементи), защото инстанциите не се пресъздават.
  • По-малък риск от дублирано свързване на събития, таймери или bindings.
  • Правилата за оформление стават видими: „кой блок е в кой слот“ може да се проследи и прегледа за всеки Breakpoint.

Важно за практиката: Разделяйте UI-блоковете достатъчно грубо. Ако премествате 30 отделни контрола, самият списък с маршрути става източник на грешки. По-добри са контейнери като layFilterBar, layNavigation, layResultList, layDetails.

Фрагмент от изходния код: Breakpoint-Router за адаптивни оформления FMX

Следният код е предназначен като помощна единица, която можете да използвате във FMX-формуляри. Той изчислява един Breakpoint (XS/SM/MD/LG/XL) и премества дефинирани контроли в дефинирани slot-контейнери. Важни детайли:

  • Debounce чрез TThread.ForceQueue: няколко Resize-събития се обединяват в едно обновяване (по-малко трептене на UI, по-малко reflow-цикли).
  • Re-Entrancy-Schutz: обновяването на оформлението често само по себе си задейства Resize/Layout.
  • Optional: Orientierung (Portrait/Landscape) може да бъде включена в Breakpoint-логиката.
Delphi
unit NB.FMX.LayoutRouter;

interface

uses
  System.Classes, System.SysUtils, System.Types, System.Generics.Collections,
  FMX.Types, FMX.Controls;

type
  TNBLayoutBreakpoint = (bpXS, bpSM, bpMD, bpLG, bpXL);

  // Mapping: кое Control да се постави в кой слот (контейнер) за даден Breakpoint.
  TNBRoute = record
    Control: TControl;
    TargetSlot: TControl; // типично TLayout или TPresentedControl
    Align: TAlignLayout;
  end;

  TNBRouteList = TList<TNBRoute>;

  TNBGetBreakpointEvent = reference to function(const AClientSize: TSizeF): TNBLayoutBreakpoint;

  TNBLayoutRouter = class(TComponent)
  private
    FRoot: TControl;
    FPending: Boolean;
    FUpdating: Boolean;
    FCurrent: TNBLayoutBreakpoint;
    FOnGetBreakpoint: TNBGetBreakpointEvent;
    FRoutes: TObjectDictionary<Integer, TNBRouteList>;
    function KeyOf(const ABp: TNBLayoutBreakpoint): Integer;
    procedure RootResized(Sender: TObject);
    procedure ApplyPending;
    procedure ApplyRoutes(const ABp: TNBLayoutBreakpoint);
    function DefaultBreakpoint(const AClientSize: TSizeF): TNBLayoutBreakpoint;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;

    procedure AttachRoot(const ARoot: TControl);
    procedure DefineRoute(const ABp: TNBLayoutBreakpoint; const AControl, ASlot: TControl;
      const AAlign: TAlignLayout = TAlignLayout.Client);

    procedure Invalidate; // ръчно принудително презадаване
    property Current: TNBLayoutBreakpoint read FCurrent;
    property OnGetBreakpoint: TNBGetBreakpointEvent read FOnGetBreakpoint write FOnGetBreakpoint;
  end;

implementation

{ TNBLayoutRouter }

constructor TNBLayoutRouter.Create(AOwner: TComponent);
begin
  inherited;
  FRoutes := TObjectDictionary<Integer, TNBRouteList>.Create([doOwnsValues]);
  FCurrent := bpMD;
end;

destructor TNBLayoutRouter.Destroy;
begin
  if Assigned(FRoot) then
    FRoot.OnResize := nil;
  FRoutes.Free;
  inherited;
end;

procedure TNBLayoutRouter.AttachRoot(const ARoot: TControl);
begin
  if FRoot = ARoot then
    Exit;

  if Assigned(FRoot) then
    FRoot.OnResize := nil;

  FRoot := ARoot;
  if Assigned(FRoot) then
    FRoot.OnResize := RootResized;

  Invalidate;
end;

procedure TNBLayoutRouter.DefineRoute(const ABp: TNBLayoutBreakpoint; const AControl,
  ASlot: TControl; const AAlign: TAlignLayout);
var
  LKey: Integer;
  LList: TNBRouteList;
  LRoute: TNBRoute;
begin
  if (AControl = nil) or (ASlot = nil) then
    raise EArgumentNilException.Create('Control/Slot не трябва да са nil');

  LKey := KeyOf(ABp);
  if not FRoutes.TryGetValue(LKey, LList) then
  begin
    LList := TNBRouteList.Create;
    FRoutes.Add(LKey, LList);
  end;

  LRoute.Control := AControl;
  LRoute.TargetSlot := ASlot;
  LRoute.Align := AAlign;
  LList.Add(LRoute);
end;

function TNBLayoutRouter.KeyOf(const ABp: TNBLayoutBreakpoint): Integer;
begin
  Result := Ord(ABp);
end;

procedure TNBLayoutRouter.RootResized(Sender: TObject);
begin
  Invalidate;
end;

procedure TNBLayoutRouter.Invalidate;
begin
  if (FRoot = nil) or FUpdating then
    Exit;

  // Debounce: прилагане само веднъж в рамките на Message-Loop
  if FPending then
    Exit;

  FPending := True;
  TThread.ForceQueue(nil,
    procedure
    begin
      ApplyPending;
    end);
end;

procedure TNBLayoutRouter.ApplyPending;
var
  LBp: TNBLayoutBreakpoint;
  LSize: TSizeF;
begin
  if (FRoot = nil) then
    Exit;

  if not FPending then
    Exit;

  FPending := False;

  LSize := TSizeF.Create(FRoot.Width, FRoot.Height);

  if Assigned(FOnGetBreakpoint) then
    LBp := FOnGetBreakpoint(LSize)
  else
    LBp := DefaultBreakpoint(LSize);

  if LBp = FCurrent then
    Exit;

  ApplyRoutes(LBp);
  FCurrent := LBp;
end;

procedure TNBLayoutRouter.ApplyRoutes(const ABp: TNBLayoutBreakpoint);
var
  LList: TNBRouteList;
  LRoute: TNBRoute;
begin
  if FUpdating then
    Exit;

  FUpdating := True;
  try
    if not FRoutes.TryGetValue(KeyOf(ABp), LList) then
      Exit;

    // Внимание: смяната на Parent променя Z-Order.
    // Ако редът е важен, извикайте DefineRoute в желаната последователност.
    for LRoute in LList do
    begin
      if (LRoute.Control.Parent <> LRoute.TargetSlot) then
        LRoute.Control.Parent := LRoute.TargetSlot;

      // Align задавайте чак след Parent, иначе Bounds може да се интерпретират по различен начин.
      LRoute.Control.Align := LRoute.Align;
      LRoute.Control.Visible := True;
    end;

  finally
    FUpdating := False;
  end;
end;

function TNBLayoutRouter.DefaultBreakpoint(const AClientSize: TSizeF): TNBLayoutBreakpoint;
var
  W: Single;
begin
  W := AClientSize.cx;
  // Breakpoints умишлено груби, тъй като целевите платформи на FMX варират значително.
  if W < 480 then Exit(bpXS);
  if W < 768 then Exit(bpSM);
  if W < 1024 then Exit(bpMD);
  if W < 1440 then Exit(bpLG);
  Result := bpXL;
end;

end.

Как да използвате Router-а във форма

Дефинирате слотове като TLayout (напр. layTop, layLeft, layContent) и след това регистрирате за всеки Breakpoint къде кои блокове да стоят. Типично е Sidebar и панелът с подробности при малки Breakpoint-и да се подредят един под друг.

Delphi
procedure TFrmMain.FormCreate(Sender: TObject);
begin
  FRouter := TNBLayoutRouter.Create(Self);
  FRouter.AttachRoot(LayoutRoot); // z. B. ein TLayout, das Client-aligned ist

  // XS: alles untereinander
  FRouter.DefineRoute(bpXS, layToolbar, slotTop, TAlignLayout.Top);
  FRouter.DefineRoute(bpXS, laySidebar, slotContent, TAlignLayout.Top);
  FRouter.DefineRoute(bpXS, layDetails, slotContent, TAlignLayout.Top);
  FRouter.DefineRoute(bpXS, layMain,    slotContent, TAlignLayout.Client);

  // MD: Sidebar links, Details rechts
  FRouter.DefineRoute(bpMD, layToolbar, slotTop,    TAlignLayout.Top);
  FRouter.DefineRoute(bpMD, laySidebar, slotLeft,   TAlignLayout.Client);
  FRouter.DefineRoute(bpMD, layMain,    slotCenter, TAlignLayout.Client);
  FRouter.DefineRoute(bpMD, layDetails, slotRight,  TAlignLayout.Client);

  // Optional: eigene Breakpoint-Logik
  FRouter.OnGetBreakpoint :=
    function(const S: TSizeF): TNBLayoutBreakpoint
    begin
      if (S.cx < 700) or (S.cy < 420) then
        Result := bpSM
      else
        Result := bpMD;
    end;
end;

Контекст: Защо „препривързването“ често е по-стабилно от превключване на Visible

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

  • Дублирано binding/събития: Две подобни контроли трябва да се поддържат синхронизирани (напр. две филтърни ленти).
  • Ред на табулация и фокус: При превключване фокусът може да се изгуби или да попаднете в невидими контроли, ако TabStop/HitTest са неблагоприятно настроени.
  • Разминаване на състоянието: Позициите на скрол, състоянията на селекция или редактираните текстове могат да се разминават.

Препривързването запазва инстанцията единна. Важно е да се режат блоковете на оформленията така, че да могат да се преместват независимо (напр. „страничен панел“ като собствен контейнер вместо много отделни контроли). Това се отплаща при поддръжка и анализ на грешки: дебъгвате една инстанция, а не два паралелни сенчести интерфейса.

Често срещани проблеми в практиката (и как да ги дебъгнете)

1) Поток от Resize-събития и повторно влизане (Re-Entrancy)

FMX задейства OnResize не само при промяна на размера от потребителя, но и при смяна на стил, промяна на родителя и понякога при промяна на DPI. Без дебаунс приложението може да попадне в цикли на пренареждане. Рутерът използва TThread.ForceQueue, за да отложи промените до следващия UI-тут.

Съвет за дебъг: логване (напр. чрез OutputDebugString) с брейкпойнт, размер и брояч на обновяванията помага да се открият Reflow-цикли. Ако допълнително логвате момента, в който ApplyRoutes започва и завършва, бързо ще видите дали един единствен Resize „каскадира“.

2) Z-Order, HitTest и „скрити“ блокиращи кликове

Смяната на родителя променя Z-Order. Ако оувърлеите (напр. Flyouts) вече не приемат кликове, често причината е, че над тях има контейнер с Client-aligned и HitTest е активиран. Вариант: предвидете умишлено отделен слот най-отгоре за overlay-площи и само там променяйте родителя на тези контроли. В FMX HitTest (дали контрол прихваща мишка/тъч събития) по-често е причината, отколкото видимостта.

3) TGridPanelLayout и процентни размери

TGridPanelLayout може да предизвика неочаквани презизчисления при процентни колони/редове в комбинация с Align=Client и динамично пренареждане. Ако трябва да използвате Grid, поставете Grid в слот и пренареждайте само цели Grid-блокове, не Grid-деца. Това намалява комбинаториката на проходите на оформлението.

4) Fokus, virtuelle Tastatur und „springende“ Eingabefelder

Краен случай, който се проявява в мобилни FMX-приложения и също на Windows-таблети: при пренареждане фокусирано Edit-Control може временно да загуби своя Parent. Това може да затвори виртуалната клавиатура или да нулира курсора. Практически се е доказало: преди Routing да запазите текущия фокус временно (Focused/IFMXFocusControl), след Routing (в същия UI-Tick) да възстановите фокуса. Това е особено полезно при форми за въвеждане, които превключват между „двуколонно“ (Tablet/PC) и „едноколонно“ (Phone).

Varianten: Breakpoints nach Formfaktor statt nur nach Breite

В реални мултиплатформени клиенти „ширината“ сама по себе си често не е правилният сигнал. Смислени варианти:

  • Ширина и височина: много плоски прозорци (напр. касови терминали, разделени екрани) изискват други правила.
  • Ориентация: Landscape на таблети често е подобен на десктоп, Portrait по-скоро „мобилен“.
  • Полезна площ в Safe-Area: В iOS/Android ефективно използваемата височина може значително да се намали от системните ленти. Който разглежда само Height, понякога маршрутизира „твърде късно“.

Рутерът е умишлено построен така, че да можете да подмените функцията за Breakpoints. Това е полезно и в Legacy-ситуации, когато една и съща форма работи в няколко хоста (напр. веднъж като обикновен прозорец, веднъж в вграден контейнер).

Ungewöhnlich sauber: Layout-Routing als „Transaktion“

При по-големи екрани проблемът се дължи не толкова на самите Breakpoints, колкото на последователността на UI операциите. Практичен модел е да третирате Routing-а като транзакция: първо да решите, след това да пренаредите, и накрая да изпълните страничните ефекти (Visibility, фокус, обновяване на данните) подредено.

Конкретно това означава: избягвайте отделни контроли по време на пренареждане да задействат собствени събития, които от своя страна стартират оформлението или достъп до данни. В FMX това се случва например, когато при смяна на Parent се извикват OnEnter/OnExit или когато LiveBinding-Ausdruck се преразглежда вследствие на Bounds-Update. Ако наблюдавате такива ефекти, помага централен „Updating“-ключ (както в Router-а) плюс ясен пост-стъп: едва след ApplyRoutes скъпи операции могат да се изпълняват (напр. презареждане на списък, свързване на детайлния изглед).

Особено при клиенти с REST-достъп това е релевантно: нежелано Reload по време на Resize може да доведе до излишни заявки. В LAN това не се усеща, но при VPN или мобилна връзка веднага.

Wann sich der Ansatz lohnt – und wo er Grenzen hat

Layout-Routerът си заслужава, когато:

  • едно FMX-приложение просъществува с години и няколко разработчика работят върху същите екрани,
  • UI-блоковете могат да се разделят ясно (Sidebar/Details/Content),
  • имате нужда от възпроизводими правила за Breakpoints, вместо ad-hoc Align-Tuning.

Ограниченията се появяват, когато един екран трябва да бъде силно „fluid“ (много динамични плочки, реални Masonry-оформления). Тогава TFlowLayout/TGridPanelLayout или собствени класове за оформление са по-подходящи. Също така, ако много отделни контроли превключват между слотове, поддържането на маршрутите става непрегледно – тогава е по-добре да се режат по-големи блокове или да се въведе декларативен слой за конфигурация (например JSON-конфигурация за присвояване на слотове, която се зарежда при стартиране).

Заключение: За Responsive оформления във FMX „Umhängen mit Breakpoints“ е прагматично междинно решение: по-малко хаос в дизайнера, ясни правила, стабилни състояния. То не заменя обмислена UI-структура, но ви дава надеждна рамка, за да развивате FMX-клиенти в дигитални корпоративни решения контролирано през различни форм-фактори.

Ако в съществуващо Delphi- или FMX-приложение искате да приложите такава архитектура на оформлението чисто, без да рискувате UI-регресии в експлоатационни сценарии, можете технически да го обсъдим с нас: обсъдете проект или модернизационно начинание с Net-Base.

В професионалния контекст също Delphi Fmx Breakpoints и Firemonkey Layout играят важна роля, когато интеграции, потоци от данни и по-нататъшно развитие трябва да се съгласуват безпроблемно.

Обсъдете проект или модернизационно начинание с Net-Base.

Следваща стъпка

Когато темата прерасне в реален проект, архитектурата, съществуващото състояние и експлоатацията трябва да бъдат разгледани съвместно още в ранна фаза.

Подпомагаме не само при отделни въпроси, но и когато от фрагменти от изходен код, проблеми с наследени системи или идеи за портал трябва да бъде реализиран надежден корпоративен проект.

  • Сегашното състояние, целевото състояние и техническите рискове се оценяват съвместно.
  • REST, достъпът до данни, порталите и разгръщането не се отлагат като по-късни последици.
  • Виждате рано кой път е икономически и експлоатационно жизнеспособен.

Сподели публикацията

Споделете тази публикация директно

LinkedIn, X, XING, Facebook, WhatsApp и имейл са незабавно достъпни. За Instagram ще подготвим връзка и кратък текст.

Електронна поща

Instagram се отваря в нов раздел. Връзката и краткият текст се копират предварително в клипборда.