からめもぶろぐ。

俺たちは雰囲気で OAuth をやっている

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

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

grabacr.net

grabacr.net

はじめに

WindowStyle = None をするだけだと何が問題かというと、確認できたのは以下の 3 点でした。

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

この問題はよく知られているようで、検索すればいろいろ出てきます。今回はこちらの記事の方法で対応することにしました。

blogs.msdn.microsoft.com

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

タイトル バーを掴んでウィンドウを移動できるようにする方法としては、MouseLeftButtonDown イベントで DragMove メソッドを使う方法がサンプルとして見つけることができます。しかし、この方法だと、最大化された状態でタイトル バーを掴んで元に戻す、ということができないんですよ。これが結構困りました。

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

ResizeMode プロパティを CanResizeWithGrip に指定してお茶を濁すという手もあるのですが、せっかくなので頑張ってみました。

サンプル コード

github.com

事前準備

タイトルにもあるように 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 頼みなのですよね。