WindowStyle を None にしてカスタム ウィンドウを作ってみる
はじめに
WPF でタイトル バーをカスタマイズしたいということはあるのですが、割と簡単にできそうな WindowStyle
プロパティを None
に指定する方法があまりに残念です。また WindowChrome
クラスもあまり思った動きをしないので (リサイズすると後ろに隠れているウィンドウが見え隠れする)、何とかならないかと頑張ってみました。なお、この問題についてはすでに素敵な記事があります。
WindowStyle
を None
をしたときの問題点について確認できたのは以下の 3 点でした。
ウィンドウを最大化したときにタスク バーが隠れてしまう
この問題はよく知られているようで、検索すればいろいろ出てきます。今回はこちらの記事の方法で対応することにしました。
DragMove メソッドだと Aero スナップができない
タイトル バーを掴んでウィンドウを移動できるようにする方法としては、MouseLeftButtonDown
イベントで DragMove
メソッドを使う方法がサンプルとして見つけることができます。しかし、この方法だと、最大化された状態でタイトル バーを掴んで元に戻す、ということができません。
ウィンドウのリサイズができない
ResizeMode
プロパティを CanResizeWithGrip
に指定してお茶を濁すという手もあるのですが、せっかくなので頑張ってみました。
サンプル コード
実行手順
事前準備
タイトルにもあるように 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