💻

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

2022/01/01に公開

はじめに

WPF でタイトル バーをカスタマイズしたいということはあるのですが、割と簡単にできそうな WindowStyle プロパティを None に指定する方法があまりに残念です。また WindowChrome クラスもあまり思った動きをしないので (リサイズすると後ろに隠れているウィンドウが見え隠れする)、何とかならないかと頑張ってみました。なお、この問題についてはすでに素敵な記事があります。

http://grabacr.net/archives/480

http://grabacr.net/archives/507

WindowStyleNone をしたときの問題点について確認できたのは以下の 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