WPFで独自タスクバーを実装する方法|ウィンドウ列挙・UWPアイコン取得・.NET8対応

WPF で独自タスクバーを実装しようとすると、単にウィンドウの一覧を出してアイコンを並べるだけでは済まず、Win32 API・WinRT API・.NET のバージョン差など、複数レイヤーの仕様を正しく抑える必要があります。この記事では、標準タスクバーと同等レベルのウィンドウ列挙とアイコン表示を実現するためのポイントと、.NET 5/8 環境でつまずきやすい WinRT 参照周りの落とし穴を、実装寄りの視点で整理します。

目次

WPF で独自タスクバーを実装する全体像

まずゴールを整理しておきます。ここで想定する「独自タスクバー」は次のような要件を満たすものとします。

  • 標準タスクバーとほぼ同じウィンドウだけを一覧表示する(不要な内部ウィンドウは含めない)
  • Win32 アプリだけでなく UWP/ストアアプリのアイコンも取得して表示する
  • .NET 5 / .NET 8 など .NET Core 系 WPF で動作させる
  • (理想として)タスクバーの点滅や進捗も扱いたい

これを分解すると、主に次の 4 つのテーマになります。

  1. タスクバーと同じウィンドウだけを列挙する方法
  2. Win32 + UWP アプリのアイコン取得
  3. タスクバーの点滅・進捗状態の扱い(できること / できないこと)
  4. .NET 5/8 環境での Windows.winmd / WinRT 参照エラー対策

以下では、それぞれについて「なぜそうする必要があるのか」「実装例」「ハマりどころ」をセットで解説していきます。

タスクバーと同じウィンドウだけを列挙する

Process.GetProcesses() がダメな理由

.NET だけで完結させたい場合にありがちなのが、

foreach (var p in Process.GetProcesses())
{
    // p.MainWindowHandle や p.MainWindowTitle を使う
}

という実装です。しかし、この方法だと次の問題が出ます。

  • タスクバーに表示されない内部プロセスまで列挙される
  • UWP アプリでは MainWindowHandle が 0 になる場合がある
  • サービスタイプのプロセスまで大量に混ざる

結果として、「標準タスクバーと同じ見た目」にはなりません。そこで、プロセスではなくウィンドウを列挙し、スタイルを見てフィルタリングするのが基本戦略になります。

EnumDesktopWindows + スタイル判定で絞り込む

標準タスクバーが持つウィンドウ一覧に近いものを得るには、Win32 API の EnumDesktopWindows を使います。列挙された各ウィンドウに対して、スタイルや属性を条件にかけてフィルタします。

フィルタ条件チェック内容目的
可視ウィンドウIsWindowVisible(hWnd) が true非表示の管理用ウィンドウを除外
子ウィンドウでないWS_CHILD を持たない親ウィンドウの内部パネルなどを除外
ツールウィンドウでない拡張スタイルに WS_EX_TOOLWINDOW が無い検索ボックス、フローティングツールなどを除外
アクティブ外窓でないWS_EX_NOACTIVATE が無いユーザー操作対象外のウィンドウを除外
Cloak されていないDwmGetWindowAttribute(DWMWA_CLOAKED) == 0バックグラウンド化された UWP などを除外
オーナーウィンドウとの関係オーナーがいる場合は WS_EX_APPWINDOW が付いているダイアログだけがタスクバーに出るパターンに対応

これらの条件を満たすものだけを残すと、標準タスクバーとほぼ同等のウィンドウ一覧が得られます。

C# での P/Invoke 実装例

最低限必要になる P/Invoke 宣言と判定コードの例です。

