Від теми журналу до практики проєкту
Відповідні сторінки послуг і технічні сторінки до публікації
Коли в Delphi FireMonkey потрібно підтримувати кілька форм-факторів, швидко доходить до Responsive Layouts FMX — і так само швидко до суміші Align-каскадів, прихованих Layout-контейнерів і дизайнерських обхідних рішень, які при наступній зміні DPI або орієнтації дають збій. У зрілих бізнес-клієнтських додатках це особливо неприємно: UI розвивається, команди змінюються, і раптом логіка прив’язана до візуальних деталей.
Суть проблеми: FireMonkey пропонує багато будівельних блоків (зокрема Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), але немає «рідної» системи брейкпоінтів як у вебі. Можна реагувати на зміну розмірів, але без чіткої архітектури це закінчується «if Width < … then …» розкиданими по багатьох формах.
Ця публікація показує Layout-Router: невеликий компонент, який централізовано керує брейкпоінтами і переміщує Controls (або цілі блоки макета) між підготовленими Slots. Мета: стани зберігаються, код залишається обслуговуваним, а крайові випадки як-от ротація, вкладені макети і Re-Entrancy згладжуються. До того ж — кілька менш очевидних прийомів, які на практиці визначають різницю між «працює в демо» і «стійко працює в експлуатації».
Чому брейкпоінти в FMX відрізняються від вебу
У веб-верстці брейкпоінти зазвичай декларативні (CSS Media Queries). У FMX рішення щодо розмітки зазвичай імперативні під час виконання: у OnResize відбувається перемикання. До цього додаються платформно-специфічні особливості:
- Фізичні пікселі vs. логічні пікселі:
ClientWidth/ClientHeightвимірюються в логічних одиницях (залежно від масштабування). Зміни DPI (наприклад Windows Per-Monitor-DPI) можуть повторно запускати розрахунок макета, не змінюючи «фізично» нічого. - Ротація і Safe Areas: Мобільні платформи надають Insets (Notch/Safe Area) — залежно від ОС і пристрою. «Брейкпоінт лише за шириною» часто занадто спрощений, оскільки корисна площа може бути меншою за загальний розмір вікна.
- Прохід розмітки: FireMonkey обчислює макети по фазах. Якщо змінювати Parent/Align у невдалий момент, виникають побічні ефекти (наприклад багаторазовий рефлоу або мерехтіння розмірів).
Layout-Router вирішує це шляхом розділення (1) «коли» (Resize/Scale/Rotation) від «як» (правила макета) і (2) концентрації правил в одному місці. Для технічних лідерів найважливіший ефект: вони отримують чіткий, перевіряний центр прийняття рішень замість багатьох локальних особливих випадків.
Архітектура: Layout-Router зі слотами замість створення контролів
Чистий прийом для FMX: не динамічно створювати Controls заново, а переміщувати існуючі Controls між Slots. Слот — це просто контейнер (наприклад TLayout), який представляє ділянку UI: Sidebar, Toolbar, Content, Footer, Details-Pane.
Переваги в індивідуальному корпоративному програмному забезпеченні:
- Стани зберігаються (Edit-Text, Scrollposition, вибрані елементи), оскільки екземпляри не створюються заново.
- Менший ризик дублювання підключень подій, таймерів або прив’язок.
- Правила макета стають видимими: «який блок знаходиться в якому слоті» можна простежити і перевірити для кожного брейкпоінта.
Важливо для практики: розбивайте UI-блоки досить грубо. Якщо ви переміщуватимете 30 окремих контролів, сам список маршрутів стане джерелом помилок. Краще використовувати контейнери, такі як layFilterBar, layNavigation, layResultList, layDetails.
Фрагмент коду: Breakpoint-Router для адаптивних макетів FMX
Наступний код призначений як допоміжний модуль, який ви можете використовувати у FMX-формах. Він обчислює breakpoint (XS/SM/MD/LG/XL) і переміщує визначені контролі у визначені слот-контейнери. Важливі деталі:
- Debounce через
TThread.ForceQueue: кілька Resize-подій об’єднуються в одне оновлення (менше тремтіння UI, менше циклів reflow). - Захист від повторного входу (Re-Entrancy): оновлення макета часто саме спричиняє повторні 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 слід помістити у який слот (контейнер) для певного breakpoint.
TNBRoute = record
Control: TControl;
TargetSlot: TControl; // типово TLayout oder 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: застосовувати лише один раз за цикл повідомлень
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-Order.
// Якщо порядок важливий, викликайте 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;
// Breakpoints навмисно грубі, оскільки цільові платформи 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.
Як використовувати Router у формі
Ви визначаєте слоти як TLayout (наприклад, layTop, layLeft, layContent) і потім реєструєте для кожного Breakpoint, де розміщуються які блоки. Типово Sidebar і панель деталей у вузьких 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 не тільки при зміні розміру користувачем, але й при зміні стилю, зміні батька та частково при зміні DPI. Без дебаунсу додаток застрягає в циклах лейауту. Router використовує TThread.ForceQueue, щоб відкласти зміни до наступного UI-тіку.
Порада для дебагу: логування (наприклад, через OutputDebugString) з брейкпоінтом, розміром і лічильником оновлень допомагає знаходити рефлоу-цикли. Якщо ви додатково логуватимете час початку й завершення ApplyRoutes, то швидко побачите, чи один Resize «каскадує».
2) Z-Order, HitTest та „невидимі“ блокатори кліків
Зміна батька змінює Z-Order. Якщо оверлеї (наприклад, Flyouts) перестають реагувати на кліки, часто це означає, що над ними лежить клієнт-алігнований контейнер із активним HitTest. Варіант: для зон оверлеїв свідомо передбачити окремий слот угорі й парентити такі контролі лише туди. У FMX саме HitTest (чи перехоплює контролі миш-/тач-події) частіше є причиною, ніж видимість.
3) TGridPanelLayout і відсоткові розміри
TGridPanelLayout може при відсоткових стовпцях/рядках у поєднанні з Align=Client та динамічним переміщенням спричиняти непередбачувані перевирахунки. Якщо потрібно використовувати Grid, розміщуйте Grid у слот і переміщайте лише цілі блоки Grid, а не дочірні елементи Grid. Це зменшує комбінаційність проходів макета.
4) Фокус, віртуальна клавіатура та «стрибаючі» поля вводу
Краєвий випадок, що виникає в мобільних FMX-додатках і також на Windows-планшетах: під час переміщення фокусований Edit-Control може тимчасово втратити батька. Це може закрити віртуальну клавіатуру або скинути курсор. Практично перевірений підхід: перед Routing тимчасово зберігати поточний фокус (Focused/IFMXFocusControl), після Routing (в тому ж UI-Tick) відновлювати фокус. Це особливо корисно для форм вводу, що переключаються між «двоколонними» (Tablet/PC) і «одноколонними» (Phone).
Варіанти: брейкпоінти за форм-фактором замість тільки за шириною
У реальних мультиплатформених клієнтах «ширина» сама по собі часто не є коректним сигналом. Розумні варіанти:
- Ширина та висота: дуже пласкі вікна (наприклад, касові термінали, розділені екрани) потребують інших правил.
- Орієнтація:
Landscapeна планшетах часто схожа на десктоп, Portrait радше mobile-подібна. - Зона Safe-Area: на iOS/Android ефективно доступна висота може значно зменшуватися через системні панелі. Хто дивиться лише на
Height, іноді маршрутизує «занадто пізно».
Router спеціально побудовано так, щоб ви могли замінити функцію брейкпоінтів. Це також корисно в legacy-ситуаціях, коли одна й та сама форма запускається в кількох хостах (z. B. einmal als normales Fenster, einmal in einem eingebetteten Container).
Незвично чисто: Layout-Routing як «транзакція»
У більших екранах проблема менше в самих брейкпоінтах, а більше в порядку UI-операцій. Практичний шаблон — розглядати Routing як транзакцію: спочатку ухвалити рішення, потім перемістити, а потім упорядковано виконати побічні ефекти (Visibility, фокус, оновлення даних).
Конкретно це означає: уникайте того, щоб окремі контролі під час переміщення генерували власні події, які знову запускають макет або доступ до даних. У FMX це, наприклад, відбувається, коли при зміні батька спрацьовує OnEnter/OnExit або вираз LiveBinding повторно обчислюється через оновлення Bounds. Якщо ви бачите такі ефекти, допоможе центральний перемикач «Updating» (як у Router) плюс чіткий посткрок: лише після ApplyRoutes дозволено виконувати ресурсоємні операції (наприклад, повторне завантаження списку, прив’язка детального перегляду).
Особливо це актуально для клієнтів з доступом REST: небажане повторне завантаження під час зміни розміру може привести до зайвих запитів. У LAN це може бути непомітно, але в VPN або мобільному середовищі — відразу.
Коли підхід виправданий — і де він має обмеження
Layout-Router має сенс, коли:
- FMX-додаток існує роками і кілька розробників працюють над тими самими екранами,
- UI-блоки можуть бути чітко розділені (Sidebar/Details/Content),
- вам потрібні відтворювані правила брейкпоінтів, замість ad-hoc налаштування Align.
Межі стають помітними, коли екран має бути сильно „fluid“ (багато динамічних плиток, справжні Masonry-розкладки). Тоді більш придатними будуть TFlowLayout/TGridPanelLayout або власні класи розкладки. Якщо дуже багато окремих контролів переміщуються між слотами, підтримка маршрутів стає неочевидною — у такому випадку краще різати на більші блоки або ввести декларативний шар конфігурації (наприклад, JSON‑конфігурацію для відповідностей слотів, яка завантажується при старті).
Висновок: Для адаптивних розкладок у FMX „Umhängen mit Breakpoints“ є прагматичним компромісом: менше хаосу в дизайнері, чіткі правила, стабільні стани. Це не замінює продуману структуру UI, але дає вам надійний каркас для контрольованого подальшого розвитку FMX-клієнтів у цифрових корпоративних рішеннях незалежно від форм-факторів.
Якщо ви хочете акуратно відтворити таку архітектуру розкладки в існуючому Delphi- або FMX-застосунку, не ризикуючи регресією UI в експлуатаційних сценаріях, ви можете технічно це з нами узгодити: обговорити проект або модернізацію з Net-Base.
У фаховому середовищі також Delphi Fmx Breakpoints та Firemonkey Layout відіграють важливу роль, коли інтеграції, потоки даних і подальший розвиток мають працювати узгоджено.
Наступний крок
Якщо тема перетворюється на реальний проєкт, архітектуру, наявну інфраструктуру та експлуатацію слід розглядати разом на ранньому етапі.
Ми підтримуємо не лише в окремих питаннях, а й тоді, коли з уривків вихідного коду, питань, пов’язаних із legacy, або ідей порталу має вирости надійний корпоративний проєкт.
- Поточний стан, цільова архітектура та технічні ризики оцінюються спільно.
- REST, доступ до даних, портали та розгортання не відкладаються на пізніші етапи.
- Ви завчасно визначаєте, який підхід є економічно та операційно життєздатним.