Net-Base Магазин

03.06.2026

Респонзивни распореди у Delphi FMX: Breakpoints без хаоса у дизајнеру (са Layout-Router као пример изворног кода)

Responsive распореди у FMX у пракси брзо постају крхки: чести resize-догађаји, промене DPI, ротације и „Visible-Layouts“ изазивају двоструко стање и рефлове које је тешко дебаговати. Овај чланак показује Layout-Router са Breakpoints који динамички контролише UI-блокове у време извршавања.

03.06.2026

Од теме часописа до пројектне праксе

Одговарајуће странице услуга и техничке странице за чланак

Ко у Delphi у FireMonkey-ју мора да подржава више формата, брзо долази до Responsive Layouts FMX – и подједнако брзо до мешавине Align-каскада, скривених layout-контејнера и дизайнерских workarounds, који падну при следећој промени DPI-ја или ротације. У развијеним бизнис софтвер клијентима то је посебно непријатно: UI се даље развија, тимови се мењају и одједном логика зависи од визуелних детаља.

Језгро проблема: FireMonkey нуди много грађевних блокова (нпр. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), али нема „нативни“ Breakpoint-System као на вебу. Може се реаговати на промене величине, али без јасне архитектуре то се завршава у „if Width < … then …“ распоређеном по многим формама.

Овај чланак показује један Layout-Router: малу компоненту која централизовано управља Breakpoints и премешта Controls (или целе layout-блокове) између припремљених слотова. Циљ: стања остају сачувана, код је одржив, а ивићни случајеви као ротација, угнежђени layout-и и реентрантност се ублажавају. Уз то иду неки мање очигледни трикови који у пракси праве разлику између „ради у демо-у“ и „ради стабилно у раду“.

Зашто су Breakpoints у FMX другачији него на вебу

У веб-лејаутима су Breakpoints углавном декларативни (CSS Media Queries). У FMX-у су одлуке о layout-у обично импeративне у време извођења: при OnResize се пребацује. Поред тога постоје платформи-специфичности:

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

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

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

Чист трик за FMX: не динамички креирати контроле, већ премештати постојеће контроле између слотова. Слот је једноставно контејнер (нпр. TLayout), који представља област UI-а: бочна трака, трака са алаткама, садржај, подножје, панел детаља.

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

  • Стања остају сачувана (садржај поља за унос, положај скрола, селектовани елементи), јер се инстанце не реконструишу.
  • Мање ризика од дуплих веза догађаја, тајмера или биндинга.
  • Правила распореда постају видљива: „који блок се налази у ком слоту“ може се по сваком Breakpoint-у праћити и ревидирати.

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

Исечак изворног кода: Breakpoint-Router за респонзивне распореде FMX

Следећи код је намењен као помоћна јединица коју можете користити у FMX формама. Он израчунава Breakpoint (XS/SM/MD/LG/XL) и премешта дефинисане контроле у дефинисане слот-контенере. Важни детаљи:

  • Debounce преко TThread.ForceQueue: више Resize-догађаја се сабије у једно ажурирање (мање тресења UI-ја, мање рефлоу-петљи).
  • Заштита од реентранције: ажурирање распореда често само покреће поновно Resize/Layout.
  • Опционо: оријентација (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);

  // Мапирање: који Control треба у који Slot (контенер) за одређени 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-редослед.
    // Ако је редослед важан, позовите 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;
  // Breakpoint-ови намерно груби, јер се циљне платформе 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.

Како користити рутер у форми

Дефинишете слотове као TLayout (нпр. layTop, layLeft, layContent) и затим по breakpoint-у региструјете где се који блок налази. Уобичајено је да се Sidebar и Details-Pane у мањим 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 пребацује. То делује удобно у дизајнеру, али има типичне нежељене последице:

  • Дупло привезивање/догађаји: Две сличне контроле морају бити синхронизоване (нпр. две траке филтера).
  • Поредак табова и фокус: При пребацивању губите фокус или завршите у невидљивим контролама ако су TabStop/HitTest неповољно подешени.
  • Неподударност стања: Положаји скролова, стања селекције или уређени текстови могу да се разликују.

Премештање задржава јединствену инстанцу. Важно је структуирати блокове распореда тако да се могу независно померати (нпр. „Sidebar“ као посебан контејнер уместо многих појединачних контрола). Управо то се исплати у одржавању и анализи грешака: дебагујете једну инстанцу, а не две паралелне сенке UI-ја.

Проблеми у пракси (и како их дебаговати)

1) Resize-бујице и реентрантност

FMX иницира OnResize не само при корисничком мењању величине, већ и при промени стила, промени Parent-а и делом при промени DPI-ја. Без дебаунса апликација упада у петље распореда. Рутер користи TThread.ForceQueue да помери измене у следећи UI-tick.

Савет за дебаговање: логовање (нпр. преко OutputDebugString) са breakpoint-ом, величином и бројачем ажурирања помаже да се пронађу рефлоу-петље. Ако додатно забележите време када ApplyRoutes почиње и завршава, брзо ћете видети да ли један Resize „каскадира”.

2) Z-Order, HitTest и „невидљиви“ блокатори кликова

