Net-Base Журнал

03.06.2026

Адаптивные макеты в Delphi FMX: брейкпоинты без хаоса в визуальном дизайнере (Layout-Router как фрагмент исходного кода)

Адаптивные макеты FMX в реальных клиентах быстро становятся хрупкими: шквалы изменения размера, переключения DPI и дублирование деревьев компоновки приводят к дрейфу компоновки. В этом обновлении показан маршрутизатор компоновки с Breakpoints, Slots и Debounce — включая Delphi-исходники и практические ловушки.

03.06.2026

От темы в журнале к проектной практике

Соответствующие страницы услуг и технологий к статье

Сцена знакома: FMX-клиент для индивидуального корпоративного ПО на компьютере разработчика работает корректно – и разваливается при первом реальном использовании. На Surface в доке всё подходит, в режиме планшета сайдбар перекрывает детальную область, а на мониторе с 125%-DPI интерфейс начинает «дрожать» при масштабировании окна. К тому моменту возникает вопрос, который никто не хочет слышать: «Как мы строим Responsive Layouts FMX, чтобы дизайнер не взорвался от пяти вариантов?»

FireMonkey поставляет множество блоков для компоновки (Align/Anchors, TFlowLayout, TGridPanelLayout, TScaledLayout). Чего не хватает — так это декларативной системы брейкпоинтов, как в вебе. На практике это быстро превращается в разбросанные if Width < ...-блоки, трудно воспроизводимые каскады ресайза и параллельно поддерживаемые деревья компоновки, которые со временем расходятся.

Этот обзор опирается на шаблон, который в зрелых клиентах более стабилен: Layout-Router, который централизованно вычисляет брейкпоинты и перемещает существующие UI-блоки между подготовленными слотами. Это не «UI-Framework im Framework», а небольшая тестируемая прослойка, которая целенаправленно решает те крайние случаи, где FMX на практике даёт сбой: штормы ресайза, смена DPI, поворот, побочные эффекты фокуса/HitTest.

Анализ сценария: что именно ломается при Responsive Layouts FMX?

Искусственно, но реалистично: экран «Aufträge» имеет наверху панель фильтров, слева — навигацию (Status, Zeiträume), в центре — список результатов, справа — детальную область. На десктопе это должно быть в три колонки, на планшете — в две (список + детали, навигация как Drawer), на телефоне — одна колонка (Navigation → Liste → Details как Stack).

Типичная эволюция в команде выглядит так:

  1. Создают два дерева компоновки в дизайнере (Desktop vs. Mobile) и переключают Visible.
  2. Потом появляются LiveBindings, таймеры, «Refresh bei Fokus» — внезапно логика дублируется.
  3. Позже «ещё один» форм‑фактор (Split-Screen/маленький ноутбук), и всё становится непригодным для ревью.

Основная ошибка — не Align или Anchors. Основная ошибка — дублирование состояния: две панели фильтров, два списка, два состояния селекции. Как только состояния расходятся, отладка превращается в угадывание.

Почему FMX-брейкпоинты не работают как в CSS

В вебе брейкпоинты декларативны (Media Queries), рендеринг‑пайплайн рассчитан на это. В FMX решения по компоновке чаще императивны и событийно управляемы: срабатывает OnResize, код переключает состояние. Плюс добавляются специфические для FMX триггеры:

  • Логические единицы вместо физических пикселов: Width/ClientWidth измеряются в «FMX‑пунктах», на которые влияет масштабирование. Per-Monitor-DPI на Windows может изменить решения по лэйауту, не меняя «ощутимого» размера окна.
  • Проходы лэйаута и реэнтрантность: изменение Parent/Align может вызвать дополнительные события Resize/Layout. Без защитного механизма возникает петля.
  • Z-Order/HitTest как побочный эффект: смена родителя изменяет Z-Order; невидимый клиент‑контейнер с HitTest=True может перехватывать клики.

Вывод: если вы хотите корректные брейкпоинты в FMX, нужно уделять «когда» (Debounce, Guard, порядок) столько же внимания, сколько и «что» (какой блок куда).

