2011年8月23日火曜日

wpf : 論理ピクセル座標ではなくデバイス座標を使う

wpfは色々なデバイスでできるだけレイアウトが崩れないように論理ピクセル座標が標準になっています。 デバイス座標を使うには論理ピクセル座標 ⇔ デバイス座標の変換が必要になります。

dpiを使って変換するコードは検索するといくつか見つかります。 基準をdpi=96と決めうちしているものが多いようです。 それがいつまでも通用するという保障はありません。 変換はwpfに任せたほうがよいでしょう。 (wpf内部でも固定値を使ってそうですが、それでも自前でコードをチビチビ書くよりマシかと。)

試した環境は、

  • Windows7 Home Premium 64bit
  • Visual C# 2010 Express (x86プラットフォーム)

変換のコードはこんな感じです。

using System;
using System.Windows;
using System.Windows.Media;

namespace LocalUtility
{
    // 注) メソッドを使用できるのはSourceInitialized以降
    class PixelScaleTransformer
    {
        private Visual _root;

        public PixelScaleTransformer(Visual root)
        {
            _root = root;
        }

        public Point DeviceToWpf(Point devicePt)
        {
            CompositionTarget ct = GetCompositionTarget();
            return ct.TransformFromDevice.Transform(devicePt);
        }

        public Point WpfToDevice(Point wpfPt)
        {
            CompositionTarget ct = GetCompositionTarget();
            return ct.TransformToDevice.Transform(wpfPt);
        }

        // X軸方向とY軸方向でThicknessが違う場合、細いほうを返す
        public double DeviceThicknessToWpfThickness(double deviceThickness)
        {
            CompositionTarget ct = GetCompositionTarget();
            Point devicePt = new Point(deviceThickness, deviceThickness);
            Point t = ct.TransformFromDevice.Transform(devicePt);
            return t.X <= t.Y ? t.X : t.Y;
        }

        // DeviceThicknessToWpfThicknessと同じ
        public double WpfThicknessToDeviceThickness(double wpfThickness)
        {
            CompositionTarget ct = GetCompositionTarget();
            Point wpfPt = new Point(wpfThickness, wpfThickness);
            Point t = ct.TransformToDevice.Transform(wpfPt);
            return t.X <= t.Y ? t.X : t.Y;
        }

        // Deviceのピクセルの横縦比
        public double GetXYratio()
        {
            Point wpfPt = DeviceToWpf(new Point(1.0, 1.0));
            return wpfPt.X / wpfPt.Y;
        }

        private CompositionTarget GetCompositionTarget()
        {
            PresentationSource source = PresentationSource.FromVisual(_root);
            if (source == null)
                throw new Exception("PixelScaleTransformer.GetCompositionTarget : PresentationSourceの取得失敗");

            CompositionTarget ct = source.CompositionTarget;
            if (ct == null)
                throw new Exception("PixelScaleTransformer.GetCompositionTarget : CompositionTargetの取得失敗");

            return ct;
        }
    }
}

前の投稿から変換部分を持ってきただけですな。 コンストラクタにはWindow(Visual)を渡します。 あとは、DeviceToWpfかWpfToDeviceで座標変換ができます。 変換できるのはWindowのSourceInitializedイベント以降なので注意してください。

メソッド名にThicknessとあるのは図形のアウトラインの太さを変換するときに使います。 普通はデバイスのピクセルの形は正方形なのでXかYのどちらかを調べればいいはず。 しかし、「ピクセルが長方形の場合は?」というのを考えて小さい方の値を返すようにしてみました。 実際に長方形のピクセルを持つデバイス向けのコードを書く場合はこの程度では済まないでしょうけど、全く考慮しないよりはマシっていうことで、ひとつ。

一応、Deviceのピクセルの横縦比はGetXYratioメソッドで調べられます。 ただし、これが正常に動くのは論理ピクセルがちゃんと正方形になってくれるときだけです。

この座標変換クラスを使ってRectangleを表示するサンプルプログラムを作ってみました。 xamlのコードは、

