午後わてんのブログ

ベランダ菜園とWindows用アプリ作成(WPFとC#)

WPF、カスタムコントロール子要素の位置変更後に、親要素の位置とサイズを変更

前々回からの続き

WPF、自動サイズCanvasをGroupThumbに使ってみた - 午後わてんのブログ gogowaten.hatenablog.com


今回の結果

結果
子要素の移動後には親要素の位置が変更(修正)する処理を追加したので違和感がなくなっている
この位置変更処理が必要になるのは、子要素が親要素の左側と上側の範囲外(マイナス座標)になったときで、今回はこの処理を子要素の移動後に行っている


テストアプリのコード

2024WPF/20241212_ReLayoutGroupThumb at master · gogowaten/2024WPF

github.com

ソリューションエクスプローラーでのファイルの配置状態



環境

  • 作成と動作環境
  • Windows 10 Home バージョン 22H2
  • Visual Studio Community 2022 Version 17.12.3
  • WPF
  • C#
  • .NET 8.0




ExCanvas.cs

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

namespace _20241212_ReLayoutGroupThumb
{
    /// <summary>
    /// 子要素に合わせてサイズが変化するCanvas
    /// ただし、子要素のマージンとパディングは考慮していないし
    /// ArrangeOverrideを理解していないので不具合があるかも
    /// </summary>
    public class ExCanvas : Canvas
    {
        protected override Size ArrangeOverride(Size arrangeSize)
        {
            if (double.IsNaN(Width) && double.IsNaN(Height))
            {
                base.ArrangeOverride(arrangeSize);
                Size resultSize = new();
                foreach (var item in Children.OfType<FrameworkElement>())
                {
                    double x = GetLeft(item) + item.ActualWidth;
                    double y = GetTop(item) + item.ActualHeight;
                    if (resultSize.Width < x) resultSize.Width = x;
                    if (resultSize.Height < y) resultSize.Height = y;
                }
                return resultSize;
            }
            else
            {
                return base.ArrangeOverride(arrangeSize);
            }
        }
    }
}

子要素に応じでサイズ調整するCanvasは前々回からのコピペ


CustomControl1.sc

using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Markup;
using System.Windows.Media;
using System.Collections.Specialized;

namespace _20241212_ReLayoutGroupThumb
{


    /// <summary>
    /// 基礎Thumb、すべてのCustomControlThumbの派生元
    /// </summary>
    public abstract class KisoThumb : Thumb
    {
        #region 依存関係プロパティ

        public double MyLeft
        {
            get { return (double)GetValue(MyLeftProperty); }
            set { SetValue(MyLeftProperty, value); }
        }
        public static readonly DependencyProperty MyLeftProperty =
            DependencyProperty.Register(nameof(MyLeft), typeof(double), typeof(KisoThumb), new PropertyMetadata(0.0));

        public double MyTop
        {
            get { return (double)GetValue(MyTopProperty); }
            set { SetValue(MyTopProperty, value); }
        }
        public static readonly DependencyProperty MyTopProperty =
            DependencyProperty.Register(nameof(MyTop), typeof(double), typeof(KisoThumb), new PropertyMetadata(0.0));

        public string MyText
        {
            get { return (string)GetValue(MyTextProperty); }
            set { SetValue(MyTextProperty, value); }
        }
        public static readonly DependencyProperty MyTextProperty =
            DependencyProperty.Register(nameof(MyText), typeof(string), typeof(KisoThumb), new PropertyMetadata(string.Empty));

        #endregion 依存関係プロパティ

        //親要素の識別用。自身がグループ化されたときに親要素のGroupThumbを入れておく
        public GroupThumb? MyParentThumb { get; internal set; }
        static KisoThumb()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(KisoThumb), new FrameworkPropertyMetadata(typeof(KisoThumb)));
        }
        public KisoThumb()
        {
            DataContext = this;
            Focusable = true;
        }
    }

    public class TextBlockThumb : KisoThumb
    {
        static TextBlockThumb()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(TextBlockThumb), new FrameworkPropertyMetadata(typeof(TextBlockThumb)));
        }

    }

    public class EllipseTextThumb : TextBlockThumb
    {
        #region 依存関係プロパティ

        public double MySize
        {
            get { return (double)GetValue(MySizeProperty); }
            set { SetValue(MySizeProperty, value); }
        }
        public static readonly DependencyProperty MySizeProperty =
            DependencyProperty.Register(nameof(MySize), typeof(double), typeof(EllipseTextThumb), new PropertyMetadata(30.0));
        #endregion 依存関係プロパティ

        static EllipseTextThumb()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(EllipseTextThumb), new FrameworkPropertyMetadata(typeof(EllipseTextThumb)));
        }

    }

    [ContentProperty(nameof(MyThumbs))]
    public class GroupThumb : KisoThumb
    {
        #region 依存関係プロパティ

        public ObservableCollection<KisoThumb> MyThumbs
        {
            get { return (ObservableCollection<KisoThumb>)GetValue(MyThumbsProperty); }
            set { SetValue(MyThumbsProperty, value); }
        }
        public static readonly DependencyProperty MyThumbsProperty =
            DependencyProperty.Register(nameof(MyThumbs), typeof(ObservableCollection<KisoThumb>), typeof(GroupThumb), new PropertyMetadata(null));

        #endregion 依存関係プロパティ

        #region コンストラクタ

        static GroupThumb()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(GroupThumb), new FrameworkPropertyMetadata(typeof(GroupThumb)));
        }
        public GroupThumb()
        {
            MyThumbs = [];
            Loaded += GroupThumb_Loaded;
            MyThumbs.CollectionChanged += MyThumbs_CollectionChanged;
            FrameworkPropertyMetadata newmeta = new(typeof(GroupThumb));


        }

        #endregion コンストラクタ

        #region 初期化

        private void GroupThumb_Loaded(object sender, RoutedEventArgs e)
        {
            var temp = GetTemplateChild("PART_ItemsControl");
            if (temp is ItemsControl ic)
            {
                var ec = GetExCanvas(ic);
                if (ec != null)
                {
                    _ = SetBinding(WidthProperty, new Binding() { Source = ec, Path = new PropertyPath(ActualWidthProperty) });
                    _ = SetBinding(HeightProperty, new Binding() { Source = ec, Path = new PropertyPath(ActualHeightProperty) });
                }
            }
        }


        //子要素を辿ってExCanvasを取り出す
        private static ExCanvas? GetExCanvas(DependencyObject d)
        {
            if (d is ExCanvas canvas) return canvas;
            for (int i = 0; i < VisualTreeHelper.GetChildrenCount(d); i++)
            {
                ExCanvas? c = GetExCanvas(VisualTreeHelper.GetChild(d, i));
                if (c is not null) return c;
            }
            return null;
        }


        private void MyThumbs_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
        {
            if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is KisoThumb nnt)
            {
                nnt.MyParentThumb = this;
            }
            else if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems?[0] is KisoThumb ot)
            {
                ot.MyParentThumb = null;
            }
        }
        #endregion 初期化

        /// <summary>
        /// 再配置、子要素の座標を元に位置を修正する
        /// </summary>
        public void ReLayout()
        {
            //すべての子要素で最も左上の座標を取得
            double left = double.MaxValue; double top = double.MaxValue;
            foreach (var item in MyThumbs)
            {
                if (left > item.MyLeft) { left = item.MyLeft; }
                if (top > item.MyTop) { top = item.MyTop; }
            }

            //自身の座標と比較、同じ(変化なし)なら終了
            if (left == MyLeft && top == MyTop) return;

            //座標変化の場合は、自身と全ての子要素の座標を変更する
            if (left != 0)
            {
                foreach (var item in MyThumbs) { item.MyLeft -= left; }
                MyLeft += left;
            }
            if (top != 0)
            {
                foreach (var item in MyThumbs) { item.MyTop -= top; }
                MyTop += top;
            }

            //ParentThumbにも伝播させる
            MyParentThumb?.ReLayout();
        }

        /// <summary>
        /// ReLayoutの左上座標取得をRectのUnion版、いまいち
        /// </summary>
        public void ReLayout2()
        {
            //子要素すべてが収まる範囲Rectを計算
            KisoThumb tt = MyThumbs[0];
            Rect uRect = new(tt.MyLeft, tt.MyTop, tt.ActualWidth, tt.ActualHeight);
            foreach (var item in MyThumbs)
            {
                Rect r = new(item.MyLeft, item.MyTop, item.ActualWidth, item.ActualHeight);
                uRect.Union(r);
            }

            //今の範囲Rectと比較、座標変化なしなら終了
            if (uRect.Left == MyLeft && uRect.Top == MyTop) return;

            //座標変化の場合は、自身と全ての子要素の座標を変更する
            if (uRect.Left != 0)
            {
                foreach (var item in MyThumbs) { item.MyLeft -= uRect.Left; }
                MyLeft += uRect.Left;
            }
            if (uRect.Top != 0)
            {
                foreach (var item in MyThumbs) { item.MyTop -= uRect.Top; }
                MyTop += uRect.Top;
            }

            //ParentThumbにも伝播させる
            MyParentThumb?.ReLayout2();
        }

    }

}

