Net-Base マガジン

03.06.2026

Delphi FMX におけるレスポンシブレイアウト:デザイナーの混乱を招かないブレークポイント管理(レイアウトルーターのソーススニペット付き)

FMXのレスポンシブレイアウトは実務ではすぐに脆弱になる。リサイズの嵐、DPIの変化、回転、そして「Visible-Layouts」が二重の状態を生み、デバッグ困難なリフローを引き起こす。本稿では、ランタイムでUIブロックを制御するブレークポイント対応のレイアウトルーターを示す。

03.06.2026

雑誌のテーマからプロジェクト実践へ

該当記事に関連するサービス・技術ページ

Wer in Delphi FireMonkey mehrere Formfaktoren bedienen muss, landet schnell bei Responsive Layouts FMX – und ebenso schnell bei einer Mischung aus Align-Kaskaden, versteckten Layout-Containern und Designer-Workarounds, die beim nächsten DPI- oder Rotationswechsel kippen. In gewachsenen Business-Software-Clients ist das besonders unangenehm: Die UI wird weiterentwickelt, Teams wechseln, und plötzlich hängt Logik an visuellen Details.

問題の核心: FireMonkey は多くの構成要素(例: Align, Anchors, TScaledLayout, TFlowLayout, TGridPanelLayout)を提供するが、Web のような「ネイティブな」ブレークポイントシステムはない。サイズ変更に応じて処理することは可能だが、明確なアーキテクチャがないと、多数のフォームに散在する「if Width < … then …」に終始する。

本稿では Layout-Router を示す: ブレークポイントを中央で管理し、Controls(あるいはレイアウトブロック全体)を準備されたスロット間で入れ替える小さなコンポーネントだ。目的は、状態を維持しつつコードの保守性を高め、回転や入れ子になったレイアウト、再入可能性といったエッジケースを吸収すること。さらに、実務で「デモでは動く」から「運用で安定して動く」へ差を生む、いくつかの目に見えにくい工夫も併せて紹介する。

Warum Breakpoints in FMX anders sind als im Web

Web レイアウトではブレークポイントは通常宣言的(CSS メディアクエリ)だが、FMX ではレイアウト判断は実行時に命令的に行われるのが典型で、OnResize で切り替える。加えてプラットフォーム固有の性質がある:

  • デバイスピクセルと論理ピクセル: ClientWidth/ClientHeight はスケーリングに依存する論理単位で表される。DPI の変化(例: Windows Per-Monitor-DPI)によって、物理的には変化していなくてもレイアウトが再トリガーされることがある。
  • 回転とセーフエリア: モバイルプラットフォームは Insets(ノッチ/セーフエリア)を提供する — OS やデバイスに依存する。単に幅だけでブレークポイントを決めるのは不十分なことが多い。画面上の利用可能領域はウィンドウの純粋なサイズより小さいためだ。
  • レイアウトパス: FireMonkey は段階的にレイアウトを計算する。誤ったタイミングで Parent/Align を変更すると副作用(複数回のリフローやサイズのチラつきなど)が発生する。

Layout-Router は、(1)「いつ」(Resize/Scale/Rotation)を「どのように」(レイアウトルール)から切り離し、(2) ルールを一箇所に集約することでこれに対処する。技術責任者にとって最も重要な効果は、多数の局所的な例外処理に代わり、明確で検証可能な意思決定の中心が得られることだ。

Architektur: Layout-Router mit Slots statt Control-Erzeugung

FMX のための肝となる手法は、Controls を動的に再生成しないことだ。既存の Controls をスロット間で入れ替える。スロットは単に UI の領域を表すコンテナ(例: TLayout)で、サイドバー、ツールバー、コンテンツ、フッター、詳細ペインなどを表す。

Vorteile in individueller Unternehmenssoftware:

  • 状態が保持される(編集テキスト、スクロール位置、選択項目など)。インスタンスを再生成しないためだ。
  • イベントやタイマー、バインディングの二重配線リスクが低減する
  • レイアウトルールが可視化される:どのブロックがどのスロットに入るかを各ブレークポイントごとに追跡・レビューできる。

実務上重要: UIブロックは十分粗く切り分けてください。30個もの個別コントロールを移動すると、ルート一覧自体が障害源になります。layFilterBarlayNavigationlayResultListlayDetails のようなコンテナを使う方が望ましい。

ソーススニペット: FMXのレスポンシブレイアウト向けブレークポイントルーター