internal static class NativeMethods
{
    public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);

    [DllImport("user32.dll")]
    public static extern bool EnumDesktopWindows(
        IntPtr hDesktop,
        EnumWindowsProc lpfn,
        IntPtr lParam);

    [DllImport("user32.dll")]
    public static extern bool IsWindowVisible(IntPtr hWnd);

    [DllImport("user32.dll", SetLastError = true)]
    public static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);

    [DllImport("user32.dll")]
    public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);

    [DllImport("dwmapi.dll")]
    public static extern int DwmGetWindowAttribute(
        IntPtr hwnd,
        int dwAttribute,
        out int pvAttribute,
        int cbAttribute);

    public const int GWL_STYLE = -16;
    public const int GWL_EXSTYLE = -20;

    public const long WS_CHILD = 0x40000000L;
    public const long WS_VISIBLE = 0x10000000L;

    public const long WS_EX_TOOLWINDOW = 0x00000080L;
    public const long WS_EX_NOACTIVATE = 0x08000000L;
    public const long WS_EX_APPWINDOW = 0x00040000L;

    public const int DWMWA_CLOAKED = 14;
    public const uint GW_OWNER = 4;
}

フィルタリング用のヘルパーメソッド例です。

static bool IsTaskbarWindow(IntPtr hWnd)
{
    if (!NativeMethods.IsWindowVisible(hWnd))
        return false;

    // Cloaked (見えない) ウィンドウを除外
    int cloaked;
    NativeMethods.DwmGetWindowAttribute(
        hWnd,
        NativeMethods.DWMWA_CLOAKED,
        out cloaked,
        sizeof(int));

    if (cloaked != 0)
        return false;

    var style = (long)NativeMethods.GetWindowLongPtr(hWnd, NativeMethods.GWL_STYLE);
    var exStyle = (long)NativeMethods.GetWindowLongPtr(hWnd, NativeMethods.GWL_EXSTYLE);

    if ((style & NativeMethods.WS_CHILD) != 0)
        return false;

    if ((exStyle & NativeMethods.WS_EX_TOOLWINDOW) != 0)
        return false;

    if ((exStyle & NativeMethods.WS_EX_NOACTIVATE) != 0)
        return false;

    // オーナーウィンドウとの関係を確認
    var owner = NativeMethods.GetWindow(hWnd, NativeMethods.GW_OWNER);
    if (owner != IntPtr.Zero && (exStyle & NativeMethods.WS_EX_APPWINDOW) == 0)
    {
        // オーナー付きかつ APPWINDOW ではないものはタスクバー対象外
        return false;
    }

    return true;
}

列挙処理は次のように書けます。

var windows = new List<IntPtr>();

NativeMethods.EnumDesktopWindows(IntPtr.Zero, (hWnd, lParam) =>
{
    if (IsTaskbarWindow(hWnd))
    {
        windows.Add(hWnd);
    }
    return true; // 列挙継続
}, IntPtr.Zero);

このリストを WPF 側の ObservableCollection<TaskbarWindowModel> にマッピングすれば、標準タスクバーにかなり近い動作になります。

WPF 側のモデル設計と更新タイミング

WPF では、列挙結果をそのまま UI に持ち込まず、最低限次のようなモデルクラスを用意するのがおすすめです。

public class TaskbarWindowModel : INotifyPropertyChanged
{
    public IntPtr Handle { get; }
    public int ProcessId { get; }
    public string Title { get; private set; }
    public ImageSource Icon { get; private set; }

    // 必要であれば AUMID やパッケージ情報も保持
    public string AppUserModelId { get; private set; }

    // 省略: INotifyPropertyChanged 実装
}

更新タイミングとしては、単純なポーリング(例:500ms おき)よりも、WinEventHook を使ってウィンドウの生成・破棄・フォアグラウンド変更などのイベントを拾って更新する方がパフォーマンスに優れます。

  • EVENT_OBJECT_CREATE / EVENT_OBJECT_DESTROY: ウィンドウの追加・削除
  • EVENT_SYSTEM_FOREGROUND: アクティブウィンドウ切り替え

WinEventHook 自体はネイティブ寄りで扱いにくいので、まずはポーリングで動かし、必要に応じて最適化していくと実装コストとのバランスが良くなります。

