0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WinForms 通知ウィンドウ設計|TopMost/SetWindowPos/Zオーダー【外伝G16】

0
Last updated at Posted at 2026-01-20

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

最前面といえばTopMostが浮かぶ。だが、ウイルス対策ソフトのスキャン開始や通知の瞬間にアクティブを奪われ、タイピング先がすり替わるたびに思う。

「今の、何も考えずにTopMostで通知を作ってないか」と。

通知UIでこの挙動が混ざると、前面に出せたとしても操作が止まり、現場の評価が落ちる。
このページは「見せたい」と「入力を奪わない」を分け、Zオーダー制御を安全に組み立てる。WinFormsだけで済む範囲と、Win32 APIが必要になる境目も揃える。


1. このページで手に入るもの

ここに置く表とコードは、通知UIの「前面化」と「入力を奪わない」を同時に満たすための最短セットになる。

  • TopMostを常用しない通知UIの設計パターン(前に見せるが、アクティブは動かさない)
  • SetWindowPos + SWP_NOACTIVATE による「前面化だけ」テンプレ(コピペ可・コメント付き)
  • WinForms側での非アクティブ表示テンプレ(ShowWithoutActivation / WS_EX_NOACTIVATE
  • 複数モニタ/タスクバー/DPI差でも位置とサイズが崩れにくい座標決めテンプレ(コピペ可)
  • 制限環境(RDP/セキュリティ方針)で「必ず前面」が成立しない前提と縮退案

2. 先に逆引き(症状 → 原因 → 対策)

この表は、現象から混入点を特定し、最短で打つ手と拡張の方向を一直線につなぐ。

症状(見えていること) 主な原因(混入点) まず打つ手(最短) 次の一手(拡張まで)
通知を出すたびに入力先が変わる Show() でアクティブ化 / TopMost常用 非アクティブ表示 + SWP_NOACTIVATE クリック時だけアクティブ化の分岐を入れる
TopMostにしたのに前面に出ないことがある フォアグラウンド制限(OS方針)/ 他TopMostと競合 「必ず前面」を要件にしない + 縮退手段 Flash/トレイ/ログを組み合わせる
Alt+Tabへ混ざる / タスクバーに出る ShowInTaskbar / 拡張スタイル未調整 WS_EX_TOOLWINDOW を使う 操作が必要な通知は別ウィンドウへ分離
位置がタスクバーに被る / 複数モニタで飛ぶ Bounds 基準 / Primary固定 Screen.WorkingArea 基準で画面を選ぶ Ownerの画面/アクティブ画面を基準にする
DPIが違う画面でサイズが崩れる 96DPI固定のサイズ/余白/画像 DeviceDpi でスケール係数を作る 表示直前に再計算する
たまに例外や無反応が混ざる UIスレッド外から表示/Win32呼び出し UI窓口でUIスレッドへ切り替える Handle再作成も含め寿命を守る

3. 最短テンプレ(コピペ): 「前面に見せるが入力を奪わない」トースト

ここに置くコードは、(1) 非アクティブ表示、(2) Zオーダーの前面化、(3) TopMost常駐の回避、(4) 作業領域/DPIを考慮した位置決め、を最短で揃える。
通知要求の混入点がどこにあっても、UIスレッドへ戻す境目も併せて用意する。

3.1 トーストフォーム(WinForms + Win32)

このコードは、ShowWithoutActivationSetWindowPos(SWP_NOACTIVATE) を併用し、「見える順番」だけを動かす。

using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;

public sealed class ToastForm : Form
{
    // 貼る場所:
    // - 通知(トースト)専用フォームとして切り出す。
    // - メインフォームとは独立して生成し、短時間で閉じる前提にする。

    private readonly Timer _closeTimer;

    public ToastForm()
    {
        FormBorderStyle = FormBorderStyle.None;
        ShowInTaskbar = false;
        StartPosition = FormStartPosition.Manual;
        DoubleBuffered = true;

        _closeTimer = new Timer { Interval = 3500 };
        _closeTimer.Tick += (_, __) =>
        {
            _closeTimer.Stop();
            Close();
        };
    }

    // WinForms側の「非アクティブ表示」。表示してもアクティブ化しにくくなる。
    protected override bool ShowWithoutActivation => true;

    // Alt+Tabへ混ざりにくくし、表示でアクティブ化しにくくする。
    // 注意: WS_EX_NOACTIVATE はクリック操作も制限しやすい。操作が必要なら後述の判例を参照。
    protected override CreateParams CreateParams
    {
        get
        {
            var cp = base.CreateParams;
            cp.ExStyle |= WS_EX_TOOLWINDOW;
            cp.ExStyle |= WS_EX_NOACTIVATE;
            return cp;
        }
    }

    public void ShowToast(string message)
    {
        // 境目:
        // - UIスレッド上で呼ぶ(UI窓口で切り替える)。
        // - Handleを確保してから Win32 の前面化へ進む。

        EnsureUiThread();

        Text = message; // サンプル。実際はLabel等へ描画する。

        EnsureHandleCreated();
        SetToastLocationBottomRight();

        // 表示はするがアクティブは動かさない。Zオーダーだけ上げる。
        ShowNoActivateAndBringToFront();

        _closeTimer.Stop();
        _closeTimer.Start();
    }

    private void EnsureHandleCreated()
    {
        // Handle寿命を守る: IsHandleCreatedがfalseならCreateControlで確保する。
        if (!IsHandleCreated)
        {
            CreateControl();
            if (!IsHandleCreated)
                throw new InvalidOperationException("ToastFormのHandle生成に失敗した。UIスレッド上で生成されているか確認が必要。");
        }
    }

    private void SetToastLocationBottomRight()
    {
        // 画面選択:
        // - 最小: 現在のマウス位置の画面
        // - 代替: Ownerの画面(所有関係を使う設計ならOwner基準)
        var screen = Screen.FromPoint(Cursor.Position);
        var wa = screen.WorkingArea;

        // DPI:
        // - 最低限 DeviceDpi でスケール係数を作り、サイズと余白を同倍率で計算する。
        float scale = DeviceDpi / 96f;

        int width  = (int)(320 * scale);
        int height = (int)(96  * scale);
        int margin = (int)(12  * scale);

        Size = new Size(width, height);

        int x = wa.Right - width - margin;
        int y = wa.Bottom - height - margin;

        Location = new Point(Math.Max(wa.Left, x), Math.Max(wa.Top, y));
    }

    private void ShowNoActivateAndBringToFront()
    {
        // ShowWindow(SW_SHOWNOACTIVATE): 非アクティブで表示する。
        // SetWindowPos(SWP_NOACTIVATE): Zオーダーを変えてもアクティブ化しない。
        ShowWindow(Handle, SW_SHOWNOACTIVATE);

        // 一時的にTopMost相当へ上げる(常駐は避ける)。
        SetWindowPos(
            Handle,
            HWND_TOPMOST,
            Location.X, Location.Y, Size.Width, Size.Height,
            SWP_NOACTIVATE | SWP_SHOWWINDOW);

        // すぐ通常へ戻す(TopMost状態を残し続けない)。
        SetWindowPos(
            Handle,
            HWND_NOTOPMOST,
            0, 0, 0, 0,
            SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE);
    }

    private void EnsureUiThread()
    {
        // UIスレッド以外から呼ぶと、Handle生成やP/Invokeが不安定になりやすい。
        // ここでは「UIスレッドで呼ぶ」前提にし、違反は早期検知する。
        if (InvokeRequired)
            throw new InvalidOperationException("UIスレッド以外からToast表示が呼ばれた。呼び出し元でUIスレッドへ切り替える必要がある。");
    }

    // ---- Win32 API / 定数 ----

    private const int WS_EX_TOOLWINDOW = 0x00000080;
    private const int WS_EX_NOACTIVATE = 0x08000000;

    private const int SW_SHOWNOACTIVATE = 4;

    private const uint SWP_NOSIZE = 0x0001;
    private const uint SWP_NOMOVE = 0x0002;
    private const uint SWP_NOACTIVATE = 0x0010;
    private const uint SWP_SHOWWINDOW = 0x0040;

    private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
    private static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2);

    [DllImport("user32.dll", SetLastError = true)]
    private static extern bool SetWindowPos(
        IntPtr hWnd,
        IntPtr hWndInsertAfter,
        int X,
        int Y,
        int cx,
        int cy,
        uint uFlags);

    [DllImport("user32.dll", SetLastError = true)]
    private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
}

