dhq_boiler
@dhq_boiler

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

[WPF]直線の片方の頂点を選択した時、SelectedItemsが直線の両方の頂点を示してしまう

お世話になっております。

解決したいこと

C# + WPFでベクターグラフィックスドローイングツールを開発しています。

2021-09-26.png

※下にソースコードへの案内を記載しております。よろしければそちらを参照ください。

この記事のタイトルはちょっとわかりにくいかもしれないので、順を追って説明していきます。

発生している問題・エラー

ユニットテストでテスト失敗になっています。
そのテストは「直線の頂点1つが選択されている」というテストメソッドで、コードは下記のようなものです。

SelectedItemsTest.cs
using boilersGraphics.Models;
using boilersGraphics.ViewModels;
using NUnit.Framework;
using System.Linq;
using System.Windows;

namespace Question20210925.Test
{
    [TestFixture]
    public class SelectedItemsTest
    {
        :
        [Test]
        public void 直線の頂点1つが選択されている()
        {
            boilersGraphics.App.IsTest = true;
            var mainWindowViewModel = new MainWindowViewModel(null);
            var viewModel = new DiagramViewModel(mainWindowViewModel, 1000, 1000);
            viewModel.Layers.Clear();
            var layer1 = new Layer();
            layer1.Name.Value = "レイヤー1";
            viewModel.Layers.Add(layer1);
            layer1.IsSelected.Value = true;

            var item1 = new StraightConnectorViewModel(viewModel, new Point(10, 10), new Point(20, 20));
            viewModel.AddItemCommand.Execute(item1);

            layer1.Children[0].IsSelected.Value = true;
            item1.SnapPoint0VM.Value.IsSelected.Value = true;
            item1.SnapPoint1VM.Value.IsSelected.Value = false;

            Assert.That(viewModel.SelectedItems.Value.ToList(), Has.Count.EqualTo(1)); //Expected: property Count equal to 1 But was:  2
            Assert.That(viewModel.SelectedItems.Value.ElementAt(0).IsSelected.Value, Is.True);
        }
    }
}

この問題の背景を説明しますと、この製品boiler's Graphicsは描画されたアイテムをクリックして選択したり、なげなわツールで選択すると、内部的にDiagramViewModelクラスのSelectedItemsプロパティで選択されたアイテムをアイテム全体から抽出して返すような仕組みになっています。これはReactivePropertyにより実現しました。

しかし、先日、直線の頂点の片方を選択した時に、矢印キーで移動させると、その選択した直線の頂点の片方が動くようにプログラミングしたつもりだったのですが、本記事で挙げているテストコードで失敗になってしまいました。
つまり、直線の頂点の片方を選択した時に、矢印キーで移動させると、その選択した直線の頂点の片方が動くはずが、その選択した直線の頂点の両方が選択されてしまい、結果的に直線全体が動いてしまうという現象が起きています。

下記の図は本機能の概説したものになります。
MINIMALIZED_boilersGraphics.jpg

恐らく、DiagramViewModelクラスのSelectedItemsプロパティに問題が潜んでいるのだと思いますが、どのようにかけば当テストケースが通るようになるでしょうか。

DiagramViewModel.cs
    public class DiagramViewModel : BindableBase, IDiagramViewModel, IDisposable
    {
        :
        public ReadOnlyReactivePropertySlim<SelectableDesignerItemViewModelBase[]> AllItems { get; }

        public ReadOnlyReactivePropertySlim<SelectableDesignerItemViewModelBase[]> SelectedItems { get; }
        :

        public DiagramViewModel(MainWindowViewModel mainWindowViewModel, int width, int height)
        {
            :
            AllItems = Layers.CollectionChangedAsObservable()
                             .Select(_ => Layers.Select(x => x.LayerItemsChangedAsObservable()).Merge()
                                .Merge(this.ObserveProperty(y => y.BackgroundItem.Value).ToUnit()))
                             .Switch()
                             .Select(_ => Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                .Where(x => x.GetType() == typeof(LayerItem))
                                                .Select(y => (y as LayerItem).Item.Value)
                                                .Union(new SelectableDesignerItemViewModelBase[] { BackgroundItem.Value })
                                                .ToArray())
                             .ToReadOnlyReactivePropertySlim(Array.Empty<SelectableDesignerItemViewModelBase>());

            :
            SelectedItems = Layers.CollectionChangedAsObservable()
                                  .Select(_ => Layers.Select(x => x.SelectedLayerItemsChangedAsObservable()).Merge()
                                      .Merge(Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                   .Where(x => x.GetType() == typeof(LayerItem))
                                                   .Select(y => (y as LayerItem).Item.Value)
                                                   .OfType<ConnectorBaseViewModel>()
                                                   .SelectMany(x => new List<SnapPointViewModel>() { x.SnapPoint0VM.Value, x.SnapPoint1VM.Value })
                                                   .ToObservableCollection()
                                                   .ObserveElementProperty(x => x.IsSelected.Value)
                                                   .ToUnit()))
                                  .Switch()
                                  .Select(_ => Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                     .Where(x => x.GetType() == typeof(LayerItem))
                                                     .Select(y => (y as LayerItem).Item.Value)
                                                     .Except(Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                                   .Where(x => x.GetType() == typeof(LayerItem))
                                                                   .Select(y => (y as LayerItem).Item.Value)
                                                                   .OfType<ConnectorBaseViewModel>())
                                                     .Union(Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                                  .Where(x => x.GetType() == typeof(LayerItem))
                                                                  .Select(y => (y as LayerItem).Item.Value)
                                                                  .OfType<ConnectorBaseViewModel>()
                                                                  .SelectMany(x => new List<SnapPointViewModel>() { x.SnapPoint0VM.Value, x.SnapPoint1VM.Value })
                                                            )
                                                     .Where(z => z.IsSelected.Value == true)
                                                     .OrderBy(z => z.SelectedOrder.Value)
                                                     .ToArray())
                                  .ToReadOnlyReactivePropertySlim(Array.Empty<SelectableDesignerItemViewModelBase>());
        }
    }
Extensions.cs
    internal static class Extensions
    {
        :
        public static IEnumerable<T2> SelectRecursive<T1, T2>(this IEnumerable<T1> source, Func<T2, IEnumerable<T2>> selector) where T1 : class where T2 : class
        {
            foreach (var parent in source)
            {
                yield return parent as T2;

                var children = selector(parent as T2);
                foreach (var child in SelectRecursive(children, selector))
                    yield return child;
            }
        }
        :
    }

ちなみに、本現象をQiitaに掲載するために当プロジェクトの最小機能版を作成したのですが、
それを動かして試してみた様子をGifアニメーションで確認できるようにしてみました。
MINIMALIZED_boilersGraphics.gif
ちょっとよく見ないとわかりにくいのですが、
1. 直線を描画
2. なげなわツールで直線の頂点の片方を選択
3. 選択ツール(マウスポインターの形のツール)で何もないところをクリック(直線を矢印キーで動かすための儀式です、たぶんなんらかのバグ)
4. 再度なげなわツールで直線の頂点の片方を選択
5. 矢印キーで直線を動かす(1ピクセル単位で動くのでぱっと見わかりづらいです)
Gifアニメーションは上記のような内容です。

該当するソースコード

当プロジェクト(ネタ元)
boiler's Graphics
https://github.com/dhq-boiler/boiler-s-Graphics
コミット:aa22aeb付近(develop)

当プロジェクトの最小機能版
https://github.com/dhq-boiler/Qiita/tree/develop/Question20210925

自分で試したこと

  • DiagramViewModel.SelectedItemsプロパティを見直しました
  • 最小機能版を抽出し、バグが再現するか確認しました→再現した
  • 最小機能版のテストプロジェクトを抽出し、テストが失敗するか確認しました→テスト失敗した

何か私の見落とし、致命的な勘違いなど気づいたところがあれば、回答していただけると助かります。よろしくお願いいたします。

追伸

このバグが取り除けたら、テストカバレッジ率を上げるべく作業したいです。
現在24%で、目標の75%~80%には遠く及ばないので、大手を振って大々的に宣伝できずにいます。
この数字は非常に恥ずかしいので、早くこのバグを取り除いて作業したいです...。

0

6Answer

断片しかわからないので想像になりますが、

layer1.Children[0].IsSelected.Value = true;
item1.SnapPoint0VM.Value.IsSelected.Value = true;
item1.SnapPoint1VM.Value.IsSelected.Value = false;

としていれば選択状態になっているのは「直線自身と片方の頂点」で、直線自身がキーダウンイベントを受け付けているために平行移動になっているのではないでしょうか。

期待している動きにするには

  1. 片方の頂点のみを選択
  2. キーダウンイベントで選択した頂点を移動
  3. 頂点の更新により直線の座標を再計算

と思われ、もし直線が非選択状態なら頂点が更新されても直線は更新されないような作りになっているのであれば、根本的に設計を見直さないといけないような気がします。

0Like

すみません、言葉が足りなかったようでした。

直線全体が動いてしまう問題を言い換えると、
なげなわツールで片方の頂点のみを選択したにもかかわらず
両方の頂点がSelectedItemsに計上されてしまっているということです。

デバッグ中のスクリーンショットがわかりやすいかなと思うので挙げてみます。
2021-09-26 (2).png

画像中のMoveSelectedItemsメソッドは選択されたアイテムの座標をdiff分だけ動かすメソッドです。
ここで、SelectedItems.Valueの中身を見てみると、2つのSnapPointViewModelオブジェクトがあります。しかし、[0]はIsSelected=Trueで正しいのですが、[1]はIsSelected=FalseでありSelectedItemsに存在するはずのないオブジェクトが存在していることになります。

バグの原因としてどこが一番怪しいかというとDiagramViewModel.SelectedItemsに代入しているコードです。特にSwitch()する前までの部分のコードが怪しいです。Switch()する前にIEnumerableをObservableCollectionに変換しているところが非常に怪しいです。
自分で書いててどうかとも思うのですが、ToObservableCollectionメソッドを使った時、ろくに動かなかった、正しく動作した記憶がないのです。
でも他に書き方があるのかと言われると、ない、見つからなかったのでこれを採用しています。

DiagramViewModel.cs
                   SelectedItems = Layers.CollectionChangedAsObservable()
                                  .Select(_ => Layers.Select(x => x.SelectedLayerItemsChangedAsObservable()).Merge()
                                      .Merge(Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                   .Where(x => x.GetType() == typeof(LayerItem))
                                                   .Select(y => (y as LayerItem).Item.Value)
                                                   .OfType<ConnectorBaseViewModel>()
                                                   .SelectMany(x => new List<SnapPointViewModel>() { x.SnapPoint0VM.Value, x.SnapPoint1VM.Value })
                                                   .ToObservableCollection()
                                                   .ObserveElementProperty(x => x.IsSelected.Value)
                                                   .ToUnit()))
                                  .Switch()
                                  .Select(_ => Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                     .Where(x => x.GetType() == typeof(LayerItem))
                                                     .Select(y => (y as LayerItem).Item.Value)
                                                     .Except(Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                                   .Where(x => x.GetType() == typeof(LayerItem))
                                                                   .Select(y => (y as LayerItem).Item.Value)
                                                                   .OfType<ConnectorBaseViewModel>())
                                                     .Union(Layers.SelectRecursive<LayerTreeViewItemBase, LayerTreeViewItemBase>(x => x.Children)
                                                                  .Where(x => x.GetType() == typeof(LayerItem))
                                                                  .Select(y => (y as LayerItem).Item.Value)
                                                                  .OfType<ConnectorBaseViewModel>()
                                                                  .SelectMany(x => new List<SnapPointViewModel>() { x.SnapPoint0VM.Value, x.SnapPoint1VM.Value })
                                                            )
                                                     .Where(z => z.IsSelected.Value == true)
                                                     .OrderBy(z => z.SelectedOrder.Value)
                                                     .ToArray())
                                  .ToReadOnlyReactivePropertySlim(Array.Empty<SelectableDesignerItemViewModelBase>());
0Like

ReactivePropertyを使ったことはないのですが、気になるのはSelectedItems内でIsSelectedを見てはいるけど、IsSelectedを変更されたことを通知されていないように見えます。

SelectedItems = Layers.CollectionChangedAsObservable() ...

となっていれば、SelectedItemsの内容が更新されるのは「Layersに追加や削除が行われたとき」であり、「Layersに含まれる要素内のプロパティが変更されたとき」ではありません。
だからそのコレクションに含まれる要素は「最後にLayersに追加や削除が行われたとき」から変化しません。

おそらくSelectedItemsはReactivePropertyではなく、ObservableCollection<LayerItem>型のコレクションとして実装するのが正しい形じゃないでしょうか。
そしてIsSelectedは更新されるたびにSelectedItems.Add()SelectedItems.Remove()を実行することでSelectedItemsの内容を更新する。

0Like

あ、今見るとObserveElementProperty(x => x.IsSelected.Value)IsSelectedは監視対象に指定しているのを見落としてましたね…
なるほどこう書けばコレクション内の値の変化を拾えるのか。
そしてReactiveProperty内部が意図した動作になっていなかったとは!解決できてよかったです。

ところで全然関係のないどうでもいいことに気づいたのですが、
.Where(x => x.GetType() == typeof(LayerItem))

.OfType<LayerItem>()
と書いた方がすっきりするのではないでしょうか。

0Like

Your answer might help someone💌