UWP / ストアアプリを含むアイコン取得

まず Win32 アプリのアイコン取得フロー

アイコン取得は、

  1. Win32 API で直接アイコンハンドルを取得してみる
  2. ダメなときだけ UWP 用のルート(AUMID 経由)にフォールバック

という二段構えにするのが現実的です。Win32 アプリの場合は、次の順番でアイコンを探します。

  1. WM_GETICON メッセージで ICON_BIG / ICON_SMALL を問い合わせ
  2. それで取れなければ GetClassLongPtr(GCL_HICON) / GCL_HICONSM
  3. それでも無理なら、GetWindowThreadProcessId -> Process.MainModule.FileName から ExtractIconEx

ひとつの例を示します。

[DllImport("user32.dll")]
static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);

[DllImport("user32.dll", EntryPoint = "GetClassLongPtr")]
static extern IntPtr GetClassLongPtr64(IntPtr hWnd, int nIndex);

const uint WM_GETICON = 0x007F;
const int ICON_BIG = 1;
const int ICON_SMALL = 0;
const int GCL_HICON = -14;
const int GCL_HICONSM = -34;

static IntPtr GetWindowIconHandle(IntPtr hWnd)
{
    IntPtr hIcon;

    // 1. WM_GETICON (BIG)
    hIcon = SendMessage(hWnd, WM_GETICON, new IntPtr(ICON_BIG), IntPtr.Zero);
    if (hIcon != IntPtr.Zero) return hIcon;

    // 2. WM_GETICON (SMALL)
    hIcon = SendMessage(hWnd, WM_GETICON, new IntPtr(ICON_SMALL), IntPtr.Zero);
    if (hIcon != IntPtr.Zero) return hIcon;

    // 3. クラスアイコン (BIG)
    hIcon = GetClassLongPtr64(hWnd, GCL_HICON);
    if (hIcon != IntPtr.Zero) return hIcon;

    // 4. クラスアイコン (SMALL)
    hIcon = GetClassLongPtr64(hWnd, GCL_HICONSM);
    return hIcon;
}

取得した HICON は System.Windows.Interop.Imaging.CreateBitmapSourceFromHIcon を使って ImageSource に変換できます。

[DllImport("user32.dll", SetLastError = true)]
static extern bool DestroyIcon(IntPtr hIcon);

static ImageSource? CreateIconImageSource(IntPtr hIcon)
{
    if (hIcon == IntPtr.Zero)
        return null;

    var source = Imaging.CreateBitmapSourceFromHIcon(
        hIcon,
        Int32Rect.Empty,
        BitmapSizeOptions.FromEmptyOptions());

    // 他スレッドからも使えるよう Freeze
    source.Freeze();

    // ハンドル解放を忘れない
    DestroyIcon(hIcon);

    return source;
}

UWP / ストアアプリのアイコンは AUMID からたどる

UWP/ストアアプリでは WM_GETICON が期待通り動作せず、上記のルートではアイコンが取れないケースが多くなります。その場合は、次のように AUMID(Application User Model ID) を起点にしてアイコンファイルを探します。

  1. SHGetPropertyStoreForWindow で IPropertyStore を取得
  2. PKEY_AppUserModel_ID を読み取り、AUMID(例:Microsoft.WindowsCalculator_8wekyb3d8bbwe!App)を得る
  3. AUMID からパッケージファミリ名とアプリ ID を分解
  4. WinRT の Windows.Management.Deployment.PackageManager で該当パッケージを検索
  5. パッケージの InstalledLocation から AppxManifest.xml を読み込む
  6. <uap:VisualElements> の Square44x44Logo などを取得し、実ファイルパスに展開
  7. .scale-200.png, .targetsize-32.png などのバリエーションから DPI に応じてベストなものを選ぶ

アイコンファイルの優先度を決める例を表にまとめます。

