2011年11月9日水曜日

wpf : UIElement.InputHitTestを試したら予想と違った動作

ペンタブレットでペイントソフトを作ったときの事を空想して、wpfのヒットテストを試してみました。 使ったのはUIElement.InputHitTestです。

wpfのマウスイベントは基本的にイベントハンドラを登録したコントロール上にカーソルがあるとき発行されます。 しかしwintabでペンタブレットの入力処理をする場合、イベントにはそういう制限がありません。 アプリケーションがアクティブならカーソルがどこにあってもイベント(メッセージ)が発行されます。 アプリケーションがアクティブでなくても、場合によってはイベントが発行されます。 イベントハンドラで自分でヒットテストをして、対象のコントロール上にカーソルがあるか、イベントを処理するべきかを判断しなければなりません。 今回はそのヒットテスト部分だけ試作しました。

サンプルコードでペンタブを使ってコーディングミスをし、座標がズレてしまったら確認の意味がないので、今回はとりあえずマウスのグローバルフックで試しました。 何かのマウスイベントがあるたびにフックの中でHitTestをして、UIElement.InputHitTestがどのコントロールを返すのか確認します。 これで目的のコントロールを検出できるか確認。

まずはMainWindow.xamlのコード。

<Window x:Class="HitTestTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525"
        SourceInitialized="OnSourceInitialized"
        Closed="OnClosed">
    <Border Margin="10">
        <Grid Name="clientArea" Background="LightBlue">
            <Label Name="label" Background="White" Height="100" Margin="10">未</Label>
        </Grid>
    </Border>
</Window>

ペイントソフトどうこう言っておきながらLabelをのせたのは手抜き&様子見です。 後で適当な画像ファイルを持ってきてImageで試そうと思ったんですが、Labelでの様子見のときに気になることが出てきてしまった。

マウスのグローバルフックはこんな感じ。

// MouseHook.cs
using System;
using System.Runtime.InteropServices;
using System.Diagnostics;

namespace HitTestTest
{
    public class MouseHook
    {
        private const int WH_MOUSE_LL = 14;

        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        public delegate IntPtr HookProcLL(int nCode, IntPtr wParam, ref MouseHookStructLL lParam);
        private static HookProcLL _hookProcLLHolder = MouseHookProc;

        public delegate void HookProc(int x, int y);
        private static HookProc _hookProcHolder;

        private static IntPtr _hHook = IntPtr.Zero;

        [StructLayout(LayoutKind.Sequential)]
        public class MousePoint
        {
            public int x;
            public int y;
        }

        [StructLayout(LayoutKind.Sequential)]
        public struct MouseHookStructLL
        {
            public MousePoint pt;
            public int mouseData;
            public int flags;
            public int time;
            public IntPtr dwExtraInfo;
        }