補足:

  • ShowWindow / SetWindowPos は Win32 API(user32.dll)になる。WinFormsだけで足りない境目は「非アクティブのまま前面化したい」場面。
  • SWP_NOACTIVATE を外すと、前面化の瞬間にアクティブが移ることがある。入力先のすり替わりを避けるなら常に付ける。
  • WS_EX_NOACTIVATE は「表示でアクティブ化しにくい」一方で、クリック操作も抑止しやすい。操作ボタンが必要なら判例で分岐を入れる。

3.2 呼び出し側テンプレ(UI窓口)

このコードは、通知要求がどこから来てもUIスレッドへ戻すための窓口になる。呼び出し側が増えても境目は1か所に揃う。

using System;
using System.Windows.Forms;

public sealed class UiToastGateway
{
    private readonly Control _ui;
    private ToastForm? _toast;

    public UiToastGateway(Control ui)
    {
        _ui = ui ?? throw new ArgumentNullException(nameof(ui));
    }

    public void Notify(string message)
    {
        // 境目: ここでUIスレッドへ戻す。
        if (_ui.InvokeRequired)
        {
            _ui.BeginInvoke(new Action(() => Notify(message)));
            return;
        }

        _toast ??= new ToastForm();
        _toast.ShowToast(message);
    }
}

