От темы в журнале к проектной практике
Соответствующие страницы услуг и технологий к статье
Тот, кто в Delphi FireMonkey должен обслуживать несколько форм-факторов, быстро приходит к Responsive Layouts FMX — и не менее быстро оказывается с комбинацией каскадов Align, скрытых контейнеров макета и обходных приёмов в дизайнере, которые при следующей смене DPI или повороте дают сбой. В зрелых бизнес-клиентах это особенно неприятно: интерфейс развивается, команды меняются, и вдруг логика привязана к визуальным деталям.
В корне проблемы: FireMonkey предоставляет много строительных блоков (например Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout), но не имеет «родной» системы брейкпоинтов, как в вебе. Можно, конечно, реагировать на изменения размеров, но без чёткой архитектуры это заканчивается множеством «if Width < … then …» по многим формах.
В этой статье показан Layout-Router: небольшая компонента, которая централизованно управляет брейкпоинтами и перебрасывает Controls (или целые блоки макета) между подготовленными слотами. Цель: состояния сохраняются, код остаётся сопровождaемым, а пограничные случаи, такие как поворот, вложенные макеты и re-entrancy, гасятся. Плюс несколько менее очевидных приёмов, которые на практике определяют разницу между «работает в демо» и «стабильно работает в эксплуатации».
Почему брейкпоинты в FMX отличаются от веба
В веб‑макетах брейкпоинты чаще декларативны (CSS Media Queries). В FMX решения по расположению элементов типично принимаются императивно во время выполнения: в обработчике OnResize происходит переключение. К этому добавляются платформенные особенности:
- Device-Pixel vs. логические пиксели:
ClientWidth/ClientHeightзаданы в логических единицах (в зависимости от масштабирования). Изменения DPI (например Windows Per-Monitor-DPI) могут повторно запускать перерасчёт макета без «физического» изменения размеров. - Поворот и Safe Areas: Мобильные платформы возвращают Insets (Notch/Safe Area) — это зависит от ОС и устройства. «Брейкпоинт только по ширине» часто слишком упрощён, потому что доступная область меньше, чем чистый размер окна.
- Layout-Pass: FireMonkey выполняет расчёт макетов по фазам. Если менять Parent/Align в неподходящий момент, возникают побочные эффекты (например множественный рефлоу или мерцающие размеры).
Layout-Router решает это тем, что (1) отделяет «когда» (Resize/Scale/Rotation) от «как» (правила макета) и (2) централизует сами правила. Для технических руководителей главный эффект: вместо множества локальных частных случаев появляется одно чёткое, проверяемое решение‑центр.
Архитектура: Layout-Router со слотами вместо создания Controls
Чистый приём для FMX: не динамически создавать Controls заново, а перемещать существующие Controls между Slots. Слот — это просто контейнер (например TLayout), который представляет область интерфейса: боковая панель, панель инструментов, содержимое, футер, панель деталей.
Преимущества для индивидуального корпоративного ПО:
- Состояния сохраняются (текст в поле редактирования, позиция прокрутки, выбранные элементы), потому что экземпляры не перестраиваются.
- Меньший риск двойного подключения обработчиков событий, таймеров или биндингов.
- Правила макета становятся наглядными: «какой блок находится в каком слоте» можно проследить и проверить для каждого брейкпоинта.
Важно для практики: разбивайте UI-блоки достаточно грубо. Если вы переключаете 30 отдельных элементов управления, сам список маршрутов становится источником ошибок. Лучше использовать контейнеры, такие как layFilterBar, layNavigation, layResultList, layDetails.
Фрагмент исходного кода: Breakpoint-Router для адаптивных макетов FMX
Приведённый ниже код предназначен как вспомогательный модуль, который можно использовать в FMX-формах. Он вычисляет брейкпоинт (XS/SM/MD/LG/XL) и перемещает заданные элементы управления в заданные слотовые контейнеры. Важные детали:
- Debounce через
TThread.ForceQueue: несколько событий изменения размера объединяются в одно обновление (меньше мерцания интерфейса, меньше проходов рефлоу). - Защита от повторного входа (Re-Entrancy): обновление компоновки часто само вызывает новые события изменения размера/перерасчёта компоновки.
- Опционально: ориентация (портретная/альбомная) может учитываться в логике брейкпоинтов.
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; // typischerweise 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-порядок.
// Если важен порядок, вызывать 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.
Как использовать роутер в форме
Вы определяете слоты как TLayout (например layTop, layLeft, layContent) и затем регистрируете для каждого брейкпоинта, где находятся какие блоки. Обычно боковая панель и панель деталей в малых брейкпоинтах располагаются друг под другом.
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» как отдельный контейнер вместо множества отдельных контролов). Именно это окупается в обслуживании и анализе ошибок: вы отлаживаете один экземпляр, а не два параллельных теневых интерфейса.
Подводные камни на практике (и как их отлаживать)
1) Штормы изменения размера и реэнтрантность
FMX triggert OnResize nicht nur bei User-Resize, sondern auch bei Style-Wechseln, Parent-Änderungen und teils bei DPI-Änderungen. Ohne Debounce hängt die App in Layout-Schleifen. Der Router nutzt TThread.ForceQueue, um die Änderungen in den nächsten UI-Tick zu schieben.
Совет по отладке: логирование (например, через OutputDebugString) с брейкпоинтом, размером и счётчиком обновлений помогает найти reflow-циклы. Если вы дополнительно логируете момент, в который ApplyRoutes запускается и заканчивается, вы быстро увидите, вызывает ли один resize «каскад».
2) Z-Order, HitTest und „unsichtbare“ Klick-Blocker
Смена родителя меняет Z-Order. Если оверлеи (например, Flyouts) перестают реагировать на клики, это часто связано с тем, что поверх лежит контейнер, выровненный по клиентской области (Client-aligned), и HitTest активен. Вариант: для областей оверлея явно выделить отдельный слот в самом верху и помещать туда такие контролы. В FMX HitTest (перехватывает ли элемент управления события мыши/касания) чаще является причиной, чем видимость.
3) TGridPanelLayout und prozentuale Größen
TGridPanelLayout может при процентных столбцах/строках в сочетании с Align=Client и динамическим перемещением вызывать неожиданные перерасчёты. Если вы вынуждены использовать Grid, поместите Grid в слот и перемещайте только целые блоки Grid, а не дочерние элементы Grid. Это уменьшит комбинаторику проходов макета.
4) Фокус, виртуальная клавиатура и «прыгающие» поля ввода
Крайний случай, который возникает в мобильных FMX-приложениях и также на Windows-планшетах: при перемещении элемент Edit, находящийся в фокусе, может кратковременно потерять родителя. Это может закрыть виртуальную клавиатуру или сбросить курсор. Практически показало себя так: перед маршрутизацией временно сохранить текущий фокус (Focused/IFMXFocusControl), после маршрутизации (в том же UI-тике) восстановить фокус. Это особенно полезно для форм ввода, которые переключаются между «zweispaltig» (планшет/ПК) и «einspaltig» (телефон).
Варианты: Breakpoints по форм-фактору вместо только по ширине
В реальных мультиплатформенных клиентах «Breite» часто сама по себе не является корректным сигналом. Полезные варианты:
- Ширина и высота: очень плоские окна (например, кассовые терминалы, разделённые экраны) требуют других правил.
- Ориентация:
Landscapeна планшетах часто «аналогично настольному», Portrait — скорее «мобильный». - Полезная область Safe-Area: На iOS/Android эффективно доступная высота может существенно уменьшаться из‑за системных панелей. Если учитывать только
Height, маршрутизация порой происходит «слишком поздно».
Der Router ist bewusst so gebaut, dass Sie die Breakpoint-Funktion austauschen können. Das ist auch hilfreich in Legacy-Situationen, wenn dieselbe Form in mehreren Hosts läuft (z. B. einmal als normales Fenster, einmal in einem eingebetteten Container).
Необычайно аккуратно: Layout-Routing как «транзакция»
На больших экранах суть проблемы чаще всего в порядке выполнения UI-операций, а не в самих Breakpoints. Практичный шаблон — рассматривать маршрутизацию как транзакцию: сначала принять решение, затем выполнить перемещения, затем упорядоченно применить побочные эффекты (видимость, фокус, обновление данных).
Конкретно это означает: избегайте, чтобы отдельные элементы управления во время перемещения сами вызывали события, которые в свою очередь запускают перерасчёт макета или доступ к данным. В FMX это случается, например, когда при смене родителя срабатывают OnEnter/OnExit или когда выражение LiveBinding переоценивается из‑за обновления Bounds. Если вы наблюдаете такие эффекты, помогает центральный переключатель «Updating» (как в Router) плюс чёткий пост‑шаг: только после ApplyRoutes можно запускать дорогие операции (например, перезагрузить список, привязать детальное представление).
Это особенно актуально для клиентов с доступом REST: непреднамеренная перезагрузка во время изменения размера может привести к лишним запросам. В LAN это обычно незаметно, но в VPN или при мобильном подключении — сразу.
Когда этот подход оправдан — и где у него ограничения
Layout-Router оправдан, если:
- FMX-приложение эксплуатируется годами и над одними и теми же экранами работают несколько разработчиков,
- UI-блоки могут быть чётко разделены (Sidebar/Details/Content),
- вам нужны воспроизводимые правила Breakpoint вместо ad‑hoc тонкой настройки
Align.
Ограничения проявляются, когда экран должен быть сильно «fluid» (много динамических плиток, настоящие Masonry-Layouts). Тогда более подходят TFlowLayout/TGridPanelLayout или собственные классы раскладки. Также, если очень много отдельных контролов переключаются между слотами, поддержка маршрутов становится неуправляемой — в этом случае лучше разбивать на более крупные блоки или ввести декларативный слой конфигурации (например, JSON-конфигурация для сопоставления слотов, загружаемая при старте).
Итог: Для Responsive Layouts FMX «Umhängen mit Breakpoints» является прагматичным компромиссом: меньше хаоса в дизайнере, чёткие правила, стабильные состояния. Он не заменяет продуманную структуру UI, но даёт вам надёжное основание для контролируемой доработки FMX-клиентов в цифровых корпоративных решениях между различными форм-факторами.
Если вы хотите аккуратно воспроизвести такую архитектуру макета в существующем Delphi- или FMX-приложении, не рискуя регрессиями пользовательского интерфейса в эксплуатационных сценариях, вы можете технически обсудить это с нами: обсудить проект или модернизацию с Net-Base.
В профессиональной среде также важную роль играют Delphi Fmx Breakpoints и Firemonkey Layout, когда интеграции, потоки данных и дальнейшее развитие должны работать слаженно.
Следующий шаг
Если из темы вырастет реальный проект, архитектуру, текущую систему и эксплуатацию следует рассматривать совместно на ранних этапах.
Мы поддерживаем не только при отдельных вопросах, но и тогда, когда из фрагментов исходного кода, унаследованных проблем или идей портала должен сформироваться надёжный корпоративный проект.
- Текущее состояние, целевое состояние и технические риски оцениваются совместно.
- REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
- Вы заранее видите, какой путь экономически и эксплуатационно жизнеспособен.