Fra magasinetema til prosjektpraksis
Egnede tjeneste- og tekniske sider for innlegget
Når man i Delphi FireMonkey må støtte flere formfaktorer, ender man fort opp med Responsive Layouts FMX – og like raskt i en blanding av Align-kaskader, skjulte layout-containere og Designer-workarounds som kollapser ved neste DPI- eller rotasjonsendring. I modnet forretningsprogramvareklienter er dette spesielt ubehagelig: UI-en videreutvikles, team skiftes, og plutselig ligger logikk bundet til visuelle detaljer.
Kjernen i problemet: FireMonkey tilbyr mange byggeklosser (f.eks. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), men ikke et «naturlig» Breakpoint-system som på weben. Man kan selvfølgelig reagere på størrelsesendringer, men uten klar arkitektur ender det i «if Width < … then …» spredt over mange Forms.
Denne artikkelen viser en Layout-Router: en liten komponent som administrerer Breakpoints sentralt og henger Controls (eller hele layout-blokker) om mellom forhåndsdefinerte Slots. Mål: tilstander bevares, koden er vedlikeholdbar, og kanttilfeller som rotasjon, nestede layouts og re-entrancy blir dempet. I tillegg noen mindre åpenbare knep som i praksis utgjør forskjellen mellom «kjører i demo» og «kjører stabilt i drift».
Hvorfor Breakpoints i FMX er annerledes enn på weben
I web-oppsett er Breakpoints ofte deklarative (CSS Media Queries). I FMX er layoutbeslutninger typisk imperative ved kjøring: Man bytter oppsett i OnResize. I tillegg kommer plattformspecifikke særegenheter:
- Device-piksler vs. logiske piksler:
ClientWidth/ClientHeighter i logiske enheter (avhengig av skalering). DPI-endringer (f.eks. Windows Per-Monitor-DPI) kan trigge layout på nytt uten at «fysisk» noe endrer seg. - Rotasjon og Safe Areas: Mobile plattformer leverer insets (Notch/Safe Area) – avhengig av OS og enhet. Et «breakpoint kun basert på bredde» er ofte for snevert, fordi den brukbare flaten kan være mindre enn vindusstørrelsen.
- Layout-pass: FireMonkey beregner layouter i faser. Hvis man endrer Parent/Align i feil øyeblikk, oppstår bivirkninger (f.eks. dobbelt reflow eller flimrende størrelser).
En Layout-Router adresserer dette ved å (1) frikoble «når» (Resize/Scale/Rotation) fra «hvordan» (layout-regler) og (2) konsentrere reglene på ett sted. For tekniske ledere er den viktigste effekten: De får et klart, verifiserbart beslutningssenter i stedet for mange lokale spesialtilfeller.
Arkitektur: Layout-Router med Slots i stedet for å opprette Controles
Det ryddige trikset for FMX: ikke dynamisk gjenopprette Controls, men flytte eksisterende Controls mellom Slots. Et Slot er enkelt et container (f.eks. TLayout) som representerer et område av UI-en: sidepanel, verktøylinje, innhold, bunntekst, detaljrute.
Fordeler i individuell bedriftsprogramvare:
- Tilstander bevares (innhold i redigeringsfelt, rulleposisjon, valgte elementer), fordi instanser ikke bygges opp på nytt.
- Mindre risiko for dobbel registrering av hendelser, timere eller bindings.
- Layout-regler blir synlige: «hvilken blokk ligger i hvilket Slot» kan for hver Breakpoint følges og gjennomgås.
Viktig i praksis: Del inn UI-blokkene grovt nok. Hvis du flytter 30 enkelte kontroller, blir rute-listen selv en feilkilde. Bedre er containere som layFilterBar, layNavigation, layResultList, layDetails.
Kildeutdrag: Breakpoint-router for responsive oppsett i FMX
Følgende kode er ment som en hjelpeenhet du kan bruke i FMX-Forms. Den beregner et breakpoint (XS/SM/MD/LG/XL) og flytter definerte Controls inn i definerte slot-containere. Viktige detaljer:
- Debounce via
TThread.ForceQueue: flere Resize-Events blir slått sammen til én oppdatering (mindre UI-risting, færre reflow-løkker). - Re-Entrancy-beskyttelse: Layout-oppdatering utløser ofte igjen Resize/Layout.
- Valgfritt: Orientering (Portrait/Landscape) kan inngå 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);
// En mapping: hvilket Control skal plasseres 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; // manuelt beregne på nytt
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 kjøring per meldingssløyfe
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;
// Merk: Parent-bytte endrer Z-orden.
// Hvis rekkefølge er viktig, kall DefineRoute i ønsket rekkefølge.
for LRoute in LList do
begin
if (LRoute.Control.Parent <> LRoute.TargetSlot) then
LRoute.Control.Parent := LRoute.TargetSlot;
// Sett Align først etter Parent, ellers kan Bounds bli tolket annerledes.
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 bevisst grove, siden FMX-målplattformer varierer mye.
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.
Slik bruker du routeren i et skjema
Du definerer slots som TLayout (f.eks. layTop, layLeft, layContent) og registrerer deretter per breakpoint hvor hvilke blokker skal ligge. Typisk er at sidebar og detaljpanelet i små breakpoints flyttes under hverandre.
procedure TFrmMain.FormCreate(Sender: TObject);
begin
FRouter := TNBLayoutRouter.Create(Self);
FRouter.AttachRoot(LayoutRoot); // f.eks. et TLayout som er Client-aligned
// XS: alt under hverandre
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 til venstre, Details til høyre
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);
// Valgfritt: egen breakpoint-logikk
FRouter.OnGetBreakpoint :=
function(const S: TSizeF): TNBLayoutBreakpoint
begin
if (S.cx < 700) or (S.cy < 420) then
Result := bpSM
else
Result := bpMD;
end;
end;Plassering: Hvorfor «omhengning» ofte er mer stabilt enn å bytte Visible
En vanlig tilnærming er å holde separate layout-trær for hver variant og bare toggle Visible. Det føles praktisk i designeren, men gir typiske bivirkninger:
- Dobbel binding/Events: To like controls må holdes synkronisert (f.eks. to filterlinjer).
- Tab-rekkefølge og fokus: Ved bytte mister man ofte fokus eller ender opp i usynlige controls hvis TabStop/HitTest er ugunstig satt.
- State drift: Rulleposisjoner, seleksjonstilstander eller redigerte tekster divergerer.
Omhengning beholder én entydig instans. Viktig er å dele opp layout-blokkene slik at de kan flyttes uavhengig (f.eks. «Sidebar» som egen container i stedet for mange enkeltcontrols). Dette lønner seg i vedlikehold og feilsøking: Du debugger én instans, ikke to parallelle skygge-UIer.
Fallgruver i praksis (og hvordan du feilsøker dem)
1) Resize-stormer og reentrancy
FMX trigger OnResize ikke bare ved brukerendring, men også ved stilbytter, parent-endringer og delvis ved DPI-endringer. Uten debounce kan appen henge i layout-sløyfer. Routeren bruker TThread.ForceQueue for å skyve endringene til neste UI-tick.
Feilsøkningstips: Logging (f.eks. via OutputDebugString) med breakpoint, størrelse og en update-counter hjelper med å finne reflow-sløyfer. Hvis du i tillegg logger tidspunktet når ApplyRoutes starter og slutter, ser du raskt om en enkelt resize «kaskaderer».
2) Z-Order, HitTest og «usynlige» klikksperrer
Parent-bytt endrer Z-Order. Hvis overlays (f.eks. flyouts) ikke lenger mottar klikk, skyldes det ofte at en Client-aligned container ligger over dem og at HitTest er aktiv. Variante: For overlay-områder reserver et eget slot øverst og parent slike controls kun der. I FMX er HitTest (om et control fanger opp mus-/touch-hendelser) oftere årsaken enn synligheten.
3) TGridPanelLayout og prosentvise størrelser
TGridPanelLayout kan ved prosentbaserte kolonner/rader i kombinasjon med Align=Client og dynamisk omparentering utløse uventede nyberegninger. Hvis du må bruke Grid, plasser Grid i en slot, og flytt kun hele Grid-blokker, ikke Grid-barn. Det reduserer kombinasjonen av layout-passene.
4) Fokus, virtuelt tastatur og «hoppende» inndatfelt
Et hjørnetilfelle som opptrer i mobile FMX-apper og også på Windows-tabletter: Ved omparentering kan et fokusert Edit-kontroll kortvarig miste Parent. Dette kan lukke det virtuelle tastaturet eller tilbakestille markøren. Erfaring viser: Ta vare på gjeldende fokus før Routing (Focused/IFMXFocusControl), og gjenopprett fokus etter Routing (i samme UI-tick). Dette lønner seg særlig for inndataskjemaer som veksler mellom «tospaltet» (Tablet/PC) og «enspaltet» (Phone).
Varianter: Breakpoints etter formfaktor i stedet for bare etter bredde
I reelle multiplattform-klienter er «bredde» alene ofte ikke riktig signal. Fornuftige varianter:
- Bredde og høyde: svært flate vinduer (f.eks. kasse-terminaler, delte skjermer) trenger andre regler.
- Orientering:
Landscapepå tablets er ofte «desktop-lignende», Portrait heller «mobile-lignende». - Safe-Area-brukbar flate: På iOS/Android kan den effektiv brukbare høyden krympe betydelig på grunn av systemlinjer. Den som bare ser på
Height, ruter noen ganger «for sent».
Routeren er bevisst bygget slik at du kan bytte ut breakpoint-funksjonen. Det er også nyttig i legacy-situasjoner, når samme form kjører i flere hosts (f.eks. én gang som normalt vindu, én gang i en innebygd container).
Uvanlig ryddig: Layout-routing som «transaksjon»
I større skjermer handler problemet mindre om selve breakpoints enn om rekkefølgen av UI-operasjonene. Et praksisnært mønster er å behandle routing som en transaksjon: først ta avgjørelse, deretter omparentere, så utføre sideeffekter (Visibility, Fokus, datarefresh) i ordnet rekkefølge.
Konkret betyr det: Unngå at enkeltkontroller under omparentering utløser egne hendelser som igjen starter layout eller data-tilgang. I FMX skjer dette for eksempel når et Parent-skifte utløser OnEnter/OnExit eller når et LiveBinding-uttrykk reevalueres på grunn av et Bounds-Update. Hvis du ser slike effekter, hjelper en sentral «Updating»-bryter (som i Routeren) pluss et tydelig post-steg: Først etter ApplyRoutes får kostbare operasjoner kjøre (f.eks. laste liste på nytt, binde detaljvisning).
Spesielt for klienter med REST-tilgang er dette relevant: En uønsket reload under en resize kan føre til unødvendige forespørsler. Det merkes ikke i LAN, men i VPN eller på mobil med én gang.
Når tilnærmingen lønner seg – og hvor den har grenser
Layout-routeren lønner seg når:
- en FMX-applikasjon lever videre over år, og flere utviklere arbeider på de samme skjermene,
- UI-blokker kan skilles klart (Sidebar/Details/Content),
- du trenger reproduserbare breakpoint-regler, i stedet for ad-hoc Align-tuning.
Begrensninger blir tydelige når en skjerm må være svært „fluid“ (mange dynamiske fliser, ekte Masonry-oppsett). Da er TFlowLayout/TGridPanelLayout eller egendefinerte layout-klasser mer hensiktsmessige. Hvis svært mange enkeltkontroller bytter mellom slots, blir vedlikeholdet av rutene uoversiktlig – da er det bedre å dele opp i større blokker eller innføre et deklarativt konfigurasjonslag (f.eks. en JSON-konfigurasjon for slot-tilordninger som lastes ved oppstart).
Konklusjon: For responsive oppsett i FMX er „omkobling med breakpoints“ en pragmatisk mellomløsning: mindre designer-kaos, klare regler, stabile tilstander. Den erstatter ikke en gjennomtenkt UI-struktur, men gir deg et robust rammeverk for å videreutvikle FMX-klienter i digitale virksomhetsløsninger på tvers av formfaktorer på en kontrollert måte.
Hvis du i en eksisterende Delphi- eller FMX-applikasjon ønsker å etterføre en slik layout-arkitektur på en ryddig måte, uten å risikere UI-regresjoner i driftsituasjoner, kan du gjerne avstemme det teknisk med oss: drøft prosjekt eller moderniseringsprosjekt med Net-Base.
I faglig sammenheng spiller også Delphi Fmx Breakpoints og Firemonkey Layout en viktig rolle når integrasjoner, dataflyter og videreutvikling må samspille på en ryddig måte.
Neste steg
Når et tema blir et reelt prosjekt, bør arkitektur, eksisterende systemer og drift tidlig vurderes samlet.
Vi bistår ikke bare med enkeltspørsmål, men også når kodesnutter, legacy-temaer eller portalideer skal utvikles til et robust virksomhetsprosjekt.
- Eksisterende tilstand, målbildet og tekniske risikoer vurderes samlet.
- REST, datatilgang, portaler og utrulling blir ikke utsatt som sene følger.
- Dere ser tidlig hvilken vei som er økonomisk og driftsmessig levedyktig.