4. 解説: 用語・評価規則・落ち方を揃える

ここは、実装がぶれやすい言葉とOS側の規則を先に揃え、後半の判例で迷わない状態にする。

4.1 用語を揃える

  • Zオーダー
    • 画面上でウィンドウが重なる順序。上にあるほど「手前に見える」。SetWindowPos はZオーダーにも触れる。
  • 最前面(TopMost)
    • 通常のウィンドウより前に居るグループ。Form.TopMostHWND_TOPMOST が相当する。
  • アクティブ
    • 入力対象になっているウィンドウ。キーボード入力先に直結する。
  • フォアグラウンド
    • 現在操作中として扱われるアプリ側の状態。ここが別プロセスのまま動くと、前面化が抑制されやすい。
  • フォーカス
    • ウィンドウ内のどのコントロールが入力を受けるか。アクティブ化が動くとフォーカスも連動しやすい。
  • 非アクティブ表示
    • 表示や前面化は行うが、アクティブを動かさない表示。ShowWithoutActivation / SW_SHOWNOACTIVATE / SWP_NOACTIVATE が核になる。
  • 作業領域(WorkingArea)
    • タスクバー等を除いた領域。通知が被る落ち方を減らすなら Screen.WorkingArea を基準にする。
  • DPI
    • 96DPI基準からの倍率。画面ごとに倍率が違う環境では、サイズ・余白・フォントの見え方が変わる。
  • P/Invoke
    • DllImport でWin32 APIを呼ぶ手段。Handle生成や呼び出しスレッドの前提が崩れると落ち方が増える。
  • RDP
    • リモートデスクトップ。セッション境界やポリシーの影響で、前面化が期待通りにならない場合がある。

4.2 「必ず前面」が崩れる理由(OS側の評価規則)

Windowsは、バックグラウンドのプロセスが勝手にフォアグラウンドを奪う挙動を抑える方針を持つ。
そのため「別アプリ操作中でも常に前面に出す」を100%成立させる設計は不安定になりやすい。
通知UIは「前に出す」より「見える確率を上げる」へ切り替えると壊れ方が減る。

  • 自アプリがフォアグラウンドのときは前面化を強める
  • それ以外は非アクティブ表示を優先し、縮退手段を併用する
  • 操作が必要なら「クリックした時だけ」アクティブ化して詳細画面へ移す