<Window x:Class="PixelScaleTransformerTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="512" Width="512"
        SourceInitialized="OnSourceInitialized">
    <StackPanel>
        <CheckBox Click="OnCheckBoxClick" IsChecked="True">SnapsToDevicePixels</CheckBox>
        <StackPanel Orientation="Horizontal">
            <Button Click="OnWpfTransformButtonClick">wpfピクセルで指定</Button>
            <Label>位置(</Label>
            <TextBox Name="tbWpfX" MinWidth="40" TextAlignment="Right"/>
            <Label>,</Label>
            <TextBox Name="tbWpfY" MinWidth="40" TextAlignment="Right"/>
            <Label>)  サイズ</Label>
            <TextBox Name="tbWpfWidth" MinWidth="40" TextAlignment="Right"/>
            <Label>×</Label>
            <TextBox Name="tbWpfHeight" MinWidth="40" TextAlignment="Right"/>
            <Label>太さ</Label>
            <TextBox Name="tbWpfThickness" MinWidth="20" TextAlignment="Right"/>
        </StackPanel>
        <StackPanel Orientation="Horizontal">
            <Button Click="OnDeviceTransformButtonClick">deviceピクセルで指定</Button>
            <Label>位置(</Label>
            <TextBox Name="tbDeviceX" MinWidth="40" TextAlignment="Right"/>
            <Label>,</Label>
            <TextBox Name="tbDeviceY" MinWidth="40" TextAlignment="Right"/>
            <Label>)  サイズ</Label>
            <TextBox Name="tbDeviceWidth" MinWidth="40" TextAlignment="Right"/>
            <Label>×</Label>
            <TextBox Name="tbDeviceHeight" MinWidth="40" TextAlignment="Right"/>
            <Label>太さ</Label>
            <TextBox Name="tbDeviceThickness" MinWidth="20" TextAlignment="Right"/>
        </StackPanel>
        <Canvas>
            <Rectangle
                    Name="sampleRect"
                    Canvas.Left="10"
                    Canvas.Top="10"
                    Width="100"
                    Height="100"
                    Stroke="Blue"
                    StrokeThickness="1.0"
                    SnapsToDevicePixels="True"/>
        </Canvas>
    </StackPanel>
</Window>

xaml.csのコードは、

using System.Windows;
using System.Windows.Controls;
using LocalUtility;

namespace PixelScaleTransformerTest
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        private PixelScaleTransformer _trns;

        public MainWindow()
        {
            InitializeComponent();

            _trns = new PixelScaleTransformer(this);
        }

        private void OnWpfTransformButtonClick(object sender, RoutedEventArgs e)
        {
            double x, y, width, height, thickness;
            try
            {
                x = double.Parse(tbWpfX.Text);
                y = double.Parse(tbWpfY.Text);
                width = double.Parse(tbWpfWidth.Text);
                height = double.Parse(tbWpfHeight.Text);
                thickness = double.Parse(tbWpfThickness.Text);
            }
            catch
            {
                MessageBox.Show(this, "実数を入力してください", "入力値エラー", MessageBoxButton.OK, MessageBoxImage.Error);
                return;
            }

            ResizeRect(x, y, width, height, thickness);

            Point pos = new Point(x, y);
            pos = _trns.WpfToDevice(pos);
            tbDeviceX.Text = pos.X.ToString("F2");
            tbDeviceY.Text = pos.Y.ToString("F2");

            Point size = new Point(width, height);
            size = _trns.WpfToDevice(size);
            tbDeviceWidth.Text = size.X.ToString("F2");
            tbDeviceHeight.Text = size.Y.ToString("F2");

            tbDeviceThickness.Text = _trns.WpfThicknessToDeviceThickness(thickness).ToString("F2");
        }

        private void OnDeviceTransformButtonClick(object sender, RoutedEventArgs e)
        {
            double x, y, width, height, thickness;
            try
            {
                x = double.Parse(tbDeviceX.Text);
                y = double.Parse(tbDeviceY.Text);
                width = double.Parse(tbDeviceWidth.Text);
                height = double.Parse(tbDeviceHeight.Text);
                thickness = double.Parse(tbDeviceThickness.Text);
            }
            catch
            {
                MessageBox.Show(this, "実数を入力してください", "入力値エラー", MessageBoxButton.OK, MessageBoxImage.Error);
                return;
            }

            Point pos = new Point(x, y);
            pos = _trns.DeviceToWpf(pos);
            x = pos.X;
            y = pos.Y;

            Point size = new Point(width, height);
            size = _trns.DeviceToWpf(size);
            width = size.X;
            height = size.Y;

            thickness = _trns.DeviceThicknessToWpfThickness(thickness);

            ResizeRect(x, y, width, height, thickness);

            tbWpfX.Text = x.ToString("F2");
            tbWpfY.Text = y.ToString("F2");
            tbWpfWidth.Text = width.ToString("F2");
            tbWpfHeight.Text = height.ToString("F2");
            tbWpfThickness.Text = thickness.ToString("F2");
        }

        private void ResizeRect(double x, double y, double width, double height, double thickness)
        {
            sampleRect.SetValue(Canvas.LeftProperty, x);
            sampleRect.SetValue(Canvas.TopProperty, y);
            sampleRect.Width = width;
            sampleRect.Height = height;
            sampleRect.StrokeThickness = thickness;
        }

        private void OnSourceInitialized(object sender, System.EventArgs e)
        {
            this.Title = "X/Y ratio = " + _trns.GetXYratio();
        }

        private void OnCheckBoxClick(object sender, RoutedEventArgs e)
        {
            CheckBox senderCb = (CheckBox)sender;
            sampleRect.SnapsToDevicePixels = (bool)senderCb.IsChecked;
            sampleRect.InvalidateVisual();
        }
    }
}

