前回の続きになります。先にこちらも読んで頂ければと。
WPF で Zune のようなウィンドウを作る
というか、前回「もうすぐ公開します」的な雰囲気出しておきながら 1 ヶ月放置というアレっぷり。。
はい、気を取り直して。
前回の投稿で Zune ライクなウィンドウを作りましたが、今回は Visual Studio 2012 です。
Zune、MetroTwit、GitHub for Windows と Visual Studio 2012 の違いといえば、Visual Studio の方は端が光ってるんですよね。しかも状況に応じて色が変わる。左から、起動時、編集中、デバッグ中、非アクティブ時です。
かっこいいですね。ちなみに私は濃色テーマが好きです。
まず思いつく方法
真っ先に思いついたのは、WindowStyle=”None” と AllowsTransparency=”True” にする方法。ウィンドウのクライアント領域と実際にコンテンツを配置する領域 (いわゆる LayoutRoot 的な) にマージンを設けて、DropShadowEffect で影をつけるというもの。
あっさりできました。本物と並べてみてもまぁそっくり。
コードはこちら。Window の Background が Transparent になっていることと、8 行目の Border の Margin=”10″ がミソです。実際のウィンドウの大きさは、見た目より 10 px 大きいのです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Width="500" Height="200"
AllowsTransparency="True" WindowStyle="None"
Background="Transparent">
BorderThickness="1" Margin="10">
これでいいじゃない、という場合はこれで完成です。
ですが、個人的にはこれではダメ… 前回の投稿の最後にも書きましたが、AllowsTransparency=”True” にすると挙動が通常のウィンドウと異なるのでよろしくないですし、今回のケースではウィンドウの実際のサイズと見た目のサイズが異なるのも微妙な感じ。。。
Visual Studio 2012 はどうなってるの?
じゃあ本家 Visual Studio 様はどういう実装になっているんだろう? と思って Spy++ で見てみたところ、こんな感じの造りになっていることが明らかに。
どうやら、色がついている端の部分の 1 px と合わせて 幅 9 px の VisualStudioGlowWindow という透明なウィンドウが張り付いているらしい。
ちなみに、Office 2013 も同じです。MSO_BORDEREFFECT_WINDOW_CLASS という透明なウィンドウがいて、影をつけています。
余談ですが、同僚とこの話をしたところ「どうして Visual Studio 開発チームはそんなややこしいことまでして光らせたかったの」と。はい、私にもわかりません。でもかっこいいんだから仕方ない。
で、無謀ながらこれを目指して作ってみました。
先にネタばらししてしまうと、一応それっぽいものが出来上がります。ですが、記事 1 回で全てを解説しきれるコードの量ではなくなってしまったので、今回は要点のみ解説していきます。
完成したサンプル コードはこちら。
https://github.com/Grabacr07/MetroRadiance
GlowWindow を作る
何はともあれ、GlowWindow を作らないことには始まりません。まずは要件を整理。
幅 1 px の線と、光る影っぽい何か
タスク バーに表示されない + アクティブ ウィンドウにならない
アプリケーションのメイン ウィンドウの四辺に張り付き、移動とサイズ変更に追従する
アプリケーションのメインウィンドウのサイズを変更させる
それぞれ順に見ていきましょう。なお、GlowWindow が張り付く先となるアプリケーションのメイン ウィンドウは、厳密には親子関係はありませんがここでは owner と呼んでいます。
幅 1 px の線と、光る影っぽい何か
これは簡単、GlowWindow の Content に Border を配置するだけです。
サンプルでは縦横どちらにも変えられる Glow というカスタム コントロールを作っていますが、中身は Border です。DropShadowEffect の Color は、Border の Background よりも明るめにした方が目立ってキレイかも。
ShadowDepth="0"
BlurRadius="8" />
デザイナー上で見るとこんな感じ (Zoom 800 %)。
タスク バーに表示されない + アクティブ ウィンドウにならない
「タスク バーに表示されない」ですが、Window の ShowInTaskbar=”False” です。おわり。…と思っていた時期が私にもありました。いや、間違いではないのですが。実は「アクティブ ウィンドウにならない」の実装がかなり曲者で、WPF だけでは無理と判断し、仕方なく P/Invoke することに。で、結局 GlowWindow の拡張ウィンドウ スタイルから WS_EX_APPWINDOW を外し、WS_EX_NOACTIVE を指定するので、結果的にはわざわざ ShowInTaskbar=”False” する必要はなかったのです。
というわけで XAML から。こういうウィンドウでこそ WindowStyle=”None” するべきですよね、ということで、AllowsTransparency=”True” Background=”Transparent” と指定して透明なウィンドウにしています。
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:metro="clr-namespace:VS2012LikeWindow2.Views.MetroChrome"
Title="GlowWindow" Width="300" Height="300"
WindowStyle="None" AllowsTransparency="True" Background="Transparent"
ShowActivated="False" ResizeMode="NoResize"
SnapsToDevicePixels="True">
x:FieldModifier="private"/>
次にコード ビハインド、の前に。
拡張ウィンドウ スタイルの取得と設定をするために、以下の関数を使用しています。
GetWindowLong function
SetWindowLong function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static class NativeMethods
{
[DllImport("user32.dll", EntryPoint = "GetWindowLongA", SetLastError = true)]
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
public static WSEX GetWindowLongEx(this IntPtr hWnd)
{
return (WSEX)NativeMethods.GetWindowLong(hWnd, (int)GWL.EXSTYLE);
}
[DllImport("user32.dll")]
public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
public static WSEX SetWindowLongEx(this IntPtr hWnd, WSEX dwNewLong)
{
return (WSEX)NativeMethods.SetWindowLong(hWnd, (int)GWL.EXSTYLE, (int)dwNewLong);
}
/* 以下省略 */
}
上記の関数を使用し、GlowWindow の拡張ウィンドウ スタイルを変えているのが以下のコード。
Window.SourceInitialized イベントは、Win32 との相互運用のためのイベントです。Initialized イベントの段階ではウィンドウ ハンドルを取得できませんが、SourceInitialized イベントの段階でならできます。まさに今回のような用途向け。
拡張ウィンドウ スタイルから WS_EX_APPWINDOW を外し、WS_EX_NOACTIVATE を追加しています。これで、タスク バーに表示されないウィンドウになります。
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var source = (HwndSource)HwndSource.FromVisual(this);
var wsex = source.Handle.GetWindowLongEx();
wsex ^= WSEX.APPWINDOW;
wsex |= WSEX.NOACTIVATE;
source.Handle.SetWindowLongEx(wsex);
source.AddHook(this.WndProc);
}
上記ソースの最後、HwndSource.AddHook メソッドの引数で指定されていた、WndProc メソッドの中身です。いわゆるウィンドウ プロシージャのようなもの。
非アクティブウィンドウ上でユーザーがマウス ボタンを押すと、WM_MOUSEACTIVATE メッセージが送られます。これを処理してあげないと、ウィンドウがアクティブになってしまいます。
WM_MOUSEACTIVATE message
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == (int)WM.MOUSEACTIVATE)
{
handled = true;
return new IntPtr(3);
}
return IntPtr.Zero;
}
これで、タスク バーに表示されず、ユーザーがクリックしてもアクティブにならないウィンドウができました。
アプリケーションのメイン ウィンドウの四辺に張り付き、移動とサイズ変更に追従する
レッツ座標計算。
サンプルでは、owner の上下左右それぞれで owner の位置とサイズを使用し GlowWindow の位置とサイズを計算する Func を作っています。計算式は割愛。
そして、計算した位置とサイズは、SetWindowPos 関数で適用します。
SetWindowPos function
static class NativeMethods
{
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, SWP flags);
/* 以下省略 */
}
この関数を使い、計算した位置とサイズ (getLeft() や this.getWidth() などの Func) を再適用するのが以下のコード。この処理を、owner の LocationChanged, SizeChanged, StateChanged イベントで呼んであげれば、その都度 GlowWindow が追従します (しかも、Z オーダーは owner のすぐ下になる…はず)。最後の引数の SWP.NOACTIVATE は、新しい位置とサイズを適用した後に、GlowWindow をアクティブにしないためのものです。
// コンストラクター
owner.LocationChanged += (sender, e) => this.Update();
owner.SizeChanged += (sender, e) => this.Update();
owner.StateChanged += (sender, e) => this.Update();
}
private void Update()
{
NativeMethods.SetWindowPos(
this.handle, this.ownerHandle,
(int)this.getLeft(), (int)this.getTop(), (int)this.getWidth(), (int)this.getHeight(),
SWP.NOACTIVATE);
}
// サンプルはもう少し処理が増えてますが、重要なのは上記のコードです。
アプリケーションのメインウィンドウのサイズを変更させる
これが一番厄介でした… まず、Visual Studio 2012 の動きから。
ご覧のように、Visual Studio のウィンドウの外にサイズ変更カーソルが出ています。そう、Visual Studio 本体でなく GlowWindow 上に出てるんですよね、これ。
なので、owner のサイズ変更幅はゼロ (前回の WindowChrome クラスで言うところの WindowChrome.ResizeBorderThickness=”0″) です。GlowWindow 上でのドラッグ操作で owner のサイズ変更をさせる、という実装にする必要があります。
いろいろ考えましたが、最終的に以下の実装になりました。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == (int)WM.LBUTTONDOWN)
{
var pt = new Point((int)lParam & 0xFFFF, ((int)lParam >> 16) & 0xFFFF);
NativeMethods.PostMessage(this.ownerHandle, (uint)WM.NCLBUTTONDOWN, (IntPtr)this.getHitTestValue(pt), IntPtr.Zero);
}
if (msg == (int)WM.NCHITTEST)
{
var ptScreen = new Point((int)lParam & 0xFFFF, ((int)lParam >> 16) & 0xFFFF);
var ptClient = this.PointFromScreen(ptScreen);
var cursor = this.getCursor(ptClient);
if (cursor != this.Cursor) this.Cursor = cursor;
}
return IntPtr.Zero;
}
まず、GlowWindow 上での LBUTTONDOWN メッセージ (左クリック)。
このメッセージを受けたら、PostMessage 関数で owner に NCLBUTTONDOWN メッセージ (非クライアント領域上の左クリック) を投げます (6 行目)。すると、owner は非クライアント領域 (ウィンドウの端) で左クリックされたと思い込み、ウィンドウのリサイズを開始してくれます。
PostMessage の 3 つめの引数では、ウィンドウのどの部分でクリックされたかを指定しています (ウィンドウ左端なら HTLEFT、左上端なら HTTOPLEFT、右下端なら HTBOTTOMRIGHT など。getHitTestValue はそれを算出する Func<Point, HitTestValues> です)。
8 行目以降の NCHITTEST メッセージに対する処理は、マウスカーソルを設定するためのものです。
マウス ポインターが GlowWindow のどの位置にいるかによって、どの方向のリサイズ カーソルを表示するのかを決定し、設定しています。
最終的にはこんな感じに。ちゃんとドラッグすればウィンドウ本体がリサイズされます。
で、最終的に
いくつかポイントを解説しましたが、完成したのが次のような感じ。
ボタンでアクセント カラーを変えられるようにしてみました。
まとめ
というわけで、Visual Studio 2012 のウィンドウかっこいいなー作りたいなーから始まったネタでしたが、蓋を開けてみれば Win32 相互運用だらけで誰も得をしないような謎記事になってしまいました。これはひどい。というか記事長すぎですね。反省。
完成したサンプル コードはこちら。”3. VS2012LikeWindow2″ が今回のプロジェクトです。それっぽい外観になっていると思います (.NET Framework 4.5 が必要です)。
https://github.com/Grabacr07/MetroRadiance
今回はあくまで例、似せてみたよ~というだけですので、本物の VisualStudioGlowWindow の動作には程遠い感じ。まともな Windows アプリケーションは .NET Framework でしか組んだことがないので、Win32 の知識不足も相まってこの有様です。もし「ここが違う」ですとか「もっとうまい方法がある」といった何かがありましたら、Twitter などでこっそり教えて頂けたら、それはとっても嬉しいなって。