用途優先するファイル名パターン備考
通常表示(100% DPI)targetsize-32.png > scale-100.png32px ベースで揃えるとレイアウトが安定
高 DPI(150% / 200%)scale-200.png > scale-150.png > scale-100.png大きい画像を縮小表示するとジャギーが出にくい
小アイコン表示targetsize-16.png > targetsize-32.png本当に小さく表示するときは 16px 専用を使う

読み込んだ PNG は WPF 側で BitmapImage として扱います。

static ImageSource? LoadPngAsImageSource(string path)
{
    if (!File.Exists(path))
        return null;

    var bitmap = new BitmapImage();
    bitmap.BeginInit();
    bitmap.UriSource = new Uri(path, UriKind.Absolute);
    bitmap.CacheOption = BitmapCacheOption.OnLoad;
    bitmap.EndInit();

    bitmap.Freeze(); // スレッドセーフにする

    return bitmap;
}

このように、「Win32 経由で取れなければ AUMID->パッケージ->マニフェスト->PNG」という順にフォールバックするパイプラインを組むことで、タスクバー上に並ぶほとんどのアプリでアイコンを表示できるようになります。

実装上の注意点

  • パッケージ情報の取得には WinRT API を使うため、後述する .NET 5/8 での WinRT 参照設定が前提になります。
  • UWP アプリは更新のたびにインストールパスが変わるため、アイコンキャッシュは AUMID やパッケージファミリ名をキー にするのがおすすめです。
  • ライト/ダーク/ハイコントラストでアイコンが切り替わるアプリもあるため、テーマ切り替えを契機にアイコンを再読み込みできる設計にしておくと品質が上がります。

タスクバーの点滅・進捗状態は原則「読み取れない」

Flash(点滅)・進捗バーの仕組み

標準タスクバーでは、ウィンドウがユーザーの注意を引きたいときにボタンがオレンジに光ったり(Flash)、ダウンロードなどの進捗がタスクバー上に表示されたりします。これらはそれぞれ次の API から設定されます。

  • 点滅:FlashWindow / FlashWindowEx
  • 進捗バー:ITaskbarList3::SetProgressState, SetProgressValue

しかし重要なのは、これらは「設定する」ための API であり、「現在の状態を取得する」ための API は公開されていないという点です。

なぜ読み取れないのか

点滅状態や進捗値は、Explorer.exe(シェル)が内部的に管理しており、他プロセスから読むための公式手段がありません。つまり、

  • 「このウィンドウが今点滅しているか」を知る API はない
  • 「このタスクボタンの進捗が何%か」を取得する API もない

という仕様になっています。ドキュメント上この読み取り API は存在せず、レジストリなどにも状態は出てきません。

どうしても実現したい場合の選択肢

アプローチ概要メリットデメリット
UI Automation でネイティブタスクバーを読むExplorer のタスクバー UI を UIA でたどり、ボタンの属性や描画を解析する理論上は既存タスクバーの状態に追従可能構造が頻繁に変わる・パフォーマンスが悪い・実装が極端に複雑
対象アプリに専用 IPC を組み込む自アプリ側からカスタムタスクバーへ進捗やフラグを通知する確実に状態を共有できる・描画も自由改造できるアプリにしか適用できない
あきらめて「未対応」と割り切る標準タスクバーの完全な互換は目指さないカスタムタスクバーの実装がシンプルになるユーザー体験が標準タスクバーと違う

「標準タスクバーを完全置き換えたい」というモチベーションだとつい頑張りたくなりますが、現実的には 点滅と進捗は OS の非公開領域なので取れないと割り切り、設計段階で機能要件を調整しておくことを強くおすすめします。

IPC での連携設計例

独自にコントロールできるアプリだけでも良いのであれば、次のような設計にすることで、純正タスクバー以上にリッチな状態共有も可能になります。

  • アプリ側:現在の状態を表すオブジェクトを定義(例:Status = Active/Waiting/Error, Progress = 0..100)
  • 通信手段:命名パイプ、TCP、gRPC など使いやすいものを選ぶ
  • カスタムタスクバー側:各アプリからの状態更新を購読し、アイコンのバッジや色、ミニプログレスバーを描画

