Net-Base Magazyn

03.06.2026

Responsywne układy w Delphi FMX: Breakpointy bez chaosu w Designerze (z Layout-Routerem jako fragment kodu źródłowego)

Responsywne układy FMX w praktyce szybko stają się kruche: burze zmian rozmiaru, przełączenia DPI, rotacja i „Visible-Layouts” powodują podwójny stan i trudno debugowalne przerysowania układu. Ten wpis pokazuje router układu z breakpointami, który kontroluje bloki interfejsu użytkownika w czasie wykonywania...

03.06.2026

Od tematu magazynowego do praktyki projektowej

Pasujące strony usługowe i techniczne do artykułu

Kto w Delphi FireMonkey musi obsługiwać kilka form faktorów, szybko trafia na Responsive Layouts FMX – i równie szybko na mieszankę kaskad Align, ukrytych kontenerów układu i obejść projektanta, które przy następnym przełączeniu DPI lub rotacji przestaną działać. W rozwijających się klientach oprogramowania biznesowego jest to szczególnie problematyczne: UI jest rozwijane dalej, zespoły się zmieniają i nagle logika zależy od detali wizualnych.

Sedno problemu: FireMonkey oferuje wiele elementów (np. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), ale nie ma „natywnego” systemu breakpointów jak w sieci. Można wprawdzie reagować na zmiany rozmiaru, ale bez jasnej architektury kończy się to rozproszonym „if Width < … then …“ w wielu formularzach.

Ten artykuł pokazuje Layout-Router: małą komponentę, która centralnie zarządza breakpointami i przewiesza Controls (lub całe bloki układu) między przygotowanymi slotami. Cel: stany pozostają zachowane, kod jest utrzymywalny, a przypadki brzegowe takie jak rotacja, zagnieżdżone layouty i Re-Entrancy są tłumione. Do tego dochodzi kilka mniej oczywistych sztuczek, które w praktyce decydują o różnicy między „działa w demo” a „działa stabilnie w eksploatacji”.

Warum Breakpoints in FMX anders sind als im Web

W układach webowych Breakpoints są zwykle deklaratywne (CSS Media Queries). W FMX decyzje dotyczące układu są typowo imperatywne w czasie wykonywania: w OnResize dokonuje się przełączenia. Do tego dochodzą cechy specyficzne dla platform:

  • Device-Pixel vs. logische Pixel: ClientWidth/ClientHeight są w jednostkach logicznych (zależnych od skalowania). Zmiany DPI (np. Windows Per-Monitor-DPI) mogą ponownie wywołać układ, bez fizycznej zmiany rozmiarów.
  • Rotation und Safe Areas: Platformy mobilne dostarczają insets (Notch/Safe Area) – zależne od OS i urządzenia. „Breakpoint oparty wyłącznie na szerokości” często jest zbyt krótkowzroczny, ponieważ powierzchnia użyteczna może być mniejsza niż nominalny rozmiar okna.
  • Layout-Pass: FireMonkey oblicza układy w fazach. Jeśli zmieni się Parent/Align w niewłaściwym momencie, powstają skutki uboczne (np. wielokrotny reflow lub migotanie rozmiarów).

Layout-Router rozwiązuje to, rozdzielając (1) „kiedy” (Resize/Scale/Rotation) od (2) „jak” (reguły layoutu) i koncentrując reguły w jednym miejscu. Dla liderów technicznych najważniejszy efekt jest taki: otrzymują klarowne, możliwe do zweryfikowania centrum decyzyjne zamiast wielu lokalnych wyjątków.

Architektur: Layout-Router mit Slots statt Control-Erzeugung

Czysty trik dla FMX: nie tworzyć dynamicznie kontrolek, lecz przewieszać istniejące Controls między Slots. Slot to po prostu kontener (np. TLayout), który reprezentuje obszar UI: pasek boczny, pasek narzędzi, zawartość, stopka, panel szczegółów.

