💻

WindowStyle を None にしてカスタム ウィンドウを作ってみる

に公開

はじめに

WPF でタイトル バーをカスタマイズしたい場合、WindowStyle プロパティを None に指定する方法が簡単そうに見えますが、実際にはいくつか課題があります。また、WindowChrome クラスもリサイズ時に背後のウィンドウが見えてしまうなど期待通りに動作しない場合があります。そのため、これらの問題を解決する方法について検討しました。今回の記事では、既存の素晴らしい記事も参考にしつつ、独自の対応方法をご紹介します。

http://grabacr.net/archives/480

http://grabacr.net/archives/507

WindowStyle を None にした場合、確認できた主な問題点は、以下の 3 点です。

ウィンドウを最大化したときにタスク バーが隠れてしまう

この問題はよく知られており、インターネット上でも多くの解決策が紹介されています。今回は、以下の記事の方法で対応します。

https://docs.microsoft.com/ja-jp/archive/blogs/llobo/maximizing-window-with-windowstylenone-considering-taskbar?WT.mc_id=M365-MVP-5002941

DragMove メソッドでは Aero スナップができない

タイトル バーをドラッグしてウィンドウを移動するには、MouseLeftButtonDown イベントで DragMove メソッドを使用する方法が一般的です。しかし、この方法では、最大化状態からタイトル バーをドラッグして元のサイズに戻す操作ができません。

ウィンドウのリサイズができない

ResizeMode プロパティを CanResizeWithGrip に設定することで一部対応できますが、今回はより柔軟な方法を検討します。

サンプル コード

https://github.com/karamem0/samples/tree/main/wpf-custom-window-style

実行手順

事前準備

XAML で WindowStyle プロパティを None に指定し、合わせて AllowsTransparency プロパティを True に設定します。

<Window
    x:Class="Karamem0.samples.Wpf.CustomWindow.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    d:DesignHeight="400"
    d:DesignWidth="600"
    AllowsTransparency="True"
    WindowStyle="None">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <Border x:Name="Chrome" Background="#FF505050" Padding="0,0,0,10">
        </Border>
        <Grid x:Name="ContentRoot" Grid.Column="0" Grid.Row="1" Background="#FF808080">
        </Grid>
    </Grid>
</Window>

ウィンドウのコード ビハインドでは、SourceInitialized イベントでウィンドウ メッセージをフックできるようにします。

protected override void OnSourceInitialized(EventArgs e)
{
    base.OnSourceInitialized(e);
    var handle = new WindowInteropHelper(this).Handle;
    if (handle == IntPtr.Zero)
    {
        return;
    }
    HwndSource.FromHwnd(handle).AddHook(this.WindowProc);
}

ウィンドウを最大化したときにタスクバーが隠れないようにする

先述の記事の方法を参考に、WindowProc メソッドで WM_GETMINMAXINFO をフックします。

private IntPtr WindowProc(IntPtr handle, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    if (msg == (int)Win32.WindowMessages.WM_GETMINMAXINFO)
    {
        var result = this.OnGetMinMaxInfo(handle, wParam, lParam);
        if (result != null)
        {
            handled = true;
            return result.Value;
        }
    }
    return IntPtr.Zero;
}

Win32 API の GetMonitorInfo 関数でモニター情報を取得し、タスク バー領域を除いた領域を最大値として返します。マルチ モニター環境にも対応しています。

private IntPtr? OnGetMinMaxInfo(IntPtr handle, IntPtr wParam, IntPtr lParam)
{
    var monitor = Win32.MonitorFromWindow(handle, Win32.MonitorFlag.MONITOR_DEFAULTTONEAREST);
    if (monitor == IntPtr.Zero)
    {
        return null;
    }
    var monitorInfo = new Win32.MonitorInfo();
    if (Win32.GetMonitorInfo(monitor, monitorInfo) != true)
    {
        return null;
    }
    var workingRectangle = monitorInfo.WorkingRectangle;
    var monitorRectangle = monitorInfo.MonitorRectangle;
    var minmax = (Win32.MinMaxInfo)Marshal.PtrToStructure(lParam, typeof(Win32.MinMaxInfo));
    minmax.MaxPosition.X = Math.Abs(workingRectangle.Left - monitorRectangle.Left);
    minmax.MaxPosition.Y = Math.Abs(workingRectangle.Top - monitorRectangle.Top);
    minmax.MaxSize.X = Math.Abs(workingRectangle.Right - monitorRectangle.Left);
    minmax.MaxSize.Y = Math.Abs(workingRectangle.Bottom - monitorRectangle.Top);
    Marshal.StructureToPtr(minmax, lParam, true);
    return IntPtr.Zero;
}

