雑誌のテーマからプロジェクト実践へ
該当記事に関連するサービス・技術ページ
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個もの個別コントロールを移動すると、ルート一覧自体が障害源になります。layFilterBar、layNavigation、layResultList、layDetails のようなコンテナを使う方が望ましい。
ソーススニペット: FMXのレスポンシブレイアウト向けブレークポイントルーター
以下のコードは、FMXフォームで使用する補助ユニットを想定しています。ブレークポイント(XS/SM/MD/LG/XL)を算出し、定義されたコントロールを指定されたスロットコンテナに移動します。重要なポイント:
- Debounce über
TThread.ForceQueue: 複数のリサイズイベントを一度の更新にまとめます(UIのちらつきが減り、リフロー回数が減少します)。 - 再入防止: レイアウト更新がしばしば再び Resize/Layout をトリガーするため、その対策が必要です。
- オプション: 画面の向き(Portrait/Landscape)はブレークポイント判定に組み込むことができます。
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 と詳細ペインが上下に並ぶことが多いです。
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 も重要な役割を果たします。
次のステップ
テーマが実際のプロジェクトになる場合、アーキテクチャ、既存資産、運用は早い段階でまとめて検討するべきです。
私たちは単なる個別の問い合わせへの対応にとどまらず、ソースの断片やレガシー課題、ポータルの構想が堅牢な企業向けプロジェクトへと成長する段階まで支援します。
- 既存環境、目標像、技術的リスクを一体として評価します。
- REST、データアクセス、ポータル、ロールアウトは後工程として先送りされることはありません。
- 早期に、どのアプローチが経済的かつ運用面で実行可能かを判断できます。