Zalety w dedykowanym oprogramowaniu przedsiębiorstwa:

  • Stany są zachowane (edytowany tekst, pozycja przewijania, wybrane elementy), ponieważ instancje nie są przebudowywane.
  • Mniejsze ryzyko podwójnego podłączenia zdarzeń, timerów lub powiązań.
  • Reguły layoutu stają się widoczne: „który blok znajduje się w którym slocie” można prześledzić i poddać przeglądowi dla każdego breakpointu.

Praktyczna uwaga: dziel bloki UI wystarczająco grubo. Jeśli będziesz przekładać 30 pojedynczych kontrolek, sama lista tras stanie się źródłem błędów. Lepiej stosować kontenery takie jak layFilterBar, layNavigation, layResultList, layDetails.

Fragment źródłowy: Breakpoint-Router dla responsywnych układów FMX

Następujący kod jest przeznaczony jako moduł pomocniczy, który można stosować w formularzach FMX. Oblicza punkt przerwania (XS/SM/MD/LG/XL) i przenosi zdefiniowane kontrolki do zdefiniowanych kontenerów slotów. Ważne szczegóły:

  • Debounce za pomocą TThread.ForceQueue: kilka zdarzeń Resize jest łączonych w jedną aktualizację (mniej drgań UI, mniej pętli reflow).
  • Ochrona przed ponownym wejściem: aktualizacja układu często sama ponownie wywołuje Resize/Layout.
  • Opcjonalnie: orientacja (Portrait/Landscape) może być uwzględniona w logice breakpointów.
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);

  // Mapowanie: który Control ma być umieszczone w którym slocie (kontenerze) dla danego breakpointu.
  TNBRoute = record
    Control: TControl;
    TargetSlot: TControl; // typowo TLayout lub 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; // ręczne ponowne obliczenie
    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 nie mogą być 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: wykonać tylko raz na pętlę komunikatów
  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;

    // Uwaga: zmiana Parent modyfikuje Z-Order.
    // Jeśli kolejność ma znaczenie, wywołaj DefineRoute w pożądanej kolejności.
    for LRoute in LList do
    begin
      if (LRoute.Control.Parent <> LRoute.TargetSlot) then
        LRoute.Control.Parent := LRoute.TargetSlot;

      // Ustaw Align dopiero po ustawieniu Parent, w przeciwnym razie Bounds mogą być interpretowane inaczej.
      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;
  // Breakpointy celowo zgrubne, ponieważ platformy docelowe FMX znacznie się różnią.
  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.

Jak użyć Routera w formularzu

Definiuje się sloty jako TLayout (np. layTop, layLeft, layContent) i następnie rejestruje się dla każdego Breakpointa, gdzie które bloki mają się znajdować. Typowe jest, że Sidebar i panel szczegółów w małych Breakpointach ustawiają się jeden pod drugim.

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;

Kontekst: Dlaczego „przenoszenie” jest często stabilniejsze niż przełączanie Visible

Powszechne podejście to utrzymywanie oddzielnych drzew layoutu dla każdej warianty i jedynie przełączanie Visible. Wydaje się wygodne w designerze, ale ma typowe skutki uboczne:

  • Podwójne powiązania/zdarzenia: Dwa podobne kontrolki trzeba utrzymywać w synchronizacji (np. dwie listwy filtrów).
  • Kolejność tabulacji i fokus: Przy przełączaniu można stracić fokus lub trafić do niewidocznych kontrolek, jeśli TabStop/HitTest są niekorzystnie ustawione.
  • Dryf stanu: Pozycje przewijania, stany zaznaczeń lub edytowany tekst mogą się rozjeżdżać.

Przenoszenie zachowuje jedną instancję. Ważne jest, by dzielić bloki layoutu tak, aby można je było przesuwać niezależnie (np. „Sidebar“ jako oddzielny kontener zamiast wielu pojedynczych kontrolek). To procentuje przy utrzymaniu i analizie błędów: debugujesz jedną instancję, a nie dwa równoległe, „cieniowe” UI.

