Od teme magazina do projektne prakse
Povezane stranice usluga i tehnologije za članak
Ko u Delphi FireMonkey mora podržavati više formfaktora, brzo završi kod Responsive Layouts FMX – i podjednako brzo i u mješavini Align-kaskada, skrivenih layout-kontejnera i designer-workarounda koji se raspadnu pri sljedećoj promjeni DPI-ja ili rotacije. U zrelim poslovnim softverskim klijentima to je posebno neugodno: UI se dalje razvija, timovi se mijenjaju i iznenada logika ovisi o vizualnim detaljima.
Suština problema: FireMonkey nudi mnogo gradivnih elemenata (npr. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), ali nema „nativni“ breakpoint-sistem kao na webu. Može se reagirati na promjene veličine, ali bez jasne arhitekture to završi u „if Width < … then …“ raširenim po mnogim formama.
Ovaj članak prikazuje Layout-Router: malu komponentu koja centralno upravlja breakpoint-ovima i prevezuje Controls (ili čitave blokove layouta) između pripremljenih slotova. Cilj: stanja ostaju sačuvana, kod je održiv, a rubni slučajevi poput rotacije, ugniježđenih layouta i re-entrancyja se ublažavaju. Uz to dolazi nekoliko manje očitih trikova koji u praksi čine razliku između „radi u demo-u“ i „radi stabilno u produkciji“.
Zašto su Breakpointi u FMX drugačiji nego na webu
U web-layoutima su breakpointi obično deklarativni (CSS Media Queries). U FMX su odluke o layoutu u runtimeu tipično imperativne: na OnResize se prebacuje. Uz to postoje specifičnosti po platformi:
- Device-Pixel vs. logische Pixel:
ClientWidth/ClientHeightsu u logičkim jedinicama (ovisno o skaliranju). DPI-promjene (npr. Windows Per-Monitor-DPI) mogu ponovno pokrenuti layout bez da se „fizički“ išta promijenilo. - Rotacija i Safe Areas: Mobilne platforme daju insets (Notch/Safe Area) – ovisno o OS-u i uređaju. „Breakpoint samo prema širini“ često je premalo, jer je upotrebljivi prostor manji od čiste veličine prozora.
- Layout-pas: FireMonkey izračunava layoute u fazama. Ako se roditelj/Align mijenja u pogrešnom trenutku, nastaju nuspojave (npr. višestruki reflow ili treperenje veličina).
Layout-Router rješava to tako što (1) odvaja „kada“ (Resize/Scale/Rotation) od „kako“ (pravila layouta) i (2) koncentrira pravila na jedno mjesto. Za tehničke leadove najvažniji efekt je jasan, provjerljiv centar odluka umjesto mnogih lokalnih posebnih slučajeva.
Arhitektura: Layout-Router sa slotovima umjesto stvaranja Controls
Čist trik za FMX: ne dinamički stvarati Controls, već postojeće Controls premještati između slotova. Slot je jednostavno kontejner (npr. TLayout) koji predstavlja područje UI-ja: bočna traka (sidebar), alatna traka (toolbar), sadržaj (content), podnožje (footer), panel detalja (details-pane).
Prednosti u prilagođenom poslovnom softveru:
- Stanja ostaju sačuvana (pozicija u polju za uređivanje, pozicija skrolanja, izabrani elementi), jer se instance ne rekonstruiraju.
- Manje rizika od duple vezanosti događaja, timera ili bindings.
- Pravila layouta postaju vidljiva: „koji blok se nalazi u kojem slotu“ može se po Breakpointu pratiti i provjeriti.
Važno za praksu: podijelite UI-blokove dovoljno grubo. Ako premještate 30 pojedinačnih kontrola, sama lista ruta postat će izvor grešaka. Bolje su kontejneri kao što su layFilterBar, layNavigation, layResultList, layDetails.
Izvorni isječak: Breakpoint-Router za responzivne rasporede FMX
Sljedeći kod je zamišljen kao pomoćna jedinica koju možete koristiti u FMX-formama. On izračunava breakpoint (XS/SM/MD/LG/XL) i premješta definisane Controls u definisane Slot-Container. Važni detalji:
- Debounce preko
TThread.ForceQueue: više Resize-Events se objedini u jedno ažuriranje (manje treperenja korisničkog sučelja, manje reflow-petlji). - Zaštita od re-entrancyja: ažuriranje layouta često samo ponovno pokreće Resize/Layout.
- Opcionalno: orijentacija (Portrait/Landscape) može se uključiti u logiku breakpointa.
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);
// Mapiranje: koji Control treba u koji Slot (kontejner) za dati Breakpoint.
TNBRoute = record
Control: TControl;
TargetSlot: TControl; // tipično TLayout ili 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; // ručno ponovno izračunavanje
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 ne smiju biti 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: izvršiti samo jednom po message-loopu
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;
// Pažnja: promjena Parent-a mijenja Z-order.
// Ako je redoslijed važan, pozvati DefineRoute u željenom redoslijedu.
for LRoute in LList do
begin
if (LRoute.Control.Parent <> LRoute.TargetSlot) then
LRoute.Control.Parent := LRoute.TargetSlot;
// Align postaviti tek nakon Parent-a, inače se Bounds mogu drugačije interpretirati.
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;
// Breakpointi namjerno grubi, jer se ciljane FMX platforme značajno razlikuju.
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.
Kako koristiti Router u formi
Definišete slotove kao TLayout (npr. layTop, layLeft, layContent) i zatim po Breakpointu registrujete gdje koji blokovi stoje. Tipično je da Sidebar i Details-Pane u malim breakpointima jedna ispod druge.
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: Zašto je „Umhängen“ često stabilnije od prebacivanja svojstva Visible
Uobičajen pristup je imati za svaku varijantu odvojena stabla layouta i samo Visible prebacivati. U Designeru to djeluje praktično, ali ima tipične nuspojave:
- Dvostruko Binding/Events: Dva slična kontrola moraju se održavati sinhronizirano (npr. dvije trake filtera).
- Redoslijed tabulatora i fokus: Prilikom prebacivanja fokus se gubi ili završite u nevidljivim kontrolama ako TabStop/HitTest stoje nepovoljno.
- State Drift: Pozicije skrolanja, stanja selekcije ili uređeni tekstovi divergiraju.
Umhängen održava instancu jedinstvenom. Važno je rezati layout-blokove tako da se mogu nezavisno premještati (npr. „Sidebar“ kao zaseban kontejner umjesto mnogo pojedinačnih kontrola). Upravo se to isplati pri održavanju i analizi grešaka: debugujete jednu instancu, ne dvije paralelne sjenke UI-a.
Zamke u praksi (i kako ih debugovati)
1) Resize-oluje i Re-Entrancy
FMX pokreće OnResize ne samo pri korisničkom mijenjanju veličine, već i pri promjenama stila, promjenama parenta i ponekad pri promjenama DPI-ja. Bez Debounce-a aplikacija zapada u layout-petlje. Router koristi TThread.ForceQueue da pomjeri promjene u sljedeći UI-tick.
Savjet za debugovanje: logovanje (npr. preko OutputDebugString) sa breakpointom, veličinom i brojačem update-a pomaže pronaći reflow-petlje. Ako dodatno logujete trenutak kada ApplyRoutes počinje i završava, brzo ćete vidjeti da li se pojedinačni Resize „kaskadira“.
2) Z-Order, HitTest i „nevidljivi“ blokatori klikova
Promjena parenta mijenja Z-Order. Ako Overlays (npr. Flyouts) više ne primaju klikove, često je uzrok to što se iznad njih nalazi Client-aligned kontejner i HitTest je aktivan. Varijanta: za overlay-površine namjerno predvidite zaseban slot na vrhu i samo tamo postavite parent za takve kontrole. U FMX-u je HitTest (da li kontrola hvata miš-/touch-događaje) češći uzrok nego sama vidljivost.
3) TGridPanelLayout i procentualne veličine
TGridPanelLayout može kod postotnih kolona/reda u kombinaciji s Align=Client i dinamičkim premještanjem izazvati neočekivane preracunavanja. Ako morate koristiti Grid, smjestite Grid u Slot i premještajte samo cijele Grid-blokove, ne Grid-djecu. To smanjuje kombinatoriku layout-pasa.
4) Fokus, virtuelna tastatura i „poskakujuća“ polja za unos
Rubni slučaj koji se javlja u mobilnim FMX-apps i također na Windows-tabletima: pri premještanju fokusirani Edit-Control može nakratko izgubiti parent. To može zatvoriti virtuelnu tastaturu ili resetirati kursor. U praksi se pokazalo korisnim: prije routinga privremeno sačuvati trenutni fokus (Focused/IFMXFocusControl), nakon routinga (u istom UI-ticku) fokus vratiti. To se posebno isplati kod obrazaca za unos koji preraspoređuju između „dvodijelnog“ (Tablet/PC) i „jednodijelnog“ (Phone) prikaza.
Varijante: Breakpoints prema formfaktoru umjesto samo prema širini
U stvarnim multiplatform-klijentima „širina“ često nije jedini odgovarajući signal. Korisne varijante:
- Širina i visina: vrlo plitki prozori (npr. kase-terminale, podijeljeni ekrani) trebaju drugačija pravila.
- Orijentacija:
Landscapena tabletima je često „slično desktopu“, Portrait više „mobilno-like“. - Iskoristivi prostor Safe-Area: Na iOS/Androidu efektivna upotrebljiva visina može se značajno smanjiti sistemskim trakama. Tko gleda samo
Height, ponekad routa „prekasno“.
Router je namjerno građen tako da možete zamijeniti funkciju Breakpoint-a. To je korisno i u legacy-situacijama, kada isti formular radi u više hostova (npr. jednom kao normalan prozor, jednom u ugrađenom containeru).
Neobično čisto: Layout-Routing kao „Transakcija“
Na većim ekranima problem manje leži u samim Breakpoint-ima, a više u redoslijedu UI-operacija. Praktičan obrazac je tretirati routing kao transakciju: prvo odlučiti, zatim premjestiti, pa tek onda uredno izvršiti nuspojave (vidljivost, fokus, osvježavanje podataka).
To konkretno znači: izbjegavajte da pojedina Controls tijekom premještanja sami triggeruju evente koji potom pokreću layout ili pristup podacima. U FMX to se događa npr. kada pri promjeni parenta fire-uje OnEnter/OnExit ili kada se LiveBinding-izraz ponovo evaluira zbog bounds-update-a. Ako primijetite takve efekte, pomaže centralni „Updating“ prekidač (kao u Routeru) plus jasan naknadni korak: tek nakon ApplyRoutes smiju se pokretati skupi procesi (npr. ponovno učitavanje liste, bindiranje detaljnog prikaza).
Posebno je relevantno za klijente s REST-pristupom: neplanirani reload tokom resize-a može generirati nepotrebne zahtjeve. To u LAN-u često nije vidljivo, ali preko VPN-a ili mobilno odmah jesu.
Kada se pristup isplati – i gdje ima ograničenja
Layout-Router se isplati kada:
- FMX-aplikacija živi godinama i više razvijača radi na istim ekranima,
- UI-blokovi se mogu jasno odvojiti (Sidebar/Details/Content),
- trebate reproducibilna pravila za Breakpoints, umjesto ad-hoc podešavanja Align-a.
Granice postaju vidljive kada ekran mora biti izrazito „fluidan“ (mnogo dinamičkih pločica, pravi Masonry-izgledi). Tada su TFlowLayout/TGridPanelLayout ili vlastite klase layouta prikladnije. Također, ako se vrlo mnogo pojedinačnih kontrola prebacuje između slotova, održavanje ruta postaje neuredno – onda je bolje koristiti veće blokove ili uvesti deklarativni sloj konfiguracije (npr. JSON-konfiguracija za dodjele slotova koja se učitava pri pokretanju).
Zaključak: Za responzivne rasporede u FMX-u, „Umhängen mit Breakpoints“ je pragmatičan kompromis: manje nereda u dizajnu, jasna pravila, stabilna stanja. Ne zamjenjuje promišljenu UI-strukturu, ali pruža robustan okvir kojim možete kontrolisano dalje razvijati FMX-klijente u digitalnim korporativnim rješenjima preko različitih form-faktora.
Ako u postojećoj Delphi- ili FMX-aplikaciji želite čisto implementirati takvu arhitekturu layouta, bez rizika od UI-regresija u operativnim scenarijima, rado to možemo tehnički razvrstati s vama: razgovarajte o projektu ili zahvatu modernizacije s Net-Base.
U stručnom kontekstu Delphi Fmx Breakpoints i Firemonkey Layout također igraju važnu ulogu kada integracije, tokovi podataka i dalji razvoj moraju funkcionirati usklađeno.
Razgovarajte o projektu ili modernizacijskom zahvatu s Net-Base.
Sljedeći korak
Ako se tema pretvori u stvarni projekat, arhitekturu, postojeći sistem i operacije trebalo bi rano zajednički razmotriti.
Pružamo podršku ne samo pri pojedinačnim pitanjima, već i kada iz fragmenata izvornog koda, naslijeđenih sistema ili ideja za portal treba nastati robustan poslovni projekat.
- Postojeće stanje, ciljno stanje i tehnički rizici procjenjuju se zajedno.
- REST, pristup podacima, portali i Rollout neće se odgađati za kasnije faze.
- Pravovremeno prepoznajete koji pristup je ekonomski i operativno održiv.