Архитектура: слоты + перемещение вместо переключения Visible

Базовая прочная единица — Slot: контейнер (TLayout, иногда TPresentedControl), который в макете выполняет определённую роль, например slotTop, slotLeft, slotCenter, slotRight, slotOverlay. UI-блоки (панель инструментов, боковая панель, список, детали) для каждого Breakpoint назначаются соответствующему Slot и при выполнении перемещаются.

Почему перемещение элементов в бизнес‑клиентах часто оказывается лучшим решением:

  • Одна инстанция — одно состояние: текст в полях редактирования, позиции прокрутки и выделения в списках сохраняются.
  • Меньше риска ошибок при подключении: события, действия, биндинги привязаны ровно к одному дереву контролов.
  • Читаемость при ревью: правила Breakpoint читаются в одном месте (конфигурация роутера), а не разбросаны по формам.

Фрагмент исходного кода: роутер макета с Breakpoints, Debounce и „Transaction“-Apply

Следующий роутер намеренно небольшой, но не наивный: он агрегирует Resize-события (Debounce), защищает от Re-Entrancy и допускает заменяемую логику Breakpoint. Важно: он работает с перемещением существующих инстанций. Именно это предотвращает типичный «State Drift», возникающий из параллельных деревьев макета.

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);

  TNBRoute = record
    Control: TControl;
    TargetSlot: TControl;
    Align: TAlignLayout;
  end;

  TNBRouteList = TList<TNBRoute>;

  // Callback, damit Breakpoints nicht „hart“ an Breite gekoppelt sind.
  TNBGetBreakpointEvent = reference to function(const AClientSize: TSizeF): TNBLayoutBreakpoint;

  // Optional: Hook nach Apply, um Fokus wiederherzustellen oder datengetriebene Aktionen zu entkoppeln.
  TNBApplyEvent = reference to procedure(const ABp: TNBLayoutBreakpoint);

  TNBLayoutRouter = class(TComponent)
  private
    FRoot: TControl;
    FPending: Boolean;
    FUpdating: Boolean;
    FCurrent: TNBLayoutBreakpoint;
    FOnGetBreakpoint: TNBGetBreakpointEvent;
    FOnAfterApply: TNBApplyEvent;
    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;
    property OnAfterApply: TNBApplyEvent read FOnAfterApply write FOnAfterApply;
  end;