Thumbを継承したカスタムコントロール群は前回からのコピペ改変

親要素の位置変更処理

親要素の位置変更処理部分
これを実行するのは親要素だけど、そのきっかけは子要素にあるので、子要素は親要素を知っている必要がある。ってことで親要素を入れておくプロパティを用意、MyParentThumbって名前にした、194行目とかにあるもの
これで位置変更があったら、更に親の親と遡って処理することもできる

MyParentThumbに親要素をいれるタイミング
親要素が子要素を管理しているプロパティはMyThumbs、これはCollection型なので要素変更時に動くCollectionChangedっていうイベントがあるのでそこで行うようにした

子要素に自身(親要素)を登録
子要素追加時に自身を子要素のMyParentThumbに登録、これは必須。削除時にMyParentThumbをnullにしているけど、必要ないかも


Generic.xaml

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:_20241212_ReLayoutGroupThumb">



  <Style x:Key="kiso" TargetType="{x:Type local:KisoThumb}">
    <Setter Property="Canvas.Left" Value="{Binding MyLeft}"/>
    <Setter Property="Canvas.Top" Value="{Binding MyTop}"/>
  </Style>


  <Style TargetType="{x:Type local:TextBlockThumb}"
         BasedOn="{StaticResource kiso}">
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate>
          <TextBlock Text="{Binding MyText}"
                     Background="{TemplateBinding Background}"/>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>


  <Style TargetType="{x:Type local:EllipseTextThumb}"
         BasedOn="{StaticResource kiso}">
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate>
          <Grid>
            <Ellipse Width="{Binding MySize}"
                     Height="{Binding MySize}"
                     Fill="{TemplateBinding Background}"/>
            <TextBlock Text="{Binding MyText}"
                       HorizontalAlignment="Center"
                       VerticalAlignment="Center"/>
          </Grid>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>


  <Style TargetType="{x:Type local:GroupThumb}"
         BasedOn="{StaticResource kiso}">
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate>
          <ItemsControl x:Name="PART_ItemsControl"
                        ItemsSource="{Binding MyThumbs}"
                        Background="{TemplateBinding Background}">
            <ItemsControl.ItemsPanel>
              <ItemsPanelTemplate>
                <local:ExCanvas/>
              </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <!--<ItemsControl.ItemContainerStyle>
              <Style TargetType="Thumb">
                <Setter Property="Canvas.Left" Value="{Binding MyLeft}"/>
                <Setter Property="Canvas.Top" Value="{Binding MyTop}"/>
              </Style>
            </ItemsControl.ItemContainerStyle>-->
          </ItemsControl>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>