4.3 なぜ SetForegroundWindow を使わないか

SetForegroundWindow はフォアグラウンド(入力対象)を動かす方向になる。通知でこれを使うと「タイピング先がすり替わる」を誘発しやすい。
前面化は SetWindowPos でZオーダーだけを上げ、SWP_NOACTIVATE でアクティブを動かさない方針が安全側になる。

4.4 ShowWindowSetWindowPos の読み方(最低限)

この2つは似て見えるが、触っている対象が違う。

  • ShowWindow(hWnd, nCmdShow)
    • 「表示状態」を変えるAPI。SW_SHOWNOACTIVATE は「表示するがアクティブは動かさない」を意味する。
  • SetWindowPos(hWnd, hWndInsertAfter, X, Y, cx, cy, uFlags)
    • 「位置/サイズ/Zオーダー」を変えるAPI。ここでの要点は hWndInsertAfteruFlags になる。
      • hWndInsertAfter = HWND_TOPMOST は最前面グループへ入れる指定(TopMost相当)。
      • hWndInsertAfter = HWND_NOTOPMOST は通常グループへ戻す指定(TopMost相当の解除)。
      • SWP_NOACTIVATE はZオーダーを動かしてもアクティブを動かさない指定。
      • SWP_NOMOVE / SWP_NOSIZE は位置やサイズを触らない指定(Zオーダーだけ触りたいときに使う)。

補足:

  • Form.TopMost = true は内部的に最前面グループへ入る。便利だが「残し続ける」形になりやすい。
  • ここでは HWND_TOPMOST へ一度入れ、すぐ HWND_NOTOPMOST へ戻すことで「常時最前面」にならない形にする。

5. 判例: 混入経路が多い順に潰す

ここは、ありがちな落ち方を「悪い例 → 直す → ポイント」で揃え、最短で修正箇所が決まる状態にする。

常時TopMostで押し切る

TopMost常用は短期で動くが、通知が他アプリの上に張り付く落ち方を作りやすい。

// 悪い例: 常時TopMost
TopMost = true;
Show();
// 直す: 表示の瞬間だけTopMost相当に上げ、すぐ通常へ戻す
ShowWindow(Handle, SW_SHOWNOACTIVATE);
SetWindowPos(Handle, HWND_TOPMOST, X, Y, W, H, SWP_NOACTIVATE | SWP_SHOWWINDOW);
SetWindowPos(Handle, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE);
  • TopMostを残し続けない
  • SWP_NOACTIVATE を外さない
  • 目標は「常時最前面」ではなく「見える確率を上げる」

ShowWithoutActivationだけで済ませて前面に出ない

非アクティブ表示は「入力を奪わない」には効くが、「手前に出す(Zオーダーを上げる)」とは別になる。
別アプリ操作中は背面側に残ることがあり、「出したのに見えない」が混ざる。
ここでは「表示」と「前面化」を分け、非アクティブ表示を維持したままZオーダーだけ上げる。

// 悪い例: 非アクティブ表示だけで前面化まで期待する
protected override bool ShowWithoutActivation => true;
Show();
// 直す: 非アクティブ表示 + Zオーダー制御を併用する(アクティブは動かさない)
ShowWindow(Handle, SW_SHOWNOACTIVATE);
SetWindowPos(Handle, HWND_TOPMOST, X, Y, W, H, SWP_NOACTIVATE | SWP_SHOWWINDOW);
SetWindowPos(Handle, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE);
  • 「表示」と「前面化」は別に考える
  • SWP_NOACTIVATE は常に付ける
  • TopMost相当は短時間で終える

UIスレッド外から呼んでHandle寿命が崩れる

通知要求はタイマー/Task/イベント購読/コールバック経由でUIスレッド外から来やすい。
そのままフォームを触ると、Handle未生成のままP/Invokeへ進んだり、待ちが混ざったりして、例外や無反応の混入点になる。
ここではUIへ戻す窓口を1つにし、Handle確保の後にWin32へ進む境目を揃える。

