Fra magasinets tema til projektpraksis
Passende service- og tekniske sider til artiklen
Hvis man i Delphi FireMonkey skal understøtte flere formfaktorer, ender man hurtigt hos Responsive Layouts FMX – og lige så hurtigt i en blanding af Align-kaskader, skjulte layout-containere og designer-workarounds, der bryder sammen ved næste DPI- eller rotationsskift. I veletablerede business-software-klienter er det særligt ubehageligt: UI’en videreudvikles, teams skifter, og pludselig hænger logik fast i visuelle detaljer.
Kernen i problemet: FireMonkey tilbyder mange byggeklodser (f.eks. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), men ikke et „naturligt“ Breakpoint-System som på webben. Man kan godt reagere på størrelsesændringer, men uden en klar arkitektur ender det i „if Width < … then …“ fordelt over mange Forms.
Denne artikel viser en Layout-Router: en lille komponent, der administrerer Breakpoints centralt og genmonterer Controls (eller hele layout-blokke) mellem forberedte Slots. Målet: tilstande bevares, koden er vedligeholdelsesvenlig, og kanttilfælde som Rotation, indlejrede Layouts og Re-Entrancy dæmpes. Derudover følger et par mindre oplagte kneb, som i praksis udgør forskellen mellem „läuft im Demo“ og „läuft stabil im Betrieb“.
Hvorfor Breakpoints i FMX er anderledes end på webben
I Web-Layouts er Breakpoints som regel deklarative (CSS Media Queries). I FMX er Layoutbeslutninger typisk imperative ved køretid: På OnResize skiftes der. Derudover kommer platformspecifikke særegenheder:
- Device-Pixel vs. logiske Pixel:
ClientWidth/ClientHeighter i logiske enheder (afhængig af skalering). DPI-skift (f.eks. Windows Per-Monitor-DPI) kan trigge layouts igen, uden at der fysisk ændrer sig noget. - Rotation og Safe Areas: Mobile platforme leverer Insets (Notch/Safe Area) – afhængig af OS og Device. Et „Breakpoint kun efter Breite“ er ofte for snævert, fordi den anvendelige flade er mindre end den rene vinduesstørrelse.
- Layout-Pass: FireMonkey beregner Layouts i faser. Hvis man ændrer Parent/Align på det forkerte tidspunkt, opstår der bivirkninger (f.eks. gentagne reflows eller flimrende størrelser).
En Layout-Router adresserer det ved at (1) frakoble det „Hvornår“ (Resize/Scale/Rotation) fra det „Hvordan“ (Layout-Regeln) og (2) koncentrere reglerne ét sted. For tekniske Leads er den vigtigste effekt: De får et klart, verificerbart beslutningscenter i stedet for mange lokale specialtilfælde.
Arkitektur: Layout-Router med Slots i stedet for Control-oprettelse
Det rene trick for FMX: ikke dynamisk genoprette Controls, men i stedet flytte eksisterende Controls mellem Slots. Et Slot er simpelt et Container (f.eks. TLayout), der repræsenterer et område af UI’en: Sidebar, Toolbar, Content, Footer, Details-Pane.
Fordele i individuel virksomhedssoftware:
- Tilstande bevares (Edit-Text, Scrollposition, selektierte Items), fordi instanser ikke genopbygges.
- Mindre risiko for dobbelt sammenkobling af Events, Timern eller Bindings.
- Layout-Regeln bliver synlige: „welcher Block liegt in welchem Slot“ lader sig for hver Breakpoint nachvollziehen und reviewen.
Vigtigt i praksis: Skær UI-blokkene til i grove enheder. Hvis du omflytter 30 enkeltkontroller, bliver rute-listen selv en fejlkilde. Bedre er containere som layFilterBar, layNavigation, layResultList, layDetails.
Kildesnip: Breakpoint-router til responsive FMX-layouts
Følgende kode er tænkt som en hjælpeenhed, som du kan bruge i FMX-forms. Den beregner et breakpoint (XS/SM/MD/LG/XL) og flytter definerede kontrolelementer ind i definerede slot-containere. Vigtige detaljer:
- Debounce over
TThread.ForceQueue: flere Resize-Events samles til én opdatering (mindre UI-rysten, færre reflow-sløjfer). - Re-Entrancy-beskyttelse: Layout-opdatering udløser ofte igen Resize/Layout.
- Valgfrit: Orientering (Portrait/Landscape) kan indgå i breakpoint-logikken.
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);
// Kortlægning: hvilket Control skal placeres i hvilken slot (container) for et breakpoint.
TNBRoute = record
Control: TControl;
TargetSlot: TControl; // typisk TLayout eller 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; // Manuel genberegning
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 må ikke være 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: kun én gang pr. 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;
// Bemærk: Skift af Parent ændrer Z-order.
// Hvis rækkefølgen er vigtig, kald DefineRoute i den ønskede rækkefølge.
for LRoute in LList do
begin
if (LRoute.Control.Parent <> LRoute.TargetSlot) then
LRoute.Control.Parent := LRoute.TargetSlot;
// Align skal sættes først efter Parent, ellers kan Bounds blive fortolket anderledes.
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 bevidst grove, da FMX-målplatforme varierer meget.
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.
Sådan bruger du routeren i en formular
Slots defineres som TLayout (f.eks. layTop, layLeft, layContent) og registreres derefter pr. breakpoint, hvor hvilke blokke placeres. Typisk er, at Sidebar og Details-Pane i små breakpoints flytter sig under hinanden.
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;Indplacering: Hvorfor „omparentering“ ofte er mere stabilt end Visible-skift
En udbredt tilgang er at holde separate layouttræer for hver variant og kun toggle Visible. Det virker bekvemt i designeren, men har typiske bivirkninger:
- Dobbelt Binding/hændelser: To lignende Controls skal holdes synkrone (f.eks. to filterbjælker).
- Tab-rækkefølge og fokus: Ved omskift mister man fokus eller ender i usynlige Controls, hvis
TabStop/HitTester ugunstigt indstillet. - State Drift: Scrollpositioner, selektionsstater eller redigeret tekst divergerer.
Omparentering bevarer instansen entydigt. Det er vigtigt at opdele layout-blokkene, så de kan flyttes uafhængigt (f.eks. „Sidebar“ som en separat container i stedet for mange enkeltcontrols). Netop det betaler sig i vedligehold og fejlanalyse: Man debugger én instans, ikke to parallelle skygge‑UIs.
Faldgruber i praksis (og hvordan man debugger dem)
1) Resize-storme og re-entrancy
FMX triggger OnResize ikke kun ved brugerresize, men også ved style-ændringer, parent-ændringer og delvist ved DPI-ændringer. Uden debounce hænger appen i layout-løkker. Routeren bruger TThread.ForceQueue til at skubbe ændringerne til næste UI-tick.
Debugging-tip: Logging (f.eks. via OutputDebugString) med breakpoint, størrelse og en update-counter hjælper med at finde reflow-løkker. Hvis du derudover logger tidspunktet, hvor ApplyRoutes starter og slutter, ser du hurtigt, om et enkelt resize „kaskaderer“.
2) Z-Order, HitTest und „unsichtbare“ Klick-Blocker
Parent-ændringer ændrer Z-Order. Hvis overlays (f.eks. Flyouts) ikke længere modtager klik, skyldes det ofte, at en Client-aligned container ligger ovenpå og HitTest er aktiv. Variant: Tildel bevidst et separat slot helt øverst til overlay-områder og parent kun sådanne Controls dér. I FMX er HitTest (om et Control aflytter mus-/touch-hændelser) oftere årsagen end synlighed.
3) TGridPanelLayout og procentuelle størrelser
TGridPanelLayout kan ved procentbaserede kolonner/rækker i kombination med Align=Client og dynamisk genplacering udløse uventede genberegninger. Hvis du er nødt til at bruge et Grid, placer Grid’et i en slot, og flyt kun hele Grid-blokke, ikke Grid-børnene. Det reducerer kombinationen af layout-passene.
4) Fokus, virtuelt tastatur og „springende“ indtastningsfelter
Et randtilfælde, der opstår i mobile FMX-apps og også på Windows-tablets: Ved genplacering kan et fokuseret Edit-Control kortvarigt miste sin Parent. Det kan lukke det virtuelle tastatur eller nulstille markøren. Det har vist sig praktisk at: gemme den aktuelle fokus før routing (Focused/IFMXFocusControl) og gendanne fokus efter routing (i samme UI-tick). Det er især nyttigt for inputskærme, der skifter mellem „tospaltet“ (Tablet/PC) og „enspaltet“ (Phone).
Varianter: Breakpoints efter formfaktor i stedet for kun efter bredde
I reelle multiplatform-klienter er „bredde“ alene ofte ikke det rigtige signal. Fornuftige varianter:
- Bredde og højde: meget flade vinduer (f.eks. kasse-terminaler, delte skærme) kræver andre regler.
- Orientering:
Landscapepå tablets er ofte „desktop-lignende“, portrait mere „mobile-lignende“. - Safe-Area-brugsareal: På iOS/Android kan den effektivt tilgængelige højde blive væsentligt reduceret af systemlinjer. Hvis man kun ser på
Height, router man nogle gange „for sent“.
Routeren er bevidst bygget, så du kan udskifte breakpoint-funktionen. Det er også nyttigt i legacy-situationer, når samme form kører i flere hosts (f.eks. én gang som normalt vindue, én gang i en indlejret container).
Usædvanligt rent: Layout-routing som „Transaktion“
På større skærme handler problemet mindre om breakpoints i sig selv og mere om rækkefølgen af UI-operationer. Et praksisegnet mønster er at behandle routing som en transaktion: først beslutte, så genplacere, og derefter udføre bivirkninger (Visibility, Fokus, datarefresh) i ordnet rækkefølge.
Det betyder konkret: Undgå at enkelte controls under genplacering udløser egne events, som igen starter layout- eller dataadgang. I FMX sker det for eksempel, når OnEnter/OnExit affyres ved Parent-skift, eller når et LiveBinding-udtryk reevalueres på grund af et bounds-opdatering. Hvis du ser sådanne effekter, hjælper en central „Updating“-afbryder (som i routeren) plus et klart post-step: Først efter ApplyRoutes må dyre operationer køre (f.eks. genindlæs liste, bind detaljevisning).
Især for klienter med REST-adgang er det relevant: En utilsigtet reload under en resize kan føre til unødvendige requests. Det bemærkes ikke i LAN, men i VPN eller på mobile netværk med det samme.
Hvornår tilgangen er værd at bruge – og hvor den har begrænsninger
Layout-routeren er værd at bruge, når:
- en FMX-applikation lever videre i årevis, og flere udviklere arbejder på de samme screens,
- UI-blokke kan adskilles klart (Sidebar/Details/Content),
- man har brug for reproducerbare Breakpoint-regler i stedet for ad-hoc Align-tuning.
Grænser ser du, når en skærm skal være stærkt „fluid“ (mange dynamiske fliser, ægte Masonry-Layouts). Så er TFlowLayout/TGridPanelLayout eller egne layout-klasser mere egnede. Også hvis meget mange enkeltcontrols skifter mellem slots, bliver vedligeholdelsen af ruterne uoverskuelig – så er det bedre at skære større blokke eller indføre et deklarativt konfigurationslag (f.eks. en JSON-konfiguration for slot-tilknytninger, der indlæses ved opstart).
Konklusion: For responsive layouts i FMX er „omkobling ved breakpoints“ en pragmatisk mellemvej: mindre designer-kaos, klare regler, stabile tilstande. Det erstatter ikke en gennemarbejdet UI-struktur, men det giver jer et robust skelet til at kontrolleret videreudvikle FMX-klienter i digitale virksomhedsløsninger på tværs af formfaktorer.
Hvis I i en eksisterende Delphi- eller FMX-applikation ønsker at gennemføre en sådan layout-arkitektur uden at risikere UI-regressioner i driftsscenarier, kan I få det teknisk vurderet hos os: Projekt eller moderniseringsforløb drøftes med Net-Base.
I fagkonteksten spiller også Delphi Fmx Breakpoints og Firemonkey Layout en væsentlig rolle, når integrationer, dataflows og videreudvikling skal fungere pålideligt sammen.
Næste trin
Når et emne bliver til et reelt projekt, bør arkitektur, eksisterende systemer og drift tidligt vurderes samlet.
Vi støtter ikke kun ved enkeltspørsmål, men også når kildekodeudsnit, legacy-komponenter eller portalidéer skal udvikles til et robust virksomhedsprojekt.
- Eksisterende tilstand, målbillede og tekniske risici vurderes samlet.
- REST, dataadgang, portaler og idrulning bliver ikke udskudt som eftertanker.
- I ser tidligt, hvilken vej der er økonomisk og driftsmæssigt holdbar.