</ResourceDictionary>

カスタムコントロール用
前回にも使ったBasedOnが活躍している
子要素を持つことができるGroupThumbの部分は以前の
WPF、ItemsControlを使って要素を入れ子にできるカスタムコントロールThumb、グループ化みたいなもの - 午後わてんのブログ

gogowaten.hatenablog.com
ここからのコピペ改変
今回は子要素を並べるPanelには自動リサイズのExCanvasを使っているので、子要素の位置とサイズによってExCanvasのサイズが変化して、ExCanvasのサイズとGroupThumbのサイズをBindingしているのでGroupThumbのサイズも自動で変化する

コメントアウトになっているところ
子要素の位置プロパティをCanvasの添付プロパティのLeftとTopにBindingする必要があると思って書いたんだけど、無くても動いたのでコメントアウトした。これはたぶんKisoThumbのStyleでBindingしているのを、BasedOnで継承しているから?


MainWindow.xaml

<Window x:Class="_20241212_ReLayoutGroupThumb.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:_20241212_ReLayoutGroupThumb"
        mc:Ignorable="d"
               Title="MainWindow" Height="350" Width="500">
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition Width="140"/>
    </Grid.ColumnDefinitions>

    <Canvas>
      <Canvas.Resources>
        <Style TargetType="{x:Type local:KisoThumb}" x:Key="kiso">
          <EventSetter Event="DragCompleted" Handler="KisoThumb_DragCompleted"/>
          <EventSetter Event="DragDelta" Handler="Thumb_DragDelta"/>
        </Style>
        <Style TargetType="{x:Type local:TextBlockThumb}" BasedOn="{StaticResource kiso}"/>
        <Style TargetType="{x:Type local:EllipseTextThumb}" BasedOn="{StaticResource kiso}"/>
        <Style TargetType="{x:Type local:GroupThumb}" BasedOn="{StaticResource kiso}"/>
      </Canvas.Resources>

      <local:GroupThumb x:Name="MyRootGroup" Background="Lavender">
        <local:TextBlockThumb MyText="Group0-1" Background="LightSteelBlue"/>

        <local:GroupThumb x:Name="MyGroup1" MyLeft="20" MyTop="20" Background="Salmon">
          <local:TextBlockThumb x:Name="MyItem1_1" MyLeft="0" MyTop="0"
                                MyText="Group1-1" Background="LightSalmon"/>
          <local:EllipseTextThumb MyLeft="50" MyTop="50" MySize="60"
                                  MyText="Group1-2" Background="Gold"/>
        </local:GroupThumb>

        <local:GroupThumb x:Name="MyGroup2" MyLeft="100" MyTop="140" Background="Violet">
          <local:TextBlockThumb x:Name="MyItem2_1" MyLeft="0" MyTop="0"
                                MyText="Group2-1" Background="Pink"/>
          <local:EllipseTextThumb MyLeft="50" MyTop="30" MySize="60"
                                  MyText="Group2-2" Background="Gold"/>
        </local:GroupThumb>

      </local:GroupThumb>
    </Canvas>

    <StackPanel Grid.Column="1">
      <Button x:Name="MyButtonText" Content="Group1-1を左に100移動" Click="MyButtonText_Click"/>
      <GroupBox Header="Group0" Background="{Binding Background}" DataContext="{Binding ElementName=MyRootGroup}">
        <StackPanel>
          <TextBlock Text="{Binding Path=MyLeft, StringFormat={}{0:0.0} Left}"/>
          <TextBlock Text="{Binding Path=MyTop, StringFormat={}{0:0.0} Top}"/>
          <TextBlock Text="{Binding Path=ActualWidth, StringFormat={}{0:0.0} width}"/>
          <TextBlock Text="{Binding Path=ActualHeight, StringFormat={}{0:0.0} height}"/>
        </StackPanel>
      </GroupBox>
      <GroupBox Header="Group1" Background="{Binding Background}" DataContext="{Binding ElementName=MyGroup1}">
        <StackPanel>
          <TextBlock Text="{Binding Path=MyLeft, StringFormat={}{0:0.0} Left}"/>
          <TextBlock Text="{Binding Path=MyTop, StringFormat={}{0:0.0} Top}"/>
          <TextBlock Text="{Binding Path=ActualWidth, StringFormat={}{0:0.0} width}"/>
          <TextBlock Text="{Binding Path=ActualHeight, StringFormat={}{0:0.0} height}"/>
        </StackPanel>
      </GroupBox>
      <GroupBox Header="Group2" Background="{Binding Background}" DataContext="{Binding ElementName=MyGroup2}">
        <StackPanel>
          <TextBlock Text="{Binding Path=MyLeft, StringFormat={}{0:0.0} Left}"/>
          <TextBlock Text="{Binding Path=MyTop, StringFormat={}{0:0.0} Top}"/>
          <TextBlock Text="{Binding Path=ActualWidth, StringFormat={}{0:0.0} width}"/>
          <TextBlock Text="{Binding Path=ActualHeight, StringFormat={}{0:0.0} height}"/>
        </StackPanel>
      </GroupBox>
      
    </StackPanel>
  </Grid>