Aero スナップに対応したタイトル バーを作成する

この対応も Win32 API を利用するのが最も簡単です。WindowProc メソッドで WM_NCHITTEST をフックします。

private IntPtr WindowProc(IntPtr handle, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    if (msg == (int)Win32.WindowMessages.WM_NCHITTEST)
    {
        var result = this.OnNcHitTest(handle, wParam, lParam);
        if (result != null)
        {
            handled = true;
            return result.Value;
        }
    }
    return IntPtr.Zero;
}

lParam パラメーターからスクリーン座標を取得し、クライアント座標に変換します。リサイズ処理も含めて、このメソッドから分岐します。

private IntPtr? OnNcHitTest(IntPtr handle, IntPtr wParam, IntPtr lParam)
{
    var screenPoint = new Point((int)lParam & 0xFFFF, ((int)lParam >> 16) & 0xFFFF);
    var clientPoint = this.PointFromScreen(screenPoint);
    var borderHitTest = this.GetBorderHitTest(clientPoint);
    if (borderHitTest != null)
    {
        return (IntPtr)borderHitTest;
    }
    var chromeHitTest = this.GetChromeHitTest(clientPoint);
    if (chromeHitTest != null)
    {
        return (IntPtr)chromeHitTest;
    }
    return null;
}

VisualTreeHelper.HitTest メソッドでクライアント座標がタイトル バー上かどうかを判定し、HTCAPTION を返します。ただし、最大化最小化閉じる ボタン上の場合は何もしません。FindVisualAncestor メソッドは、VisualTreeHelper.GetParent メソッドで親を検索し、指定した型で最初に見つかったものを返す拡張メソッドです。

private Win32.HitTestResult? GetChromeHitTest(Point point)
{
    var result = VisualTreeHelper.HitTest(this.Chrome, point);
    if (result != null)
    {
        var button = result.VisualHit.FindVisualAncestor<Button>();
        if (button == null)
        {
            return Win32.HitTestResult.HTCAPTION;
        }
    }
    return null;
}

ウィンドウをリサイズできるようにする

同様に WM_NCHITTEST をフックし、クライアント座標がウィンドウ端にある場合は該当する戻り値を返します。判定ロジックはやや泥臭いですが、他に良い方法があればご教示いただけますと幸いです。

private Win32.HitTestResult? GetBorderHitTest(Point point)
{
    if (this.WindowState != WindowState.Normal)
    {
        return null;
    }
    var top = (point.Y <= 5);
    var bottom = (point.Y >= this.Height - 5);
    var left = (point.X <= 5);
    var right = (point.X >= this.Width - 5);
    if (top == true)
    {
        if (left == true)
        {
            return Win32.HitTestResult.HTTOPLEFT;
        }
        if (right == true)
        {
            return Win32.HitTestResult.HTTOPRIGHT;
        }
        return Win32.HitTestResult.HTTOP;
    }
    if (bottom == true)
    {
        if (left == true)
        {
            return Win32.HitTestResult.HTBOTTOMLEFT;
        }
        if (right == true)
        {
            return Win32.HitTestResult.HTBOTTOMRIGHT;
        }
        return Win32.HitTestResult.HTBOTTOM;
    }
    if (left == true)
    {
        return Win32.HitTestResult.HTLEFT;
    }
    if (right == true)
    {
        return Win32.HitTestResult.HTRIGHT;
    }
    return null;
}

おわりに

WPF でのカスタム ウィンドウ実装は、最終的に Win32 API に頼る部分が多くなってしまう点がもどかしいところです。

Discussion