以下のコードは、FMXフォームで使用する補助ユニットを想定しています。ブレークポイント(XS/SM/MD/LG/XL)を算出し、定義されたコントロールを指定されたスロットコンテナに移動します。重要なポイント:

  • Debounce über TThread.ForceQueue: 複数のリサイズイベントを一度の更新にまとめます(UIのちらつきが減り、リフロー回数が減少します)。
  • 再入防止: レイアウト更新がしばしば再び Resize/Layout をトリガーするため、その対策が必要です。
  • オプション: 画面の向き(Portrait/Landscape)はブレークポイント判定に組み込むことができます。
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);

  // マッピング: どの Control が特定のブレークポイントでどのスロット(コンテナ)に配置されるかを定義する。
  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;

  // デバウンス: メッセージループごとに一度だけ適用
  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;
  // ブレークポイントは意図的に粗めに設定している。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.

フォームでのルーターの使用方法

Slots を TLayout として定義し(例: layTop, layLeft, layContent)、各ブレークポイントごとにどのブロックがどこに配置されるかを登録します。典型的には、小さなブレークポイントでは Sidebar と詳細ペインが上下に並ぶことが多いです。

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;

Einordnung: Warum „Umhängen“ oft stabiler ist als Visible-Schalten

一般的なアプローチとして、各バリアントごとに別個のレイアウトツリーを用意し、単に Visible を切り替える方法があります。デザイナ上は扱いやすいものの、典型的な副作用があります:

  • バインディング/イベントの重複: 類似する 2 つのコントロールを同期して維持する必要がある(例: 2 つのフィルタバー)。
  • タブ順とフォーカス: 切り替え時にフォーカスを失ったり、TabStop/HitTest の設定によっては見えないコントロールにフォーカスが移ってしまうことがある。
  • 状態の乖離: スクロール位置、選択状態、編集中のテキストが乖離する。

Umhängen はインスタンスを一意に保ちます。重要なのは、レイアウトブロックを独立して移動できるように切り分けることです(例:多くの個別コントロールを置くのではなく「Sidebar」を独立したコンテナにする)。この設計は保守と障害解析で効果を発揮します:2 つの並列する影の UI ではなく、単一のインスタンスをデバッグできます。

実務上の落とし穴(とそのデバッグ法)

1) リサイズの嵐と再入(Re-Entrancy)

FMX は OnResize をユーザーのリサイズだけでなく、スタイル変更、親の変更、場合によっては DPI 変更時にもトリガーします。デバウンスがないとアプリがレイアウトループに陥ることがあります。ルーターは変更を次の UI ティックに移すために TThread.ForceQueue を利用しています。

デバッグのヒント: ブレークポイント、サイズ、更新カウンタを含めたログ(例: OutputDebugString)はリフローループの発見に有効です。さらに ApplyRoutes の開始と終了の時刻をログすれば、単一のリサイズが「連鎖」しているかどうかがすぐに分かります。

2) Zオーダー、HitTest および「見えない」クリックブロッカー

親の変更は Z-Order を変えます。オーバーレイ(例: Flyouts)がクリックできなくなる場合、クライアントに合わせて配置されたコンテナが上に重なり、HitTest が有効になっていることが原因であることが多いです。対策として、オーバーレイ領域専用に意図的に最上位の別スロットを用意し、そこにのみそのようなコントロールを parent する方法があります。FMX では、コントロールがマウス/タッチイベントを受け取るかどうかを示す HitTest が、可視性よりも原因となることがよくあります。

3) TGridPanelLayout とパーセンテージによるサイズ指定

TGridPanelLayoutは、パーセンテージ指定の列/行をAlign=Clientと組み合わせ、かつ動的に親を差し替えると予期せぬ再計算を誘発することがあります。Gridを使用する必要がある場合は、Gridをスロットに配置し、Gridの子要素ではなくGridブロック全体のみを親の差し替え対象にしてください。これによりレイアウトパスの組合せが減り、安定します。

4) フォーカス、仮想キーボードと「ジャンプする」入力フィールド

モバイル向けのFMXアプリや、Windows搭載タブレットでも発生する端的な問題です:親を切り替える際、フォーカス中のEdit-Controlが一時的に親を失ってしまうことがあり、それが仮想キーボードの閉鎖やカーソル位置のリセットを引き起こします。実務上有効だった対策は、ルーティング前に現在のフォーカスを一時保存(Focused/IFMXFocusControl)、ルーティング後(同一UIティック内)にフォーカスを復元する、という手順です。特に「2カラム」(Tablet/PC)と「1カラム」(Phone)を切り替える入力フォームで有効です。

バリエーション:幅だけでなくフォームファクターによるブレークポイント