implementation

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: mehrere Resize-Events zu einem Apply bündeln.
  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) or (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;

  if Assigned(FOnAfterApply) then
    FOnAfterApply(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-Wechsel beeinflusst Z-Order.
    // Reihenfolge wird durch DefineRoute-Aufrufe festgelegt.
    for LRoute in LList do
    begin
      if LRoute.Control.Parent <> LRoute.TargetSlot then
        LRoute.Control.Parent := LRoute.TargetSlot;

      // Align nach Parent setzen.
      LRoute.Control.Align := LRoute.Align;
      LRoute.Control.Visible := True;
    end;
  finally
    FUpdating := False;
  end;
end;

function TNBLayoutRouter.DefaultBreakpoint(const AClientSize: TSizeF): TNBLayoutBreakpoint;
var
  W, H: Single;
begin
  W := AClientSize.cx;
  H := AClientSize.cy;

  // Kombiniert: sehr geringe Höhe behandelt wie „kleiner“, z. B. Split-Screen oder Kiosk.
  if (W < 480) or (H < 360) then Exit(bpXS);
  if (W < 768) or (H < 480) then Exit(bpSM);
  if W < 1024 then Exit(bpMD);
  if W < 1440 then Exit(bpLG);
  Result := bpXL;
end;

end.

Использование в форме: определение слотов, маршрутизация блоков

В дизайнере вам нужны только слоты и блоки. Маршрутизатор переназначает блоки соответствующим образом. Это снижает хаос в работе дизайнера и сохраняет правила в Delphi-коде проверяемыми.

Delphi
procedure TFrmOrders.FormCreate(Sender: TObject);
var
  LPrevFocus: IControl;
begin
  FRouter := TNBLayoutRouter.Create(Self);
  FRouter.AttachRoot(layRoot); // TLayout Align=Client

  // Breakpoint XS: Stack
  FRouter.DefineRoute(bpXS, layToolbar, slotTop,     TAlignLayout.Top);
  FRouter.DefineRoute(bpXS, layNav,     slotContent, TAlignLayout.Top);
  FRouter.DefineRoute(bpXS, layList,    slotContent, TAlignLayout.Client);
  FRouter.DefineRoute(bpXS, layDetails, slotContent, TAlignLayout.Bottom);

  // Breakpoint MD: 3-Spalten
  FRouter.DefineRoute(bpMD, layToolbar, slotTop,    TAlignLayout.Top);
  FRouter.DefineRoute(bpMD, layNav,     slotLeft,   TAlignLayout.Client);
  FRouter.DefineRoute(bpMD, layList,    slotCenter, TAlignLayout.Client);
  FRouter.DefineRoute(bpMD, layDetails, slotRight,  TAlignLayout.Client);

  // Fokus-Falle entschärfen: vor/nach Apply RESTaurieren.
  FRouter.OnAfterApply :=
    procedure(const ABp: TNBLayoutBreakpoint)
    begin
      // Beispiel: Fokus wiederherstellen, wenn FMX ihn beim Parent-Wechsel verliert.
      if Assigned(Screen) and Assigned(Screen.Focused) then
        LPrevFocus := Screen.Focused
      else
        LPrevFocus := nil;

      if (LPrevFocus <> nil) and (LPrevFocus.GetObject is TFmxObject) then
        (LPrevFocus.GetObject as TControl).SetFocus;
    end;

  // Optional: eigene Logik, wenn Sie Portrait/Landscape härter trennen wollen.
  FRouter.OnGetBreakpoint :=
    function(const S: TSizeF): TNBLayoutBreakpoint
    begin
      if (S.cx < 820) or (S.cy < 500) then
        Result := bpSM
      else
        Result := bpMD;
    end;
end;

Подводные камни: где подход может дать сбой

Штормы Resize: почему Debounce в FMX обязателен

FMX вызывает OnResize не только при действиях пользователя, но и при смене Parent-/Align или при изменении масштабирования. Без Debounce вы в худшем случае будете маршрутизировать несколько раз за кадр. Поэтому маршрутизатор сводит всё через TThread.ForceQueue к «одному Apply на цикл сообщений». Это прагматичный компромисс: вы получаете стабильность без таймерной логики или искусственных задержек.

Z-Order и HitTest: невидимые блокировщики кликов

Смена родительского контейнера меняет Z-Order. Если после переключения breakpoint’а кнопки перестают реагировать на клики, часто дело не в том, что «Visible» установлено неправильно, а в том, что какой-то контейнер сверху перехватывает события. В FMX ключевое свойство — HitTest: прозрачный лейаут с HitTest=True блокирует мышь/тач. Для оверлеев (Drawer, Flyouts) предусмотрите отдельный slotOverlay, который намеренно находится «сверху», и проверяйте значения HitTest по умолчанию после смены стиля.

TGridPanelLayout и процентные столбцы: усилители рефлоу

Если вы используете TGridPanelLayout с процентными значениями, лучше перемещать целые блоки грида, а не отдельные дочерние элементы грида. Перерасчёты процентов + цепочки Align + смены родителя — типичная причина нестабильных промежуточных состояний. Альтернативой TGridPanelLayout в некоторых экранах может быть простая система слотов с фиксированной шириной для боковой панели/деталей, поскольку она более детерминирована.

Сравнение: «Visible-Layouts» vs. переназначение через Router

Критерий Несколько деревьев макета (переключение Visible) Layout-маршрутизатор (переподключение элементов управления)
Состояние (редактирование/прокрутка/выбор) Риск дрейфа, из‑за дублирования экземпляров Стабильно, поскольку один экземпляр на блок
Отладка Ошибки видны только в «Варианте B» Единое дерево элементов управления, воспроизводимое применение изменений
Сопровождение Удобно для дизайнера, в дальнейшем дорого обходится Код поддаётся ревью, правила централизованы
FMX-пограничные случаи (Resize/DPI) Часто мерцание и проблемы с таймингом Debounce/Guard управляемы

Классификация с источниками: почему группировка «UI-Tick» технически обоснована

Шаблон «собирать дорогие обновления UI и выполнять их контролируемо» актуален не только для Layout, но и для всего, что бурно реагирует на UI‑события (Requests, Logs, Bindings). Тот, кто в клиенте помимо Layout хочет аккуратно вести сетевую или отладочную телеметрию, выигрывает от той же дисциплины: чёткие триггеры, чёткие шлюзы, отсутствие неукротимого потока событий. В этом смысле принцип Debounce/Guard хорошо сочетается с надёжными операционными подходами, как описано в фрагментах Net-Base по Overload‑Gates (контроль параллелизма вместо «всё одновременно») — см. материал про Concurrency‑Gate в Delphi-REST-Server: High Performance REST Server in Delphi: Concurrency-Gate, 429/503 und sauberes Overload-Verhalten.

А если в FMX‑клиенте вы дополнительно используете встроенный веб‑интерфейс (например для аутентификации, просмотра документов или портальных элементов), дисциплина жизненного цикла так же критична: инициализация, Dispose, отписка от событий. Это концептуально похоже на «чёткую изоляцию смены Parent». Практическая точка опоры: Delphi WebView2 в FMX: путь принятия решений для жизненного цикла, Bridge, загрузок и стабильной логики Dispose.

Однозначная редакционная оценка

С редакционной точки зрения Layout-Router не «Nice-to-have», а осмысленная инвестиция, как только экран должен корректно обслуживать более двух форм‑факторов: он вынуждает принять явное решение по Layout и делает вопросы состояния видимыми. Цена — необходимость сознательно разбивать UI‑блоки и поддерживать чистоту структуры слотов; кто этого не сделает, просто создаст дополнительный слой хаоса.

Вывод: когда имеет смысл использовать Router для адаптивных Layout в FMX — и границы применения

Router особенно оправдан, если вы модернизируете FMX‑приложение в долгосрочной перспективе, обслуживаете несколько классов устройств и должны поддерживать стабильные состояния (вводы, выделения, позиции прокрутки). Он также полезен в ситуациях с legacy‑кодом, поскольку централизует правила точек перелома (breakpoints) и снижает риск распределённо встроенных особых случаев.

Ограничения: если требуется очень «fluid» макет (плиточные стены, Masonry, динамические карты), специализированные классы компоновки или собственный алгоритм размещения элементов будут целесообразнее, чем slot‑маршрутизация. И если экран состоит из очень большого числа отдельных элементов управления, список маршрутов становится нечитабельным: сначала рефакторьте (соберите в более крупные блоки), затем применяйте маршрутизацию.

Если вам нужно в существующем Delphi/FMX-решении стабильно поддержать макет на нескольких форм-факторах, не перенося при этом эксплуатационных проблем (фокус, HitTest, Resize-штормы) в повседневную работу, это часто можно быстро локализовать в ходе технического ревью: свяжитесь с нами.

Для этой темы также важны Delphi Fmx Breakpoints и Firemonkey Layout. В статье эти аспекты систематизированы в понятной форме и показано, на что следует обращать внимание в повседневной практике.

Обсудить проект или задачу по модернизации с Net-Base.

Следующий шаг

Если из темы становится реальный проект, архитектуру, существующее состояние и эксплуатацию следует рассматривать совместно на ранней стадии.

Мы поддерживаем не только при отдельных вопросах, но и тогда, когда из фрагментов исходного кода, унаследованных проблем или идей портала должен сформироваться надёжный корпоративный проект.

  • Текущее состояние, целевое состояние и технические риски оцениваются совместно.
  • REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
  • Вы заранее видите, какой путь экономически и операционно жизнеспособен.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и электронная почта доступны немедленно. Для Instagram мы готовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.

Написать комментарий

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert