WinUI 3 Canvasの座標ズレを解消する方法|GuidedProcessAsyncのポイント一覧が一致しない原因と対策

WinUI 3 の Canvas で点を描くアプリでは、ガイドモード(ContentDialog)で座標を選ばせた瞬間に「一覧の座標とグリッドが一致しない」「最後のポイントが変」「一部しか表示されない」といった違和感が起きがちです。原因を整理し、グリッド表示と座標ロジックを分離して確実に直す方法を解説します。

目次

起きている症状を「見える問題」と「中身の問題」に分ける

まずは現象を整理します。今回のケースは、Canvas 上でマウス操作で描いた点(ピクセル座標)は正しく表示できているのに、ガイドモード(GuidedProcessAsync)で座標を選択させる UI を出した途端、表示が合わなくなるのが特徴です。

症状ユーザーが感じる違和感典型的な原因
ダイアログの座標と Canvas のグリッド座標が一致しない「同じ点を指しているはずなのに数値がズレて見える」ピクセル座標とグリッド番号(マス番号)を混在させている
座標リストが途中までしか出ない「最後のポイントが無い」「一部しか選べない」ContentDialog 内がスクロールできず、表示領域に収まっていない
切り替え後に謎の線や点が残る「端っこに前の描画が残る」Canvas を作り直すたびに古い Canvas を残している/要素の削除ができていない

ここから先は、上の3つをそれぞれ「なぜ起きるのか」と「どう直すと再発しにくいか」の観点で掘り下げます。

ズレの正体は「ピクセル座標」と「グリッド番号」の混在

WinUI の Canvas に描画する位置は基本的にピクセル座標です。たとえばグリッド間隔が 20px なら、交点は (0, 0)、(20, 0)、(40, 0)…のように 20px 刻みの値になります。

一方、グリッドのラベルやツールチップでよく表示したくなるのは「マス番号(グリッド番号)」です。(20, 0) は「(1,0) のマス」という意味なので、表示上は 1,0 と出したくなります。ここが混ざると、「実体は 20px なのに表示は 1」という食い違いが生まれます。

意味例扱う場所よくある表示
ピクセル座標(Canvas の実座標)(20, 260)Canvas.SetLeft/SetTop、線の X1/Y1 など20,260
グリッド番号(マス番号)(1, 13)ユーザーに見せるラベル、選択 UI1,13

今回の「ダイアログの座標が 1,260 のように見える」系の違和感は、Points コレクションにピクセル値を入れているのに、UI 表示がグリッド番号前提で作られていることが引き金になりやすいです。

原因が起きやすい実装パターン

グリッド描画のついでに Points を作ってしまう

たとえば次のように、交点ラベルを追加しながら Points にも追加していると、ガイドモードの座標候補が「グリッド表示処理」に引きずられます。

// 交点ごとにラベルを追加しつつ、Points にも追加している例
for (int i = 0; i < canvas.Width; i += gridSpacing)
{
    for (int j = 0; j < canvas.Height; j += gridSpacing)
    {
        AddGridLabel(canvas, i, j, gridSpacing);
        Points.Add(new PointModel(i, j)); // ← 実体はピクセル座標
    }
}

この形だと、次の3つが同時に起きやすくなります。

  • Points が「ピクセル座標」で溜まる(例:0,20,40…)
  • ラベルは「グリッド番号」で表示する(例:0,1,2…)
  • ガイドモードでも AddGridLines を呼びたくなり、責務が混ざる

ContentDialog にチェックボックスを並べただけで満足してしまう

Canvas が 378px 四方、グリッド間隔が 20px の場合、交点候補はおおむね 19×19=361 個になります。361 個の CheckBox を縦に並べると、スクロールが無い限り途中までしか見えません。

結果として「最後のポイントが変」「最後まで見えていないだけなのに座標がおかしい」と誤認しやすくなります。

トグルで表示したいグリッドと、ガイド用候補点生成が一体化している

要件として「グリッド線や補助線は ToggleGridVisibility を押した時だけ表示したい」「ガイドモードはグリッド無しでも座標候補を扱いたい」と考えている場合、グリッド表示と候補点生成の分離が必須になります。

解決の基本方針:座標の“単位”を揃え、責務を分ける

直し方は複雑そうに見えますが、方針はシンプルです。

  • Canvas に描くのはピクセル座標(描画のための数値)
  • ユーザーに見せるのはグリッド番号(理解しやすい数値)
  • グリッド線の表示(見た目)と、候補点の生成(データ)を分離する
機能担当メソッド例やることやらないこと
グリッド線を表示するAddGridLines / RemoveGridLinesLine や TextBlock を Canvas に追加・削除Points(選択結果)を変更しない
候補点を列挙するEnumerateCandidatePointsgridSpacing ごとに座標候補を生成Canvas の見た目を変えない
ガイドモードで選ばせるGuidedProcessAsync候補点を UI に出し、選択結果を Points に反映AddGridLines を呼ばない(依存させない)

対応策:ダイアログで見せる座標は「グリッド番号」に統一する

Points にはピクセル座標を保持していても構いません。重要なのは、UI 表示では gridSpacing で割ってグリッド番号を見せることです。これだけで「Canvas のラベルと一致しない」問題は体感的に大きく減ります。

// gridSpacing をマジックナンバーにしない(フィールド/定数へ)
private const int GridSpacing = 20;

foreach (var point in Points)
{
    var checkBox = new CheckBox
    {
        // 表示はグリッド番号に揃える
        Content = $"Point ({point.X / GridSpacing},{point.Y / GridSpacing})",
        IsChecked = false,
        Tag = point
    };

    panel.Children.Add(checkBox);
}

さらに、デバッグしやすくするなら「グリッド番号+ピクセル座標」を両方出すのもおすすめです。ユーザーにはグリッド番号が分かりやすく、開発者はピクセル値を追えます。

Content = $"({point.X / GridSpacing},{point.Y / GridSpacing})  [px:{point.X},{point.Y}]";

対応策:ContentDialog ではスクロールできる構造にする

チェックボックスの数が多い場合、単純に StackPanel を ContentDialog に突っ込むだけでは表示が破綻します。最小の修正で改善するなら ScrollViewer を挟むのが分かりやすいです。

ContentDialog dlg = new()
{
    XamlRoot = _rootContainer!.XamlRoot,
    Title = "Select point to draw",
    CloseButtonText = "Ok"
};

ScrollViewer scrollViewer = new()
{
    VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
    // 必要に応じて高さを決める(画面に合わせる)
    Height = 600
};

StackPanel panel = new()
{
    Orientation = Orientation.Vertical
};

// ここでポイントごとに CheckBox を panel に追加
scrollViewer.Content = panel;
dlg.Content = scrollViewer;

await dlg.ShowAsync();

もう少し UI として整えたい場合は、後述する GridView / ListView の方が「一覧らしさ」を出しやすいです(検索ボックスを付ける、折り返し表示など)。

ScrollViewer と GridView の使い分け

方式向いているケースメリット注意点
ScrollViewer + StackPanelとりあえず全件見えるようにしたい実装が最小、学習コストが低い項目が多いと UI 要素が増える(361 件なら許容範囲)
GridViewグリッド状に並べたい、見た目を整えたい一覧 UI と相性が良く、折り返しやすい選択順の表現や並び替えを考える必要が出る
ListView縦リストで素直に並べたいスクロール、選択、バインドが扱いやすい表示が長くなりやすい(361件は縦に長い)

対応策:ガイドモードでは AddGridLines を呼ばず、候補点だけ生成する

「グリッド線表示」と「ガイド用候補点生成」が絡むと、要件が変わった時に壊れやすくなります。ここは割り切って、ガイドモードではグリッド線の描画に一切触れない形にします。

ポイントは次の通りです。

  • GuidedProcessAsync の中では AddGridLines を呼ばない
  • 候補点は gridSpacing ごとにループして生成する(データ生成)
  • チェックの ON/OFF で Points コレクションに追加・削除する(選択結果)
  • ダイアログが閉じたタイミングで Points を使って Canvas に描画する(描画)
private const int GridSpacing = 20;

// 候補点の列挙:グリッド線とは別
private IEnumerable<PointModel> EnumerateCandidatePoints()
{
    // Width/Height が double の場合に備えて floor する
    int maxXIndex = (int)Math.Floor(_canvas.Width / GridSpacing);
    int maxYIndex = (int)Math.Floor(_canvas.Height / GridSpacing);

    for (int gx = 0; gx <= maxXIndex; gx++)
    {
        for (int gy = 0; gy <= maxYIndex; gy++)
        {
            yield return new PointModel(gx * GridSpacing, gy * GridSpacing);
        }
    }
}
// ガイドモード
[RelayCommand]
async Task GuidedProcessAsync()
{
    Points.Clear();
    DrawCanvas(); // 新しい Canvas を用意(後述の「古い Canvas を削除」も適用)

    ContentDialog dlg = new()
    {
        XamlRoot = _rootContainer!.XamlRoot,
        Title = "Select point to draw",
        CloseButtonText = "Ok"
    };
    dlg.Closed += ContentDialog_Closed;

    ScrollViewer scrollViewer = new()
    {
        VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
        Height = 600
    };

    StackPanel panel = new()
    {
        Orientation = Orientation.Vertical
    };

    foreach (var point in EnumerateCandidatePoints())
    {
        var checkBox = new CheckBox
        {
            Content = $"Point ({point.X / GridSpacing},{point.Y / GridSpacing})",
            IsChecked = false,
            Tag = point
        };

        checkBox.Checked += guidCheckBox_Checked;
        checkBox.Unchecked += guidCheckBox_UnChecked;

        panel.Children.Add(checkBox);
    }

    scrollViewer.Content = panel;
    dlg.Content = scrollViewer;
    await dlg.ShowAsync();
}

void guidCheckBox_Checked(object sender, RoutedEventArgs e)
{
    if ((sender as CheckBox)?.Tag is PointModel point)
    {
        // 重複追加を避けたいなら、Equals 実装や HashSet を検討
        Points.Add(point);
    }
}

void guidCheckBox_UnChecked(object sender, RoutedEventArgs e)
{
    if ((sender as CheckBox)?.Tag is PointModel point)
    {
        Points.Remove(point);
    }
}

private void ContentDialog_Closed(ContentDialog sender, ContentDialogClosedEventArgs args)
{
    // 選択されたポイントを Canvas に描画
    foreach (var point in Points)
    {
        var ellipse = new Ellipse
        {
            Width = 4,
            Height = 4,
            Fill = new SolidColorBrush(Colors.Red)
        };

        Canvas.SetLeft(ellipse, point.X - 2);
        Canvas.SetTop(ellipse, point.Y - 2);
        _canvas!.Children.Add(ellipse);
    }
}

この形にしておくと、グリッド線は ToggleGridVisibility の時だけ表示し、ガイドモードは「候補点を生成して選択するだけ」にできます。要件が分離されるため、後から UI を変えても壊れにくくなります。

対応策:DrawCanvas は毎回作り直し、古い Canvas を削除する

ガイドモードと通常描画を切り替えるたびに新しい Canvas を Grid に追加していると、古い Canvas が裏に残って「端っこに謎の線」や「前の点が残る」といった現象が出ます。Canvas は 1 枚だけを保つようにし、作り直すときは必ず古いものを削除します。

private void DrawCanvas()
{
    _canvas = new Canvas
    {
        Background = new SolidColorBrush(Color.FromArgb(255, 0, 128, 0)),
        Width = 378,
        Height = 378,
    };

    // 既存 Canvas を削除(複数ある可能性も考えて全削除でもOK)
    var oldCanvas = _rootContainer!.Children.OfType<Canvas>().FirstOrDefault();
    if (oldCanvas != null)
    {
        _rootContainer.Children.Remove(oldCanvas);
    }

    _rootContainer.Children.Add(_canvas);
    Grid.SetRow(_canvas, 1);
}

もし Grid の中に Canvas が複数入る可能性がある設計なら、FirstOrDefault ではなく「Tag で識別して削除」「すべての Canvas を削除」など、自分の構成に合わせて確実に消えるように調整してください。

応用:GridView で“見やすい”一覧にする

「スクロール付きの一覧」を作りたいだけなら GridView でも十分です。GridView は内部で ScrollViewer を持つため、基本的にスクロールは自動で効きます。見た目を整えたい場合はこちらが便利です。

ContentDialog dlg = new()
{
    XamlRoot = _rootContainer!.XamlRoot,
    Title = "Select point to draw",
    CloseButtonText = "Ok"
};

GridView gridView = new GridView
{
    Width = 420,
    Height = 600
};

foreach (var point in EnumerateCandidatePoints())
{
    var checkBox = new CheckBox
    {
        Content = $"({point.X / GridSpacing},{point.Y / GridSpacing})",
        IsChecked = false,
        Tag = point
    };

    checkBox.Checked += guidCheckBox_Checked;
    checkBox.Unchecked += guidCheckBox_UnChecked;

    gridView.Items.Add(checkBox);
}

dlg.Content = gridView;
await dlg.ShowAsync();

UI の密度を上げたい場合は、CheckBox ではなく ToggleButton や CheckBox 風のカスタムテンプレートにすることで、クリックしやすく・見やすくできます。ただし、まずは「座標が一致する」「全件見える」状態を優先し、その後に UI 改善する方が結果的に早いです。

最後のポイントが「おかしく見える」ときのチェック項目

Canvas サイズ 378px と gridSpacing 20px の組み合わせは、割り切れません(378 ÷ 20 = 18.9)。そのため、交点として生成される最大値は 360px 側に寄ります。これはバグではなく仕様に近い挙動です。

状況起きること対処アイデア
Canvas サイズが gridSpacing の倍数ではない最外周に「余り」ができ、最後の交点が端に来ないCanvas サイズを 380 など倍数にする/余りは枠線として別描画にする
for ループを < で回している境界ギリギリの点は生成されない(安全側)意図的に <= にするか、最大 index を計算して生成する
Points を AddGridLines のたびに追加しているトグルやモード切替で Points が重複し、一覧が増殖する候補点は別で生成/Points は選択結果のみ/必要なら重複排除

「端まで交点が欲しい」という要件なら、2つの選択肢があります。

  1. Canvas のサイズを gridSpacing の倍数に揃える(例:380×380、400×400)
  2. 交点は 20px 刻みで生成し、外枠だけは別の線(Frame)として描画する

実装の分かりやすさ・保守性を考えると、前者(サイズを揃える)が最もトラブルが少ないです。どうしても 378 に固定する理由がある場合は、後者の「枠線だけ別扱い」が安定します。

再発防止:Points の重複・削除ミスを潰す

ガイドモードを何度も実行したり、グリッド表示をトグルする設計だと、Points に同じ座標が複数回入るケースが出てきます。描画結果が濃く見えたり、一覧が“増えているように見える”原因になります。

対策は次のいずれかです。

  • 候補点(全 361 点)と、選択点(ユーザーが選んだ点)を完全に別コレクションにする
  • PointModel を record 化するなどして等価比較を定義し、重複を防ぐ
  • 内部は HashSet<PointModel> で保持し、表示用だけ List/ObservableCollection を使う

たとえば record を使うと、値が同じなら同一扱いにでき、重複排除が簡単になります。

public readonly record struct PointModel(int X, int Y);

この形なら、Points.Contains(point) が値比較になり、追加前チェックが素直に書けます。

if (!Points.Contains(point))
{
    Points.Add(point);
}

グリッド表示をトグルで出し入れする時の設計

ToggleGridVisibility でグリッド線を出したい場合は、「グリッド要素だけを消せる」設計にしておくと安全です。追加した Line や TextBlock に Tag を付けておくと、削除が簡単になります。

private const string GridVisualTag = "GridVisual";

private void AddGridLines(Canvas canvas)
{
    // 例:縦線
    for (int x = 0; x <= canvas.Width; x += GridSpacing)
    {
        var line = new Line
        {
            X1 = x, Y1 = 0,
            X2 = x, Y2 = canvas.Height,
            Stroke = new SolidColorBrush(Colors.Gray),
            StrokeThickness = 1,
            Tag = GridVisualTag
        };
        canvas.Children.Add(line);
    }

    // 横線も同様に追加し、ラベルも Tag を付ける
}

private void RemoveGridLines(Canvas canvas)
{
    var targets = canvas.Children
        .OfType<FrameworkElement>()
        .Where(e => (e.Tag as string) == GridVisualTag)
        .ToList();

    foreach (var element in targets)
    {
        canvas.Children.Remove(element);
    }
}

この方式なら、「ガイドモードではグリッドを描かない」「必要なときだけトグルで描く」を両立できます。グリッド表示の ON/OFF が座標ロジックに干渉しないため、バグの温床になりにくいのが最大のメリットです。

実務で役立つデバッグのコツ

  • クリックした点をログに出す: PointerPressed で e.GetCurrentPoint(_canvas).Position を出力し、ピクセル値が期待通りか確認する
  • グリッド番号も同時に表示: (int)(x / GridSpacing) を併記して、変換が正しいかチェックする
  • UI の見た目を疑う: 「座標が無い」ではなく「スクロールできず見えていない」ことが多いので、まずは ScrollViewer の有無を確認する
  • Width/Height と ActualWidth/ActualHeight: Canvas サイズをコードで固定していない場合は Loaded 後の ActualWidth を使う(ガイド候補点の生成タイミングに注意)

まとめ:ズレを消す鍵は“単位の統一”と“責務の分離”

  • Canvas の実座標はピクセル、ユーザーに見せるのはグリッド番号。混ぜない
  • ContentDialog の一覧は ScrollViewer / GridView で必ずスクロール可能にする
  • ガイドモードは候補点生成だけにして、グリッド表示(見た目)から切り離す
  • Canvas は作り直すたびに古いものを削除し、描画の残骸を残さない

この整理で、ガイドモードでもグリッド表示でも「同じ座標体系」で扱えるようになり、ポイント選択と描画がズレなくなります。さらに UI を改善したくなった場合も、候補点生成が独立しているので、GridView から ListView へ変えるなどの変更が安全にできます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次