        [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
        private static extern IntPtr SetWindowsHookEx(int idHook, HookProcLL lpfn, IntPtr hInstance, int threadId);

        [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
        private static extern bool UnhookWindowsHookEx(IntPtr hHook);

        [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
        private static extern IntPtr CallNextHookEx(IntPtr hHook, int nCode, IntPtr wParam, ref MouseHook.MouseHookStructLL lParam);

        [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern IntPtr GetModuleHandle(string lpModuleName);

        public static void Hook(HookProc proc)
        {
            _hookProcHolder = proc;

            using (Process curProcess = Process.GetCurrentProcess())
            using (ProcessModule curModule = curProcess.MainModule)
            {
                _hHook = SetWindowsHookEx(WH_MOUSE_LL, _hookProcLLHolder,
                    GetModuleHandle(curModule.ModuleName), 0);
            }

            if (_hHook == IntPtr.Zero)
                throw new Exception();
        }

        public static void UnHook()
        {
            try
            {
                if (_hHook != IntPtr.Zero)
                {
                    UnhookWindowsHookEx(_hHook);
                }
            }
            catch { }

            _hHook = IntPtr.Zero;
        }

        private static IntPtr MouseHookProc(int nCode, IntPtr wParam, ref MouseHook.MouseHookStructLL lParam)
        {
            if (nCode < 0)
                return CallNextHookEx(_hHook, nCode, wParam, ref lParam);

            _hookProcHolder(lParam.pt.x, lParam.pt.y);

            return CallNextHookEx(_hHook, nCode, wParam, ref lParam);
        }
    }
}

グローバルフックについては色んなページにサンプルコードが落ちてるので詳細は割愛で。 最初は、static変数の_hookProcLLHolderを忘れてちょっとだけ躓きました。 static変数にとっておかないと、GCがデリゲートを回収してしまい例外が発生します。 これは注意で。

最後にMainWindow.xaml.csです。

// MainWindow.xaml.cs
using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;

namespace HitTestTest
{
    public partial class MainWindow : Window
    {
        private UIElement _hitTestTarget;

        public MainWindow()
        {
            InitializeComponent();

            _hitTestTarget = this;
            //hitTestTarget = clientArea;
        }

        private void OnSourceInitialized(object sender, EventArgs e)
        {
            try
            {
                MouseHook.Hook(MouseHookProc);
            }
            catch
            {
                MessageBox.Show(this, "マウスフック失敗");
                Close();
            }
        }

        private void OnClosed(object sender, EventArgs e)
        {
            MouseHook.UnHook();
        }

        private void HitTest(Point pt)
        {
            DependencyObject elem = _hitTestTarget.InputHitTest(pt) as DependencyObject;

            string p = string.Format("({0:f2},{1:f2})", pt.X, pt.Y);

            label.Content = "null : " + p;

            while (elem != null)
            {
                if (elem == label)
                {
                    label.Content = "label : " + p;
                    break;
                }
                else if (elem == clientArea)
                {
                    label.Content = "clientArea : " + p;
                    break;
                }
                else
                {
                    label.Content = "[" + elem.GetType().FullName + "] : " + p;
                }
                elem = LogicalTreeHelper.GetParent(elem);
            }
        }

        public void MouseHookProc(int x, int y)
        {
            Point screenPoint = new Point(x, y);
            Point clientPoint = _hitTestTarget.PointFromScreen(screenPoint);

            HitTest(clientPoint);
        }
    }
}

マウスイベントが発行されるたびにHitTestをしてラベルに表示するだけのコードです。 _hitTestTargetに検出したいコントロールをセットしてコンパイル→実行すると、InputHitTestが返す値を確認できます。 対象コントロールの外側だとnullが、対象コントロール内だとそれ以外の値が返るようです。 とりあえずnullかどうかをチェックすれば良さそうですね。

予想外だったのは次の2点。

  1. Labelのインスタンスが検出できない。
  2. 検出対象のコントロールが他のウィンドウの下にあっても前面のときと同じ値が返る。

Labelのインスタンスが検出できない点について、Label上にマウスカーソルを持っていくとBorderとTextBlockが検出されました。 BorderとTextBlockは実際にLabelを構成している部品でしょう。 InputHitTestを使う場合、xamlで書いたコントロールがすんなり出てくるとは限らないようですね。 VisualツリーにはLabelが登録されていても、実際に表示されるのは部品のBorderとTextBlockなので「その座標にどのコントロールがあるか?」を調べるInputHitTestでは直接Labelを取り出せないようです。

ということで、

elem = _hitTestTarget.InputHitTest(pt) as DependencyObject;
if (elem == label)
{
 ...
}

のコードではlabelを検出できません。 VisualTreeHelperを使って、

elem = _hitTestTarget.InputHitTest(pt) as DependencyObject;
if (VisualTreeHelper.GetParent(elem) == label)
{
 ...
}

のように書いたらLabel内のBorderにあたるところにマウスカーソルを合わせたらlabelが検出できました。

どのコントロールがInputHitTestですんなり出てくるか確認してから使わないとハマりそうですね。 また、wpfはテンプレートでコントロールの変更ができます。 試してませんが、もしかしたらテンプレートを使うとInputHitTestが返すコントロールが変わるかもしれません。 UIデザインの詳細が確定していないときや他人が作ったカスタムコントロールを使うときは注意。 特定のコントロールを探すには

while (elem != null)
{
    if (elem == label)
    {
        ...発見時の処理
        break;
    }
    elem = VisualTreeHelper.GetParent(elem);
}

のようにするのがいいのかな?

検出対象のコントロールが他のウィンドウの下にあっても前面のときと同じ値が返るのは困りました。 ペイントソフトで使うことを考えると、ユーザーから見えない部分に描き込むのはNGなので、他のウィンドウの下にある場合は弾かなければなりません。 そのままでは使えなさそうです。 ユーザーから見える部分だけを検出する方法を探さなければ。

ちょっと考えて浮かんだのは次の3点。

  1. VisualTreeHelper.HitTestを使う。
  2. レンダリング領域やクリッピング領域のようなものが得られるメソッド/プロパティを探す。
  3. win32sdkのWindowFromPointを使ってカーソルが対象ウィンドウ上にあるか確かめる。

VisualTreeHelper.HitTestを使うのはちょっとの書き換えで済むので簡単ですね。 今度、最初に試して見ましょう。 でも内部はInputHitTestと同じだったりして?

クリッピング領域っぽいメソッド/プロパティを探してみたらUIElement.VisualClip、UIElement.GetLayoutClip、VisualTreeHelper.GetClipが見つかりました。 レンダリング領域っぽいメソッド/プロパティは未発見です。 検索で探す予定。 利用可能なクリッピング領域が見つかれば、Geometry.FillContainsでOK...のはず。

win32sdkのWindowFromPointを使うのは、wpfのやり方としてはあまりお行儀が良くなさそうだけど、実際に目的を達成できそうな気配が1番濃厚です。 wpfはWin32コンテンツなどをホストしないかぎりウィンドウのみHWNDを持っていて、子コントロールにはHWNDがないはず。 javaの軽量コンポーネントみたいなイメージでしょうか? WindowFromPointで持ってきたHWNDとMainWindowのHWNDを比べればMainWindowの表示領域か判断できます。

MainWindowか他のウィンドウかのチェックについては、こんな感じかな? 今度気が向いたらテストプログラムを修正してみましょう。 Labelのインスタンスについては今のところ関係ないので、頭の端に入れとく程度で。

...

※) アクティブではないアプリケーションにwintabのメッセージが送られるケースについて、以前チラッと触れました。 ↓の投稿に書いてます。 詳細の確認は取ってないですが、一応。