Pułapki w praktyce (i jak je debugować)

1) Burze zmian rozmiaru i Re-Entrancy

FMX wywołuje OnResize nie tylko przy zmianie rozmiaru przez użytkownika, ale też przy zmianie stylu, zmianie parenta oraz częściowo przy zmianie DPI. Bez mechanizmu debounce aplikacja wpada w pętle layoutu. Router używa TThread.ForceQueue, aby przesunąć zmiany do następnego ticku UI.

Wskazówka do debugowania: logowanie (np. przez OutputDebugString) z breakpointer, rozmiarem i licznikiem aktualizacji pomaga znaleźć pętle reflow. Jeśli dodatkowo zalogują Państwo moment, w którym ApplyRoutes się rozpoczyna i kończy, szybko zobaczycie, czy pojedyncza zmiana rozmiaru „kaskaduje”.

2) Z-order, HitTest i „niewidoczne” blokery kliknięć

Zmiana parenta zmienia Z-order. Jeśli overlaye (np. Flyouts) przestają reagować na kliknięcia, często wynika to z tego, że nad nimi leży kontener client-aligned z aktywnym HitTest. Rozwiązanie: dla obszarów overlay świadomie przewidzieć oddzielny slot najwyżej i tylko tam parentować takie kontrolki. W FMX HitTest (czy kontrolka przechwytuje zdarzenia myszy/touch) częściej jest przyczyną niż sama widoczność.

3) TGridPanelLayout i rozmiary procentowe

TGridPanelLayout może przy procentowych kolumnach/wierszach w kombinacji z Align=Client i dynamicznym przenoszeniem między rodzicami wywoływać nieoczekiwane przeliczenia. Jeśli musisz używać Grid, umieść Grid w slocie i przenoś tylko całe bloki Grid, nie dzieci Grid. To redukuje kombinatorykę przebiegów layoutu.

4) Fokus, wirtualna klawiatura i „podskakujące” pola wejściowe

Przypadek brzegowy, który występuje w mobilnych aplikacjach FMX i także na Windows-tabletach: przy przenoszeniu fokusowane kontrolki edycyjne mogą chwilowo stracić rodzica. To może zamknąć wirtualną klawiaturę lub zresetować kursor. Praktycznie sprawdza się: przed routingiem tymczasowo zapisać aktualny fokus (Focused/IFMXFocusControl), a po routingu (w tym samym ticku UI) przywrócić fokus. Ma to sens szczególnie w formularzach wejściowych, które przełączają się między „dwukolumnowymi“ (Tablet/PC) a „jednokolumnowymi“ (Phone).

Warianty: breakpointy według formfaktora zamiast tylko według szerokości

W realnych klientach multiplatformowych „sama szerokość“ często nie jest właściwym sygnałem. Sensowne warianty:

  • Szerokość i wysokość: bardzo płaskie okna (np. terminale kasowe, podzielone ekrany) potrzebują innych reguł.
  • Orientacja: orientacja pozioma na tabletach często jest podobna do desktopu, pionowa raczej typowo mobilna.
  • Użyteczna powierzchnia w obszarze bezpiecznym (Safe-Area): na iOS/Android efektywnie użyteczna wysokość może znacząco się zmniejszyć z powodu pasków systemowych. Kto bierze pod uwagę tylko Height, czasem routuje „za późno“.

Router jest świadomie zaprojektowany tak, aby można było wymienić funkcję breakpointów. To jest pomocne także w sytuacjach legacy, kiedy ten sam formularz działa w różnych hostach (np. raz jako normalne okno, raz w osadzonym kontenerze).

Niezwykle czysto: layout-routing jako „transakcja“

Na większych ekranach problem zwykle nie tkwi w samych breakpointach, lecz w kolejności operacji UI. Praktyczny wzorzec to traktować routing jako transakcję: najpierw podjąć decyzję, potem przenieść elementy, a następnie uporządkowanie efektów ubocznych (widoczność, fokus, odświeżenie danych).

