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/ClientHeightsą 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.
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.
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.