Промена Parent-а мења Z-Order. Ако Overlays (нпр. Flyouts) више не примају кликове, често је узрок то што изнад лежи Client-aligned контејнер са активним HitTest-ом. Опција: за overlay-површине намерно предвидите посебан слот на самом врху и само тамо мењајте parent таквих контрола. У FMX-у је HitTest (да ли контрола хвата миш-/тач-догађаје) чешћи узрок него видљивост.

3) TGridPanelLayout и процентуалне величине

TGridPanelLayout може да покрене неочекивана прерачунавања при процентуалним колонама/редовима у комбинацији са Align=Client и динамичким премештањем. Ако морате да користите Grid, поставите Grid у слот и премештајте само читаве Grid-блокове, не Grid-децу. То смањује комбинаторику пролаза распореда.

4) Фокус, виртуелна тастатура и „скачућа“ поља за унос

Ранд-случај који се појављује у мобилним FMX-апликацијама и такође на Windows-Tablets: при премештању фокусиран Edit-Control може привремено изгубити Parent. То може затворити виртуелну тастатуру или вратити позицију курсора. Практично се показало: пре рутирања привремено сачувати тренутни фокус (Focused/IFMXFocusControl), а после рутирања (у истом UI-Tick) вратити фокус. То посебно има смисла код уносних маски које се мењају из „zweispaltig“ (Tablet/PC) у „einspaltig“ (Phone).

Варијанте: Breakpoints по форм-фактору уместо само по ширини

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

  • Ширина и висина: веома плитки прозори (нпр. POS-терминали, подељени екрани) захтевају другачија правила.
  • Оријентација: Landscape на таблетима је често „слично десктопу“, Portrait је уобичајено више у „мобилном“ режиму.
  • Safe-Area-Nutzfläche: На iOS/Android ефективно искоришћена висина може значајно да се смањи услед системских трака. Ко гледа само Height, понекад рутира „превише касно“.

Рутер је намерно дизајниран тако да можете заменити функцију Breakpoint-а. То је корисно и у legacy ситуацијама када иста форма ради у више хостова (нпр. једном као нормални прозор, другом као уграђени контејнер).

Необично чисто: Layout-Routing као „трансакција“

На већим екранима проблем мање зависи од самих Breakpoint-ова, а више од редоследа UI-операција. Практичан образац је третирати рутирање као трансакцију: прво одлучити, затим преместити, па уредно извршити нуспојаве (Visibility, фокус, освежавање података).

Конкретно то значи: избегавајте да појединачне контроле током премештања покрећу своје event-ове који затим покрећу распоред или приступ подацима. У FMX се то дешава, на пример, када при промени родитеља OnEnter/OnExit фајер-ује или када се LiveBinding-израз поново евалуира услед Bounds-апдејта. Ако приметите такве ефекте, помаже централни „Updating“ прекидач (као у рутеру) плус јасан пост-степ: само након ApplyRoutes смеју да се покрећу скупе операције (нпр. поновно учитавање листе, повезивање детаљног приказа).

Посебно код клијената са REST-приступом ово је релевантно: ненамерно поновно учитавање током resize-а може довести до непотребних захтева. То се у LAN-у не примећује, али преко VPN-а или на мобилној везама одмах.

Када се приступ исплати – и где има ограничења

Layout-Router се исплати када:

  • FMX апликација опстане годинама и више развијача ради на истим екранима,
  • UI-блокови се могу јасно раздвојити (Sidebar/Details/Content),
  • потребне су вам репродуцибилне Breakpoint-правиле уместо ad-hoc подешавања Align-а.

Границе постају видљиве када екран мора бити јако „fluid“ (много динамичких квачки, прави Masonry-распореди). Тада су TFlowLayout/TGridPanelLayout или сопствене класе распореда погодније. И када већи број појединачних контрола мења место између слотова, одржавање рута постаје непрегледно – онда је боље резати веће блокове или увући декларативни слој конфигурације (нпр. JSON-конфигурација за доделе слотова која се учитава при старту).

Закључак: За responsive распореде у FMX-у „Umhängen mit Breakpoints“ представља прагматичан средњи пут: мање хаоса у дизајнеру, јасна правила, стабилна стања. Он не замењује промишљену UI-структуру, али пружа робусан оквир да бисте FMX клијенте у дигиталним корпоративним решењима контролисано даље развијали преко форм-фактора.

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

У стручном контексту Delphi Fmx Breakpoints и Firemonkey Layout такође играју важну улогу када интеграције, токови података и даљи развој морају да се уклопе на уредан начин.

Разговарајте о пројекту или модернизационом предузећу са Net-Base.

Следећи корак

Када тема прерасте у реалан пројекат, архитектуру, постојеће системе и операције треба рано разматрати заједно.

Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.

  • Постојеће стање, циљано стање и технички ризици оцењују се заједно.
  • REST, приступ подацима, портали и роллаут се неће одлагати као накнадне последице.
  • Ви рано видите који пут је економски и оперативно одржив.

Подели објаву

Поделите ову објаву директно

LinkedIn, X, XING, Facebook, WhatsApp и е-пошта су одмах доступни. За Instagram припремамо линк и кратак текст.

Е-пошта

Инстаграм се отвара у новој картици. Линк и кратак текст се претходно копирају у међуспремник.