Konkretnie: unikaj sytuacji, w których pojedyncze kontrolki podczas przenoszenia wywołują własne zdarzenia, które z kolei uruchamiają layout lub dostęp do danych. W FMX zdarza się to np. gdy przy zmianie rodzica odpala się OnEnter/OnExit lub wyrażenie LiveBinding jest ponownie ewaluowane przez update bounds. Jeśli obserwujesz takie efekty, pomaga centralny przełącznik „Updating“ (jak w routerze) oraz jasny krok końcowy: dopiero po ApplyRoutes mogą uruchamiać się kosztowne operacje (np. ponowne załadowanie listy, bindowanie widoku szczegółowego).

Szczególnie istotne przy klientach z dostępem do REST: niezamierzone ponowne załadowanie podczas zmiany rozmiaru może generować zbędne żądania. W sieci LAN tego nie widać, ale w VPN lub w sieci mobilnej od razu.

Kiedy podejście się opłaca – i gdzie ma ograniczenia

Layout-router opłaca się, gdy:

  • aplikacja FMX jest rozwijana przez lata i kilku programistów pracuje nad tymi samymi ekranami,
  • bloki UI można wyraźnie rozdzielić (Sidebar/Details/Content),
  • potrzebujesz odtwarzalnych reguł breakpointów, zamiast ad-hoc strojenia Align.

Granice widoczne są, gdy ekran musi być bardzo „fluid” (wiele dynamicznych kafelków, prawdziwe układy typu masonry). Wtedy lepiej sprawdzą się TFlowLayout/TGridPanelLayout lub własne klasy layoutu. Również gdy bardzo wiele pojedynczych kontrolek przełącza się między slotami, utrzymanie tras staje się nieprzejrzyste – wtedy lepiej podzielić na większe bloki lub wprowadzić deklaratywną warstwę konfiguracyjną (np. konfiguracja JSON dla przypisań slotów, ładowana przy starcie).

Wniosek: Dla responsywnych layoutów FMX „przełączanie z użyciem Breakpoints” to pragmatyczny kompromis: mniej chaosu w środowisku projektowym, jasne reguły, stabilne stany. Nie zastąpi to przemyślanej struktury UI, ale daje solidne rusztowanie pozwalające na kontrolowany rozwój klientów FMX w cyfrowych rozwiązaniach korporacyjnych w różnych formfaktorach.

Jeśli w istniejącej Delphi- lub FMX-aplikacji chcą Państwo taką architekturę layoutu wdrożyć poprawnie, bez ryzyka regresji UI w scenariuszach operacyjnych, chętnie omówimy to technicznie z Państwem: projekt lub przedsięwzięcie modernizacyjne z Net-Base.

W kontekście merytorycznym Delphi Fmx Breakpoints i Firemonkey Layout również odgrywają ważną rolę, gdy integracje, przepływy danych i dalszy rozwój muszą ściśle współgrać.

Omówić projekt lub przedsięwzięcie modernizacyjne z Net-Base.

Następny krok

Gdy temat stanie się rzeczywistym projektem, architekturę, stan istniejący i eksploatację należy wcześnie rozpatrywać wspólnie.

Wspieramy nie tylko w pojedynczych zagadnieniach, lecz także wtedy, gdy z fragmentów kodu źródłowego, kwestii związanych z systemami legacy lub koncepcji portalu ma powstać solidny projekt dla przedsiębiorstwa.

  • Stan istniejący, obraz docelowy i ryzyka techniczne są oceniane łącznie.
  • REST, dostęp do danych, portale i Rollout nie są odkładane na później.
  • Wcześnie widzą Państwo, która droga jest ekonomicznie opłacalna i operacyjnie wykonalna.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e‑mail są natychmiast dostępne. Dla Instagrama przygotowujemy od razu link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.