Од теме часописа до пројектне праксе
Одговарајуће странице услуга и техничке странице за чланак
Ко у Delphi у FireMonkey-ју мора да подржава више формата, брзо долази до Responsive Layouts FMX – и подједнако брзо до мешавине Align-каскада, скривених layout-контејнера и дизайнерских workarounds, који падну при следећој промени DPI-ја или ротације. У развијеним бизнис софтвер клијентима то је посебно непријатно: UI се даље развија, тимови се мењају и одједном логика зависи од визуелних детаља.
Језгро проблема: FireMonkey нуди много грађевних блокова (нпр. Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), али нема „нативни“ Breakpoint-System као на вебу. Може се реаговати на промене величине, али без јасне архитектуре то се завршава у „if Width < … then …“ распоређеном по многим формама.
Овај чланак показује један Layout-Router: малу компоненту која централизовано управља Breakpoints и премешта Controls (или целе layout-блокове) између припремљених слотова. Циљ: стања остају сачувана, код је одржив, а ивићни случајеви као ротација, угнежђени layout-и и реентрантност се ублажавају. Уз то иду неки мање очигледни трикови који у пракси праве разлику између „ради у демо-у“ и „ради стабилно у раду“.
Зашто су Breakpoints у FMX другачији него на вебу
У веб-лејаутима су Breakpoints углавном декларативни (CSS Media Queries). У FMX-у су одлуке о layout-у обично импeративне у време извођења: при OnResize се пребацује. Поред тога постоје платформи-специфичности:
- Device-Pixel vs. логички пиксели:
ClientWidth/ClientHeightсу у логичким јединицама (зависно од скалирања). DPI-промене (нпр. Windows Per-Monitor-DPI) могу поново покренути layout без физичке промене. - Ротација и Safe Areas: Мобилне платформе пружају Insets (Notch/Safe Area) – у зависности од ОС-а и уређаја. „Breakpoint само по ширини“ често је преспоро размишљање, јер је употребљива површина мања од саме величине прозора.
- Layout-процес: FireMonkey рачуна layout-е у фазама. Ако се у погрешном тренутку промени Parent/Align, настају нуспојаве (нпр. вишеструки рефлоу или треперење величина).
Layout-Router решава то тако што (1) раздваја „када“ (Resize/Scale/Rotation) од „како“ (правила layout-а) и (2) концентрише та правила на једном месту. За техничке лидере најважнији ефекат је: добијају јасан, проверљив центар за одлучивање уместо многих локалних посебних случајева.
Архитектура: Layout-Router са слотовима уместо креирања контрола
Чист трик за FMX: не динамички креирати контроле, већ премештати постојеће контроле између слотова. Слот је једноставно контејнер (нпр. TLayout), који представља област UI-а: бочна трака, трака са алаткама, садржај, подножје, панел детаља.
Предности у прилагођеном корпоративном софтверу:
- Стања остају сачувана (садржај поља за унос, положај скрола, селектовани елементи), јер се инстанце не реконструишу.
- Мање ризика од дуплих веза догађаја, тајмера или биндинга.
- Правила распореда постају видљива: „који блок се налази у ком слоту“ може се по сваком Breakpoint-у праћити и ревидирати.
Важно за праксу: режите UI-блокове довољно грубо. Ако премештате 30 појединачних контрола, листа рута сама по себи постаје извор грешака. Погоднији су контејнери као што су layFilterBar, layNavigation, layResultList, layDetails.
Исечак изворног кода: Breakpoint-Router за респонзивне распореде FMX
Следећи код је намењен као помоћна јединица коју можете користити у FMX формама. Он израчунава Breakpoint (XS/SM/MD/LG/XL) и премешта дефинисане контроле у дефинисане слот-контенере. Важни детаљи:
- Debounce преко
TThread.ForceQueue: више Resize-догађаја се сабије у једно ажурирање (мање тресења UI-ја, мање рефлоу-петљи). - Заштита од реентранције: ажурирање распореда често само покреће поновно Resize/Layout.
- Опционо: оријентација (Portrait/Landscape) може утицати на логику Breakpoint-а.
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);
// Мапирање: који Control треба у који Slot (контенер) за одређени Breakpoint.
TNBRoute = record
Control: TControl;
TargetSlot: TControl; // типично TLayout или 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; // ручно поново израчунати
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 не смеју бити 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: применити само једном по 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;
// Упозорење: промена Parent-а мења Z-редослед.
// Ако је редослед важан, позовите DefineRoute у жељеном редоследу.
for LRoute in LList do
begin
if (LRoute.Control.Parent <> LRoute.TargetSlot) then
LRoute.Control.Parent := LRoute.TargetSlot;
// Align поставити тек после Parent-а, у супротном се Bounds могу другачије тумачити.
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;
// Breakpoint-ови намерно груби, јер се циљне платформе FMX значајно разликују.
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.
Како користити рутер у форми
Дефинишете слотове као TLayout (нпр. layTop, layLeft, layContent) и затим по breakpoint-у региструјете где се који блок налази. Уобичајено је да се Sidebar и Details-Pane у мањим breakpoint-овима померају један испод другог.
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;Постављање: Зашто је „премештање“ често стабилније од пребацивања Visible
Уобичајен приступ је да се за сваку варијанту задрже посебни стаблови распореда и да се само својство Visible пребацује. То делује удобно у дизајнеру, али има типичне нежељене последице:
- Дупло привезивање/догађаји: Две сличне контроле морају бити синхронизоване (нпр. две траке филтера).
- Поредак табова и фокус: При пребацивању губите фокус или завршите у невидљивим контролама ако су TabStop/HitTest неповољно подешени.
- Неподударност стања: Положаји скролова, стања селекције или уређени текстови могу да се разликују.
Премештање задржава јединствену инстанцу. Важно је структуирати блокове распореда тако да се могу независно померати (нпр. „Sidebar“ као посебан контејнер уместо многих појединачних контрола). Управо то се исплати у одржавању и анализи грешака: дебагујете једну инстанцу, а не две паралелне сенке UI-ја.
Проблеми у пракси (и како их дебаговати)
1) Resize-бујице и реентрантност
FMX иницира OnResize не само при корисничком мењању величине, већ и при промени стила, промени Parent-а и делом при промени DPI-ја. Без дебаунса апликација упада у петље распореда. Рутер користи TThread.ForceQueue да помери измене у следећи UI-tick.
Савет за дебаговање: логовање (нпр. преко OutputDebugString) са breakpoint-ом, величином и бројачем ажурирања помаже да се пронађу рефлоу-петље. Ако додатно забележите време када ApplyRoutes почиње и завршава, брзо ћете видети да ли један Resize „каскадира”.
2) Z-Order, HitTest и „невидљиви“ блокатори кликова
Промена Parent-а мења Z-Order. Ако Overlays (нпр. Flyouts) више не примају кликове, често је узрок то што изнад лежи Client-aligned контејнер са активним HitTest-ом. Опција: за overlay-површине намерно предвидите посебан слот на самом врху и само тамо мењајте parent таквих контрола. У FMX-у је HitTest (да ли контрола хвата миш-/тач-догађаје) чешћи узрок него видљивост.
3) TGridPanelLayout и процентуалне величине
TGridPanelLayout може да покрене неочекивана прерачунавања при процентуалним колонама/редовима у комбинацији са Align=Client и динамичким премештањем. Ако морате да користите Grid, поставите Grid у слот и премештајте само читаве Grid-блокове, не Grid-децу. То смањује комбинаторику пролаза распореда.
4) Фокус, виртуелна тастатура и „скачућа“ поља за унос
Ранд-случај који се појављује у мобилним FMX-апликацијама и такође на Windows-Tablets: при премештању фокусиран Edit-Control може привремено изгубити Parent. То може затворити виртуелну тастатуру или вратити позицију курсора. Практично се показало: пре рутирања привремено сачувати тренутни фокус (Focused/IFMXFocusControl), а после рутирања (у истом UI-Tick) вратити фокус. То посебно има смисла код уносних маски које се мењају из „zweispaltig“ (Tablet/PC) у „einspaltig“ (Phone).
Варијанте: Breakpoints по форм-фактору уместо само по ширини
У реалним мултиплатформ клијентима „ширина“ сама по себи често није прави сигнал. Смислене варијанте:
- Ширина и висина: веома плитки прозори (нпр. POS-терминали, подељени екрани) захтевају другачија правила.
- Оријентација:
Landscapeна таблетима је често „слично десктопу“, Portrait је уобичајено више у „мобилном“ режиму. - Safe-Area-Nutzfläche: На iOS/Android ефективно искоришћена висина може значајно да се смањи услед системских трака. Ко гледа само
Height, понекад рутира „превише касно“.
Рутер је намерно дизајниран тако да можете заменити функцију Breakpoint-а. То је корисно и у legacy ситуацијама када иста форма ради у више хостова (нпр. једном као нормални прозор, другом као уграђени контејнер).
Необично чисто: Layout-Routing као „трансакција“
На већим екранима проблем мање зависи од самих Breakpoint-ова, а више од редоследа UI-операција. Практичан образац је третирати рутирање као трансакцију: прво одлучити, затим преместити, па уредно извршити нуспојаве (Visibility, фокус, освежавање података).
Конкретно то значи: избегавајте да појединачне контроле током премештања покрећу своје event-ове који затим покрећу распоред или приступ подацима. У FMX се то дешава, на пример, када при промени родитеља OnEnter/OnExit фајер-ује или када се LiveBinding-израз поново евалуира услед Bounds-апдејта. Ако приметите такве ефекте, помаже централни „Updating“ прекидач (као у рутеру) плус јасан пост-степ: само након ApplyRoutes смеју да се покрећу скупе операције (нпр. поновно учитавање листе, повезивање детаљног приказа).
Посебно код клијената са REST-приступом ово је релевантно: ненамерно поновно учитавање током resize-а може довести до непотребних захтева. То се у LAN-у не примећује, али преко VPN-а или на мобилној везама одмах.
Када се приступ исплати – и где има ограничења
Layout-Router се исплати када:
- FMX апликација опстане годинама и више развијача ради на истим екранима,
- UI-блокови се могу јасно раздвојити (Sidebar/Details/Content),
- потребне су вам репродуцибилне Breakpoint-правиле уместо ad-hoc подешавања Align-а.
Границе постају видљиве када екран мора бити јако „fluid“ (много динамичких квачки, прави Masonry-распореди). Тада су TFlowLayout/TGridPanelLayout или сопствене класе распореда погодније. И када већи број појединачних контрола мења место између слотова, одржавање рута постаје непрегледно – онда је боље резати веће блокове или увући декларативни слој конфигурације (нпр. JSON-конфигурација за доделе слотова која се учитава при старту).
Закључак: За responsive распореде у FMX-у „Umhängen mit Breakpoints“ представља прагматичан средњи пут: мање хаоса у дизајнеру, јасна правила, стабилна стања. Он не замењује промишљену UI-структуру, али пружа робусан оквир да бисте FMX клијенте у дигиталним корпоративним решењима контролисано даље развијали преко форм-фактора.
Ако у постојећој Delphi- или FMX апликацији желите чисто имплементирати такву архитектуру распореда без ризиковања регресија корисничког интерфејса у оперативним сценаријима, слободно нам то технички класификујте: разговарајте о пројекту или модернизационом предузећу са Net-Base.
У стручном контексту Delphi Fmx Breakpoints и Firemonkey Layout такође играју важну улогу када интеграције, токови података и даљи развој морају да се уклопе на уредан начин.
Разговарајте о пројекту или модернизационом предузећу са Net-Base.
Следећи корак
Када тема прерасте у реалан пројекат, архитектуру, постојеће системе и операције треба рано разматрати заједно.
Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.
- Постојеће стање, циљано стање и технички ризици оцењују се заједно.
- REST, приступ подацима, портали и роллаут се неће одлагати као накнадне последице.
- Ви рано видите који пут је економски и оперативно одржив.