下の方にある青い四角形の位置、サイズ、線の太さをwpfの論理ピクセル座標またはデバイス座標で指定できます。 「wpf/deviceピクセルで指定」のボタンの横にある項目を埋めてからボタンを押せば四角形が移動します。 入力値は論理、デバイスともにdouble値です。 サンプルコードということで入力値のチェックはしていません。 SnapsToDevicePixelsのチェックボックスをチェックするとピクセルスナップの有効/無効を切り替えられます。

サンプルを動かす前は「デバイスの方の値を整数で入力したらSnapsToDevicePixels=trueなしでもアンチエイリアスなしのカッキリとした表示になるんじゃないか?」と思ってました。 しかし、そうはなりませんでしたね。 アンチエイリアスがかかってぼやけてしまいます。 整数値を入れてSnapsToDevicePixels=trueを設定すれば思ったとおりの表示になりました。

ディスプレイの設定でdpiを変えて3パターンほど試しました。 100~150のあいだの中途半端なdpiでの2パターンをチェック、いつも使っているdpi=150でもチェックです。 その3パターンではOK。 厳密にテスト項目をあげてチェックしたわけではないのといろんな環境で試したわけではないので汎用的かどうかは断言できないですが、多分他のシェイプやコントロールでもRectangleと同じ様に使えるんじゃないですかね?

wpfのコントロールやシェイプは基本的にプロパティに論理ピクセル座標を指定して使います。 そのためデバイス座標を使うときは、開発コード上のデバイス座標 → wpfプロパティの論理ピクセル座標 → レンダリング時のデバイス座標と変換されるのを忘れてはなりません。 このときの処理について気になることが2つあります。

まず1つ目は変換のために処理が重くなるということです。 私が気にしているのは単純にレイアウト計算に時間がかかるということではありません。 デバイス座標を使いたいというからには当然ピクセルスナップも使うことになるでしょう。 この処理がどれだけ重いか分からないです。

もう1つは座標変換が環境依存になるかもしれないということ。 現行のPCで動かす分には、すぐに環境による違いが出るということはなさそうな気がします。 しかし、次世代のOSで動かす場合とか、「新しい携帯端末でもwpfが動かせるらしい。移植しよう。」みたいになったときに1ドットずれることはありえます。

こういうのがあるので、「デバイス座標はホントに必要か?」というのはよく考えて使うべきですね。 使うときは「あとあと注意しなくてはならないかもしれない項目」として扱いましょう。 「レイアウトのためだけに」っていうのはやはりやってはならないでしょう。

最後にPixelScaleTransformerクラスのコーディングのすみっこについて。

SourceInitializedイベントの後しか使えないっていうのはちょっとだけ残念ですよね? 変換メソッドをいつでも使えるようにするにはどうしたらよいか考えてみました。

PixelScaleTransformerクラスではPresentationSourceを使っています。 PresentationSourceを継承したHwndSourceというクラスもあります。 PresentationSourceにしてもHwndSourceにしても、OSにWindowが登録されてhwndが発行されるまでまともに使うことができません。 じゃあ「元からあるhwndを使えばいいんじゃないか?」と考えました。 デスクトップのhwndは元からあるはずです。 つまり、

  1. win32apiのGetDesktopWindow関数でデスクトップのhwndを得る。
  2. デスクトップのhwndからHwndSourceを作成。
  3. そのHwndSourceを使えばいつでも変換可能?

という考え方です。 考え方としてはありえそうですよね? でもコレって、マルチモニタのときはどうなるんでしょう? マルチモニタ用のコードを書く手間とSourceInitializedイベントを待つのと、どっちがいいかと考えると...

ということで、想像するだけで実際に試すのは止めました。 興味がある方はぜひ試してみてください。