こうしておけば、OS 依存の仕様に振り回されず、将来的な拡張も容易になります。

.NET 5 / .NET 8 での WinRT 参照エラー対策

Windows.winmd を直接参照してはいけない

.NET Framework(4.8 など)では、プロジェクトに Windows.winmd を直接参照して WinRT API を呼び出す手法がよく使われていました。しかし、.NET 5 以降では Windows.winmd の直接参照はサポートされておらず、そのままビルドすると NETSDK1130 などのエラーになります。

.NET 5/6/7/8 では、代わりに NuGet パッケージ経由で WinRT 投影を追加する必要があります。代表的なものは次の 2 つです。

  • Microsoft.Windows.CsWinRT
  • Microsoft.Windows.SDK.Contracts

UWP パッケージ情報や Windows.Management.Deployment.PackageManager を使うだけであれば、Microsoft.Windows.SDK.Contracts で十分なケースが多くなります。

プロジェクトファイルの例(WPF / .NET 8)

WPF で .NET 8 を使う場合の .csproj のサンプルです。

<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
    <UseWPF>true</UseWPF>
    <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.26100.1" />
  </ItemGroup>
</Project>

ここでのポイントは、

  • TargetFramework に -windows10.0.xxxxx.0 を付ける(Windows ターゲットを明示)
  • TargetPlatformMinVersion と TargetFramework のバージョンを整合させる
  • Windows.winmd を直接参照に追加しない

バージョンが噛み合っていないと、

「10.0.26100 > 7.0」 のような趣旨のエラー

が出ることがあります。この場合は、

  • プロジェクトの TargetFramework に指定している Windows バージョン
  • 参照している Microsoft.Windows.SDK.Contracts のバージョン

の双方を確認し、「ターゲット ≥ MinVersion」かつ「SDK 契約のバージョンと矛盾しない」ように調整してください。

.NET Framework 4.8 以前との違いを整理

項目.NET Framework 4.8 以前.NET 5 / 6 / 7 / 8
WinRT 参照方法Windows.winmd を直接参照可能直接参照は非推奨 / 非対応。NuGet で SDK 契約を追加
プロジェクト設定フレームワークバージョンのみTargetFramework に Windows バージョンを含める
ビルドエラーWinRT 周りは比較的少ないNETSDK1130 などターゲット不整合のエラーが出やすい
メリット既存サンプルがそのまま動く最新 API に追従しやすい・クロスプラットフォームの恩恵

「昔のサンプルをそのまま .NET 8 に持ってきたらビルドが通らない」という場合の多くは、WinRT 参照周りの設計が時代に合っていないことが原因です。一度プロジェクトファイルを整理し、NuGet ベースの WinRT 参照に移行してから、UWP アプリのアイコン取得コードを移植するとスムーズです。

高 DPI・パフォーマンス・安定性のベストプラクティス

アイコンキャッシュ戦略

タスクバー相当の UI では、アプリ数が増えるとアイコン読み込みのコストが馬鹿になりません。特に UWP アプリの PNG を毎回ファイルから読み込んでいると、ディスク I/O がネックになります。そこで、次のようなキャッシュ戦略を取ると快適になります。

  • キー:AUMID または EXE パス + サイズ(例:32, 48)
  • 値:ImageSource(BitmapImage / BitmapSource)
  • アイコン更新が起こり得るイベント(アプリ更新、テーマ切り替えなど)でキャッシュをクリア

実装イメージは次のようになります。

class IconCache
{
    private readonly Dictionary<(string key, int size), ImageSource> _cache
        = new();