実際のマルチプラットフォームクライアントでは、「幅」だけが適切な判断材料とは限りません。考慮すべき実用的な変種:

  • 幅と高さ:非常に平たいウィンドウ(例:レジ端末、分割表示)は別のルールを必要とします。
  • 向き:タブレットでのLandscapeは多くの場合「デスクトップに近く」、縦向きはより「モバイル的」です。
  • セーフエリアの利用可能領域:iOS/Androidではシステムバー等により実効的に使える高さが大幅に縮むことがあります。Heightだけを見ると、ルーティングが遅れがちになります。

このルーターは、ブレークポイント判定機能を差し替えられるように設計してあります。これは同じフォームが複数のホスト上で動くレガシー状況(例:一度は通常ウィンドウ、別のホストでは埋め込みコンテナ)にも役立ちます。

整然とした設計:レイアウトルーティングを「トランザクション」として扱う

大きな画面では問題はブレークポイント自体よりもUI操作の順序によって顕在化します。実務で有効なパターンは、ルーティングをトランザクションとして扱うことです:まず判定を行い、次に親の差し替えを行い、最後に副作用(Visibility、フォーカス、データのリフレッシュ)を順序立てて実行します。

具体的には、個々のコントロールが親の差し替え中に独自にイベントを発火して、それがさらにレイアウトやデータアクセスを開始してしまうのを避けます。FMXでは、親の切り替えでOnEnter/OnExitが発火したり、Boundsの更新でLiveBinding式が再評価されたりすることでこれが起きます。そのような挙動が見られる場合は、ルーターのような中央の「Updating」スイッチと明確なポストステップを設けると有効です:ApplyRoutes完了後でなければコストの高い処理(例:一覧の再読み込み、詳細ビューのバインド)を走らせない、という運用です。

特にREST経由でのアクセスがあるクライアントでは重要です:リサイズ中の意図しないリロードは不必要なリクエストを誘発します。LAN内では目立たないことが、VPNやモバイル回線では即座に問題となります。

いつこのアプローチが有効か―そして限界

このLayoutルーターが有効なのは、次のような場合です:

  • FMXアプリケーションが数年にわたり運用され、複数の開発者が同じ画面を保守する場合、
  • UIブロックを明確に分離できる(サイドバー/詳細/コンテンツ等)、
  • その場しのぎのAlign調整ではなく、再現性のあるブレークポイント規則が必要な場合。

画面が高度に「フルイド」である必要がある場合(多数の動的タイル、実際の Masonry レイアウトなど)、制約が顕在化します。その場合は TFlowLayout/TGridPanelLayout や独自のレイアウトクラスの方が適しています。非常に多くの個別コントロールがスロット間を移動するような場合、ルートの保守が煩雑になります – その際は大きめのブロックに切り分けるか、宣言的な構成レイヤー(例:起動時に読み込むスロット割当の JSON 構成)を導入する方が望ましいです。

結論:Responsive レイアウトにおいて FMX では「ブレークポイントによる付け替え」が実用的な折衷策です。Designer の混乱を減らし、ルールを明確にし、状態を安定させます。これは綿密に設計された UI 構造に代わるものではありませんが、FMX クライアントをフォームファクタ間で制御された形で継続的に発展させるための信頼できる骨組みを提供します。

既存の Delphi または FMX アプリケーションでそのようなレイアウトアーキテクチャを、運用シナリオで UI 回帰を招くことなく丁寧に適用したい場合は、技術的に整理することを承ります:プロジェクトまたはモダナイゼーション案件を Net-Base とご相談ください。

専門的な文脈では、統合、データフロー、継続的な拡張が整合して機能する必要がある場合、Delphi Fmx Breakpoints と Firemonkey Layout も重要な役割を果たします。

プロジェクトまたはモダナイゼーション案件を Net-Base とご相談ください.

次のステップ

テーマが実際のプロジェクトになる場合、アーキテクチャ、既存資産、運用は早い段階でまとめて検討するべきです。

私たちは単なる個別の問い合わせへの対応にとどまらず、ソースの断片やレガシー課題、ポータルの構想が堅牢な企業向けプロジェクトへと成長する段階まで支援します。

  • 既存環境、目標像、技術的リスクを一体として評価します。
  • REST、データアクセス、ポータル、ロールアウトは後工程として先送りされることはありません。
  • 早期に、どのアプローチが経済的かつ運用面で実行可能かを判断できます。

投稿を共有

この投稿を直接共有する

LinkedIn、X、XING、Facebook、WhatsApp、およびE-Mailはすぐに利用可能です。Instagram用のリンクと短文はただちに準備します。

Eメール

Instagramは新しいタブで開きます。リンクと短文は事前にクリップボードにコピーされます。