// 悪い例: バックグラウンドからフォームを直接操作する
Task.Run(() =>
{
    toast.ShowToast("...");
});
// 直す: UIスレッドへ戻してから触る
if (ui.InvokeRequired)
{
    ui.BeginInvoke(new Action(() => toast.ShowToast("...")));
    return;
}

toast.ShowToast("...");
  • UI操作はUIスレッドで行う
  • Handle生成(IsHandleCreated / CreateControl)を境目として守る
  • UI窓口へ集約すると呼び出し側の揺れが減る

複数モニタで位置が飛ぶ(タスクバーに被る)

単一画面前提の座標(Primary固定、Bounds基準)は、複数モニタやタスクバー位置変更でズレやすい。
特に Bounds はタスクバーを含むため、右下に置いたつもりが被る落ち方が混ざる。
ここでは「どの画面に出すか」と「どの領域を基準にするか」を分け、作業領域を基準に右下へ置く。

// 悪い例: Primary固定 + Bounds基準で、タスクバーへ被りやすい
var r = Screen.PrimaryScreen.Bounds;
Location = new Point(r.Right - Width, r.Bottom - Height);
// 直す: 出す画面を選び、WorkingArea基準で置く
var screen = Screen.FromPoint(Cursor.Position);
var wa = screen.WorkingArea;

Location = new Point(
    wa.Right - Width - margin,
    wa.Bottom - Height - margin);
  • Bounds ではなく WorkingArea を使う
  • 画面選択はカーソル位置/Owner基準などで決める
  • 余白(margin)を持つ

DPI差でサイズが崩れる(フォント/余白が合わない)

96DPI前提の固定ピクセルは、画面ごとの倍率差でサイズ・余白・角丸・アイコンが揃わなくなる。
ノート+外部モニタの組み合わせでは、表示先の画面によって「同じ通知なのに大きさが違う」が出やすい。
ここでは DeviceDpi を基準に係数を作り、サイズと余白を同倍率で計算する(表示直前に再計算する)。

// 悪い例: 96DPI前提の固定値
Size = new Size(320, 96);
int margin = 12;
// 直す: DeviceDpiを基準に同倍率でスケールする
float scale = DeviceDpi / 96f;

int width  = (int)(320 * scale);
int height = (int)(96  * scale);
int margin = (int)(12  * scale);

Size = new Size(width, height);
  • サイズ・余白・フォントの倍率を揃える
  • 表示先の画面が変わるなら表示直前に再計算する
  • 画像/アイコンも同じ倍率で扱う方針にする

クリックで詳細を開きたいが、表示では入力を奪いたくない

WS_EX_NOACTIVATE を入れると、クリックしてもアクティブ化しにくくなる。
通知から詳細画面へ遷移したい場合は「表示は非アクティブ、クリック時だけアクティブ化」を分ける。

// 悪い例: 表示の時点でアクティブ化し、入力先がすり替わる
Show();
Activate();
// 直す: 表示は非アクティブのまま。クリック時だけ詳細画面を前面に出す
// - トースト自体は非アクティブ表示のまま
// - 詳細画面(またはメインフォーム)をユーザー操作に合わせて出す
private void Toast_Click(object? sender, EventArgs e)
{
    // クリックは明確なユーザー操作なので、ここで詳細画面を表示する
    // 例: メインフォームへ通知を伝え、必要なら前面へ出す
    _gateway.OpenDetails();
}
  • 表示でアクティブを動かさない
  • アクティブ化が必要な場面は「ユーザー操作に反応する」形にする
  • トーストに操作を詰め込みすぎず、詳細画面へ回す

制限環境で前面化が効かない(RDP/セキュリティ方針)

前面化はOS側の制御に触れるため、成立しない環境が混ざる。前面化失敗時の縮退が無いと「出ているはずなのに見えない」で止まる。