    public ImageSource? GetOrAdd(string key, int size, Func<ImageSource?> factory)
    {
        var tupleKey = (key, size);
        if (_cache.TryGetValue(tupleKey, out var image))
            return image;

        image = factory();
        if (image != null)
        {
            // Freeze 済みであること(またはここで Freeze する)
            _cache[tupleKey] = image;
        }
        return image;
    }

    public void Clear() => _cache.Clear();
}

高 DPI 環境での見栄えを揃える

Windows 10/11 ではスケーリング 150%、200% 環境も珍しくありません。タスクバーのアイコンをクッキリ表示するには、

  • OS の DPI を取得して、使うアイコンサイズの目安を決める
  • 指定サイズ以上の PNG を選び、必要に応じて縮小して描画
  • WPF の SnapsToDevicePixels="True" を設定し、ぼやけを抑える

のような工夫が有効です。特に UWP アイコンは .scale-200 などの高解像度版が用意されていることが多いため、大きい画像を縮小して使うのが基本戦略になります。

スレッドセーフなアイコンの扱い

ウィンドウ列挙やアイコン読み込みをバックグラウンドスレッドで行い、UI スレッドに結果を渡す構成にすると、タスクバーの動作が軽くなります。その際は、

  • 読み込んだ BitmapImage は Freeze() を呼んでおく
  • UI スレッドへの反映だけ Dispatcher.Invoke/BeginInvoke で行う

といった基本を守っておくと、“The calling thread cannot access this object…” 系の例外を防げます。

想定される問題と対策の早見表

問題原因対策
タスクバーと違うウィンドウが混ざるウィンドウスタイルのフィルタが甘いWS_CHILD / WS_EX_TOOLWINDOW / Cloaked 判定を見直す
一部 UWP アプリのアイコンが表示されないAUMID が取れていない / マニフェストのパス解決ミスPKEY_AppUserModel_ID の取得とパスの解決ロジックをログ付きで検証
高 DPI でアイコンがボケて見えるscale-100 の PNG を引き伸ばしているscale-200 など高解像度 PNG を優先し、縮小して使う
バックグラウンドスレッドで例外が出るFreeze していない BitmapSource を他スレッドで使用アイコン生成直後に Freeze() を呼び、UI スレッドにはフリーズ済みを渡す
.NET 8 でビルドエラーWindows.winmd 直参照 / TFM と SDK 契約のバージョン不整合Windows.winmd を削除し、Microsoft.Windows.SDK.Contracts に移行

まとめ:WPF で「実用的な」カスタムタスクバーを作るために

WPF で標準タスクバーを置き換えるカスタムタスクバーを実装するには、単に Process.GetProcesses() を回してアイコンを表示するだけでは不十分です。実用レベルに仕上げるには、次のポイントを押さえる必要があります。

  • ウィンドウ列挙:EnumDesktopWindows でトップレベルウィンドウを列挙し、スタイルや Cloak 状態を見て精密にフィルタする
  • アイコン取得:Win32 のアイコンは WM_GETICON → GetClassLongPtr で取得し、UWP アプリは AUMID & WinRT 経由で PNG を特定する
  • 点滅・進捗:OS 非公開のため読み取りは基本的に不可能。必要なら自前の IPC でアプリと連携する設計に切り替える
  • .NET 5/8 の環境差:Windows.winmd の直接参照は避け、Microsoft.Windows.SDK.Contracts などの NuGet を使って WinRT API を呼び出す
  • 品質とパフォーマンス:アイコンキャッシュ、高 DPI 対応、BitmapImage.Freeze() などを徹底し、快適な動作を維持する

これらを組み合わせれば、「タスクバーと同等のウィンドウ一覧+アイコン表示」 を WPF で安全に実現しつつ、.NET バージョンの違いにも強い構成を取ることができます。一方で、タスクバーの点滅や進捗バーの読み出しは OS 非公開の領域であるため、要件定義の段階で別のアプローチ(IPC やオリジナルの状態表示など)を検討することが、長期的に見て安定した実装につながります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次