Net-Base Magasin

03.06.2026

Responsivt layout i Delphi FMX: Breakpoints uden designer-kaos (med Layout-Router som kildekodeudsnit)

Responsive Layouts i FMX bliver i praksis hurtigt skrøbelige: Resize-storme, DPI-skift, rotation og „Visible-Layouts“ skaber dobbelt tilstand og reflows, der er svære at debugge. Dette indlæg viser en Layout-Router med Breakpoints, der kontrollerer UI-blokke under kørselstid...

03.06.2026

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/ClientHeight er 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.
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);

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

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;

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/HitTest er 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: Landscape på 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.

Projekt eller moderniseringsforløb drøftes med Net-Base.

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.

Del indlæg

Del dette indlæg direkte

LinkedIn, X, XING, Facebook, WhatsApp og e-mail er straks tilgængelige. Til Instagram forbereder vi link og kort tekst med det samme.

E-mail

Instagram åbner i en ny fane. Linket og kortteksten kopieres på forhånd til udklipsholderen.