</Window>

後半のStackPanelの中身は、位置とサイズの確認用なので必要ない


MainWindow.xaml.cs

using System.Windows;
using System.Windows.Controls.Primitives;

namespace _20241212_ReLayoutGroupThumb
{
   public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void Thumb_DragDelta(object sender, DragDeltaEventArgs e)
        {
            if (sender is KisoThumb t)
            {
                t.MyLeft += e.HorizontalChange;
                t.MyTop += e.VerticalChange;
                e.Handled = true;
            }
        }

        private void MyButtonText_Click(object sender, RoutedEventArgs e)
        {
            MyItem1_1.MyLeft -= 100;
            MyItem1_1.MyParentThumb?.ReLayout();
        }

        //ドラッグ移動終了時
        private void KisoThumb_DragCompleted(object sender, DragCompletedEventArgs e)
        {
            //親要素の再配置
            if (sender is KisoThumb t && t.MyParentThumb is not null)
            {
                t.MyParentThumb.ReLayout();
            }
            //イベントをここで停止
            e.Handled = true;
        }
    }
}

ドラッグ移動終了時イベントのKisoThumb_DragCompletedで、親要素の位置変更を実行している
ドラッグ移動しているのは子要素だけど、位置変更処理はその親要素なので、子要素は親要素を知っている必要があったのがここ、そのためのMyParentThumbだったわけ
今思ったけど親要素の型(クラス)は必ずGroupThumbなんだから、MyParentGroupって名前のほうが良かったかも

ボタンのクリックイベントMyButtonText_Click
ここでは子要素のの左位置の値を-100した後に、親要素位置の変更をしている


感想

テストアプリ

思ってたよりだいぶ簡潔に書けた
2年前のときはサイズと位置の調整でかなり難航して、できるにはできたけどぐちゃぐちゃだった
今回のもポリラインやベジェ曲線が入ってくると難しくなるのかも?


関連記事

昨日
WPF、Styleの引き継ぎ(継承)させるBasedOnをCustomControlでも使ってみた - 午後わてんのブログ gogowaten.hatenablog.com

3日前
WPF、自動サイズCanvasをGroupThumbに使ってみた - 午後わてんのブログ gogowaten.hatenablog.com

4日前
WPF、ItemsControlを使って要素を入れ子にできるカスタムコントロールThumb、グループ化みたいなもの - 午後わてんのブログ gogowaten.hatenablog.com



1週間前
WPF、カスタムコントロール使ってみた、マウスドラッグ移動できるTextBlockを作成 - 午後わてんのブログ gogowaten.hatenablog.com