// 悪い例: 前面化が成功する前提で、失敗時を無視する
SetWindowPos(hWnd, HWND_TOPMOST, x, y, w, h, SWP_NOACTIVATE | SWP_SHOWWINDOW);
// 直す: 失敗時でも気付ける手段を残す(例: 点滅/トレイ/ログ)
bool ok = SetWindowPos(hWnd, HWND_TOPMOST, x, y, w, h, SWP_NOACTIVATE | SWP_SHOWWINDOW);
if (!ok)
{
    // 表示自体は続け、別の手段で気付けるようにする
    ShowWindow(hWnd, SW_SHOWNOACTIVATE);

    // 例:
    // - FlashWindowEx でタスクバー点滅
    // - NotifyIcon のバルーン
    // - ログ記録(後追い調査のため)
}
  • 「必ず前面」は要件にしない
  • 前面化の失敗パターンを前提に縮退手段を入れる
  • 失敗しても作業が止まらない形にする

async/await後にUIへ戻らず、たまに触って落ちる

async は別スレッドという意味ではなく、await 後の復帰先は文脈で変わる。
復帰先がUIでない場面が混ざると、通知表示がUIスレッド外になりやすい。

// 悪い例: UI文脈を落とした後にUIを触る
await SomethingAsync().ConfigureAwait(false);
_toast.ShowToast("...");
// 直す: UI窓口へ渡し、内側でUIスレッドへ戻す
await SomethingAsync().ConfigureAwait(false);
_uiToastGateway.Notify("...");
  • await 後にUIへ戻る前提は外れることがある
  • UI操作はUI窓口へ集約する
  • 境目(UIへ戻す箇所)を1か所にする

6. チェックリスト(レビューで見る所)

この表は、通知UIの修正レビューで詰まりが出やすい観点だけを残す。

観点 確認項目 合格ライン ありがちな失敗パターン
TopMost 常用していないか 表示の瞬間だけ上げ、すぐ戻す TopMostがtrueのまま残る
入力 アクティブ/フォーカスが動かないか ShowWithoutActivation + SWP_NOACTIVATE Show() だけで前面化を狙う
Zオーダー 前面化だけを行えているか SetWindowPosSWP_NOACTIVATE がある SetForegroundWindow で奪いに行く
UIスレッド Win32/表示がUIスレッドか UI窓口で切り替える Task/タイマーから直接フォーム操作
Handle Handle確保が境目になっているか IsHandleCreated を見て確保 未生成のままP/Invoke
位置 タスクバー/複数画面 WorkingArea 基準 Bounds 基準で被せる
DPI 画面ごとの倍率 DeviceDpi でスケール 96DPI固定で崩れる
縮退 制限環境 点滅/トレイ/ログがある 前面化失敗が無視される

7. セルフチェック(5問)

この5問に即答できれば、通知UIの前面化は概ね安全側になる。

  1. TopMostは常用せず、表示の瞬間だけ上げて戻しているか
  2. 非アクティブ表示(ShowWithoutActivation / SW_SHOWNOACTIVATE / SWP_NOACTIVATE)が揃っているか
  3. UIスレッドへ戻す境目が、呼び出し口に1つだけあるか
  4. 位置決めは WorkingArea 基準になっているか(タスクバー/複数画面)
  5. 前面化が成立しない環境向けの縮退(点滅/トレイ/ログ)があるか
回答例
  • 1: HWND_TOPMOST に上げた直後に HWND_NOTOPMOST へ戻している
  • 2: ShowWithoutActivationSWP_NOACTIVATE が両方入っている
  • 3: UiToastGateway.Notify のようなUI窓口があり、そこ以外からフォームを触らない
  • 4: Screen.WorkingArea を使い、余白(margin)を持っている
  • 5: SetWindowPos 失敗時でも気付ける手段(点滅/トレイ/ログ)がある

関連トピック

  • 連載Index: S00_門前の誓い_総合Index
  • 止血の作法と計測の型: E04
  • メッセージループの構造(UIスレッド/ポンプ): G11
  • 非同期の実戦整理(await復帰先/文脈): G13
  • ルール化の落とし込み: R06

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?