【C#】WPFにおける動的なUserControlの追加

こんにちわ。女性はひつじではありません、人間ですとかよく言われます。

※最近いいねが付いたので追記させていただきます。めんどくさ 2025/3/25


簡単なんだけど何故か詰まったので書いてみる（とにかくこういう事がとても多い）。初学者でも比較的カンタンに試せるように書いたので気軽にやってみてほしい。

詰まったときに [こういう記事](https://qiita.com/okazuki/items/e7da617887003d0f2899)をたまたま見つけて、自分のUserコントロールを追加したら見切れたんでTeratailで質問してみたことがある。Heightを指定しないといけないとの事である。

もちろん、Canvasに自作のコントロールを追加するなんて事はしないでいい（やってて違和感があったんですぐ辞めた）。
通常はStackPaneとかに追加する。


# 自作のUserControl（外観）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145714/8c8cf937-e026-e41c-3938-875b335f77ad.png)

```C#
<UserControl x:Class="WpfApp3.UserControl1"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfApp3"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
<!--Grid　要素は書き換え-->
    <Grid HorizontalAlignment="Left" VerticalAlignment="Top">
     <!--コピーここから-->
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="Auto" >

        </Grid.ColumnDefinitions>
        <TextBox x:Name="ArgumentEditor" Grid.Row="0" Grid.Column="2" Margin="-10,3,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="380"/>

        <RadioButton />

        <ContentControl HorizontalAlignment="Left" Name="SelectorLabelCon">
            <TextBlock  
                    Name="ParamLabel"
                TextWrapping="Wrap"
                    
               Grid.Row="0"
               Grid.Column="2"
                HorizontalAlignment="Left"
                 Text="パラメータ名"  Margin="15,3,15,0"/>
        </ContentControl>
    <!--ここまで-->
    </Grid>
</UserControl>

```

WPF（XAML）はこういうとき、本当に便利である。
コピペは一気に貼るよりは要素毎に貼り付けることをオススメする。

これを新しいプロジェクトを作って適用してみる。


最近のwindows11ではプロジェクトの作成時にまで管理者権限を要求するようになっている。めんどくさい。

面倒だからUserControl1でいいだろう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145714/be49947d-6f0b-0b59-041b-4a11f069dd8f.png)



MainForm側はStackPnelを追加して適当な名前を付ける（だけ）。

```C#
 <Grid>
        <StackPanel x:Name="mainPanel">                  
        </StackPanel>
</Grid>
```

あとはLoadイベントでも追加しておく
Loadイベントはタブコントロール切り替えなどで複数回呼ばれる可能性が高いのでコンストラクタの方がいいかも知れない。

# 呼び出し実装
```C#
 private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            
            
            for(int Count = 0  ; Count < 10  ; Count++ )
            {
                Thickness marthick = new Thickness(-40, 0, 0, 0);

                var useC = new UserControl1() 
                {
                    Margin = marthick,
                    FontSize = 14,
                    Width = 480,
                    
                    Name = "ParamSelector" + $"{Count}"
                    //名前と番号を振っておく
                };

                if (!mainPanel.Children.Contains(useC))
                    mainPanel.Children.Add(useC);
                    
            }

```

### 実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145714/71acdd67-3d47-d6e5-66c0-d623e9859286.png)


大して時間は掛からない筈である。

動的なコントロールの操作方法はNameプロパティの割り振りを始め、コンストラクタでイベント登録などをゴリゴリ書かないといけないのでちょっと大変かもしれない。

そのうち手を出したいDataBind、ViewModel
https://qiita.com/soi/items/12ceea4efcf31c1a7b93

[自作のアプリ](https://qiita.com/EndOfData/items/b643638b176cd440c648)ではこれをとにかく書きまくっている。ユーザーコントロール側で書いた方がいい実装もあると思う。ちょっと重そうだし。
DataBindingを駆使すると実装側は比較的省力化出来る筈なのでそのうちやりたいです。DataContextをTwoWayに設定出来るらしい。

イベントのsenderで見分ける場合は、WPFコントロールの親要素をや子要素を見つける方法がblogで公開されてるので調べて併用してほしい。


# 動的に追加したUserControlの活用方法

例によって自作のアプリ( https://github.com/Sheephuman/HaruaConvert )ぐらいしか例を出せないんだけど、使い熟すとこんな感じです。コードゴリゴリ書いてるだけで、それほど難しいことはしてない。


※最近動画の貼り方が判明したので
<iframe width="560" height="315" src="https://www.youtube.com/embed/YB2mjhUVvsE?si=fUc0Z_zMuSFVjdba" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


今回はこれを記事用にサンプルコード化させていただきます。


## UserControlにiniファイルを読み込む





今回は用意されたiniファイルを読み込みます。
ちなみに自前のモノです。
※メモ帳等で```UTF-16 LE```で保存しないと文字化けします。
面倒な場合はgitを使用してください

### git
Gsudo :https://github.com/gerardog/gsudo
```slim
git clone https://github.com/Sheephuman/UserControl_Sample.git
```

```idr:iniファイル
[OriginalUserControl_0]
Arguments_0={input} {output}.gif 
ParameterLabel_0=GIF変換
[CheckState]
OriginalUserControl_Check=0
isForceExecCheckBox=False
NoDialogCheck=False
NoAudio=False
Force30FPS=False
isUserParameter=True
IsOpenForuderChecker=True
AutoScroll_Checker=True
BackImage_Checker=False
ExitExplorerChecker=False
[WindowsLocate]
WindowLeft=457.5
WindowTop=255.5
[Directory]
ConvertDirectory=C:\Users\USER\Videos
MainTab_OutputDirectory=G:\bin
ParamTab_OutputSelectorDirectory=G:\test
ParamTab_InputSelectorDirectory=
[Selector_Generate]
Selector_Generate=6
[ffmpegQuery]
BaseQuery=-codec:v libx265 -r 60 -vf yadif=0:-1:1 -pix_fmt yuv420p -acodec aac -threads 2                          
endStrings=_Harua
[OriginalUserControl_1]
Arguments_1={input} -filter_complex "[0:v]setpts=0.33*PTS[v];[0:a]atempo=3.0[a]" -map "[v]" -map "[a]" {output}.mp4   
ParameterLabel_1=倍速再生
[OriginalUserControl_2]
Arguments_2={input} -filter_complex "[0:v] fps=5,scale=320:-1:flags=lanczos,palettegen=stats_mode  =diff [p];[0:v][p] paletteuse" {output}.gif
ParameterLabel_2=GIF（320ピクセル、10FPS）
[OriginalUserControl_3]
Arguments_3={input} -ss 0:00:0 -t 00:00:07 {output}
ParameterLabel_3=10秒切り取り
[OriginalUserControl_4]
Arguments_4={input} -ss 0:00:00 -t 00:00:2 {output}
ParameterLabel_4=30秒-40秒まで
[OriginalUserControl_5]
Arguments_5={input} -vf "fps=15,scale=iw/2:ih/2" {output}.gif
ParameterLabel_5=GIFの画面サイズを1/2に
```



これをワーキングフォルダ（defaultだと実行ファイルと同じ場所のディレクトリ）に配置しておく
今回のPathは　```C:\TestCode\UserControl_Sample\UserControl_Sample\bin\Debug\net8.0-windows```


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145714/019e6b7a-ea34-45fa-a732-402739fd2bb7.png)



# Buttonを押すとiniを読み込む

とりあえずボタンを追加。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145714/22a19885-2cb0-43a1-bd6c-3752d94c7280.png)


```xml
<Window x:Class="UserControl_Sample.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:UserControl_Sample"
        mc:Ignorable="d"
        Loaded="Window_Loaded"
        Title="MainWindow" Height="450" Width="800">
    <Grid HorizontalAlignment="Left" VerticalAlignment="Top">
        <StackPanel Orientation="Horizontal">
            <Button Click="LoaddIniButton_Click"
            x:Name="LoaddIniButton" Width="100" Height="50" Content="Load Ini" />
            <StackPanel x:Name="mainPanel">   
               <!--ここにUserControlが追加されます。-->
            </StackPanel>
        </StackPanel>
    </Grid>
</Window>

```

実行時
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145714/54e68f16-31c5-40ba-bd24-572691b4bf45.png)



# iniを読み書きするクラス```IniDefinition```を追加

　※オリジナルではなくサイトからの流用です。検索しても出てこないので消滅してるらしい
 ※2 GPTを使って```CA1838```警告に対処している
```cs:IniCreate.cs

using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;

namespace HaruaConvert
{
    internal sealed class IniCreate
    {

        [DllImport("kernel32", CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Winapi)]
        internal static extern int WritePrivateProfileString(
          [MarshalAs(UnmanagedType.LPWStr), In] string lpAppName,
          [MarshalAs(UnmanagedType.LPWStr), In] string lpKeyName,
          [MarshalAs(UnmanagedType.LPWStr), In] string lpString,
          [MarshalAs(UnmanagedType.LPWStr), In] string lpFileName);
    }


    public static class IniDefinition
    {

        /// <summary>
        /// MarshalAs属性の削除: MarshalAs(UnmanagedType.LPWStr)は
        /// StringBuilderに適用されることが一般的ですが、char[]を
        /// 使用する場合は、この属性が原因で問題が発生することがあり
        /// ます。char[]の場合、CLRは自動的に適切なマーシャリングを行います。
        /// </summary>
        /// <param name="lpAppName"></param>
        /// <param name="lpKeyName"></param>
        /// <param name="lpDefault"></param>
        /// <param name="lpReturnString"></param>
        /// <param name="nSize"></param>
        /// <param name="iniFilename"></param>
        /// <returns></returns>
        [DllImport("kernel32", CharSet = CharSet.Unicode)]
        internal static extern uint GetPrivateProfileString(
    string lpAppName,
    string lpKeyName,
    string lpDefault,
    [Out] char[] lpReturnString,
    uint nSize,   //char[]のサイズをnSizeパラメータで正確に指定
    string iniFilename);


        /// <summary>
        /// INIファイルからキーの値を取得します
        /// <para>戻り値は, 取得が成功したかどうかを示します</para>
        /// </summary>
        /// <typeparam name="T">データ取得する型</typeparam>
        /// <param name="filePath">ファイルパス</param>
        /// <param name="sectionName">セクション名</param>
        /// <param name="keyName">キー名</param>
        /// <param name="defaultValue">初期値</param>
        /// <param name="outputValue">出力値</param>
        /// <returns>取得の成功有無</returns>
        public static bool TryGetValueOrDefault<T>(string filePath, string sectionName, string keyName, T defaultValue, out T outputValue)
        {



            // 出力値の初期化
            outputValue = defaultValue;

            // ファイルパスの有効性をチェック
            if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
            {
                return false;
            }

            // 読み取りバッファの準備
            char[] buffer = new char[1024];  // //CA1838 の解決

            // GetPrivateProfileStringを呼び出して設定値を読み取る
            uint readChars = GetPrivateProfileString(sectionName, keyName, null, buffer, (uint)buffer.Length, filePath);


            // 読み取りが成功したかどうかをチェック
            if (readChars == 0)
            {
                return false; // 読み取りに失敗
            }

            // null終端文字までの内容を文字列に変換
            string resultString = new string(buffer, 0, (int)readChars).TrimEnd('\0');

            // 空文字列のチェック
            if (string.IsNullOrEmpty(resultString))
            {
                return false;
            }

            // 型Tへの変換を試みる
            try
            {
                TypeConverter converter = TypeDescriptor.GetConverter(typeof(T));
                if (converter != null && converter.CanConvertFrom(typeof(string)))
                {
                    outputValue = (T)converter.ConvertFromString(resultString);  //CA1305 の解決

                    return true; // 変換に成功
                }
            }
            catch
            {
                // 変換に失敗した場合は、ここで処理される
            }

            return false; // 変換に失敗または変換器が見つからない
        }

        /// <summary>
        /// INIファイルからキーの値を取得します
        /// </summary>
        /// <typeparam name="T">データ取得する型</typeparam>
        /// <param name="filePath">ファイルパス</param>
        /// <param name="sectionName">セクション名</param>
        /// <param name="keyName">キー名</param>
        /// <param name="defaultValue">初期値</param>
        /// <returns>キー値</returns>
        public static T GetValueOrDefault<T>(string filePath, string sectionName, string keyName, T defaultValue)
        {
            T ret = defaultValue;
            TryGetValueOrDefault(filePath, sectionName, keyName, defaultValue, out ret);
           
            return ret;
        }

        /// <summary>
        /// INIファイルにデータを書き込みます
        /// </summary>
        /// <param name="filePath">ファイルパス</param>
        /// <param name="sectionName">セクション名</param>
        /// <param name="keyName">キー名</param>
        /// <param name="outputValue">出力値</param>
        public static void SetValue(string filePath, string sectionName, string keyName, string outputValue)
        {

            int result = IniCreate.WritePrivateProfileString(sectionName, keyName, outputValue, filePath);
            ;// CA1806の解決
            // WritePrivateProfileString が 0 を返した場合、操作は失敗しています。
            if (result == 0)
            {
                throw new IOException($"Failed to write to INI file. FilePath: {filePath}, SectionName: {sectionName}, KeyName: {keyName}");
            }
        }

    }
}

```

# 複数のUserControl1用のList型変数を追加


```cs
   public List<UserControl1> userControlList { get; set; }
```

Loadイベントに以下を加えます。（一回のみの実行を保証するには基本的にコンストラクタが良い）
```cs
private void Window_Loaded(object sender, RoutedEventArgs e)
{
    for (int Count = 0; Count < 10; Count++)
    {
        Thickness marthick = new Thickness(10, 10, 0, 0);

        var useC = new UserControl1()
        {
            Margin = marthick,
            FontSize = 14,
            Width = 480,

            Name = "ParamSelector" + $"{Count}"
            //名前と番号を振っておく
        };

        
        

        if (!mainPanel.Children.Contains(useC))
            mainPanel.Children.Add(useC);

        userControlList.Add(useC);
        //HashSetにUserControlを追加
        //HashSetなので重複チェックは省く

    }

```

# プロパティをセットするイベントを登録
今回はLoadイベントのみを追加します。
必要に応じてダブルクリックイベントなどを追加できます。


 
```csharp
 public void userControl_setPropaties(ParamSelector selector,MainWindow main) 
 {  
 　//
   selchild.Loaded += main.ParamSelect_Load;
 }
```
イベントの処理
```cs

 public void ParamSelect_Load(object sender, RoutedEventArgs e)
 {
     /////初回のみ呼ばれるようにする
     ///これはタブ切り替えの際に再度Loadされてしまうのでその対処
     firstSet = ParamSelector_SetText(sender, firstSet);
 }
```
以下　UserControにini情報をセットするメソッド
 ```cs
  bool ParamSelector_SetText(object sender, bool _firstSet)
 {

  int i = 0;
 if (_firstSet)
 {
     foreach (var selector in selectorList)
     {
         selector.ArgumentEditor.Text = IniDefinition.GetValueOrDefault
             (paramField.iniPath, ParamField.ControlField.ParamSelector + "_" + $"{i}", IniSettingsConst.Arguments_ + $"{i}",
             "");


         //selector.ArgumentEditor.Text);

         selector.ParamLabel.Text = IniDefinition.GetValueOrDefault(paramField.iniPath, ParamField.ControlField.ParamSelector + "_" + $"{i}",
         IniSettingsConst.ParameterLabel + "_" + $"{i}",
      "パラメータ名").Replace("\r\n", "", StringComparison.Ordinal);

         rcount = IniDefinition.GetValueOrDefault(paramField.iniPath, "CheckState", ParamField.ControlField.ParamSelector + "_Check", "0");
         int rcountInt = int.Parse(rcount, CultureInfo.CurrentCulture);




         i++;

         if (selector.Name == ParamField.ControlField.ParamSelector + rcount)
         {
             selector.SlectorRadio.IsChecked = true;
             paramField.usedOriginalArgument = selector.ArgumentEditor.Text;
         }
     }


     _firstSet = false;
     return _firstSet;
 }

 return _firstSet;

 }
 ```

# Load時呼び出し 
```cs
private bool firstSet { get; set; }
      public void OriginalUserControl_Load(object sender, RoutedEventArgs e)
      {
          /////初回のみ呼ばれるようにする
          ///これはタブ切り替えの際に再度Loadされてしまうのでその対処
          firstSet = userControl_setProperties(firstSet);
      }
```

# プロパティのセット用変数
```cs

        private bool userControl_setProperties(bool _firstSet)
        {
            int i = 0;
            if (!_firstSet)
            {


                foreach (var childControl in userControlList)
                {
                    childControl.ArgumentEditor.Text = IniDefinition.GetValueOrDefault
                        (IniData.iniPath, IniManagedData.ControlField.OriginalUserControl + "_" + $"{i}", IniSettingsConst.Arguments_ + $"{i}",
                        "");

                   
                    //selector.ArgumentEditor.Text);

                    childControl.ParamLabel.Text = IniDefinition.GetValueOrDefault(IniData.iniPath, IniManagedData.ControlField.OriginalUserControl + "_" + $"{i}",
                    IniSettingsConst.ParameterLabel + "_" + $"{i}",
                 "パラメータ名").Replace("\r\n","" , true ,CultureInfo.CurrentCulture);

                    string userCCount; //ユーザーコントロールの数

                    userCCount = IniDefinition.GetValueOrDefault(IniData.iniPath, "CheckState", IniManagedData.ControlField.OriginalUserControl + "_Check", "0");
                    int rcountInt = int.Parse(userCCount, CultureInfo.CurrentCulture);
                    //int.Parseでstring型をint型に変換




                    i++;

                    if (childControl.Name == IniManagedData.ControlField.OriginalUserControl + userCCount)
                    {
                        childControl.SlectorRadio.IsChecked = true;
                        IniData.usedOriginalArgument = childControl.ArgumentEditor.Text;
                    }
                }

                return _firstSet;
            }
            else
            {
                _firstSet = false;
            }

            return _firstSet;

        }

```


# データクラス
べた書きよりもこうした方が管理しやすいです。

```cs:IniSettingsConst.cs
   class IniSettingsConst
   {
       public const string Arguments_ = "Arguments_";
       public const string ParameterLabel = "ParameterLabel";
   }
```

```cs:IniManagedData.cs
 class IniManagedData
 {

     public string iniPath { get; set; } = Directory.GetCurrentDirectory() + "\\setting.ini";
     public string usedOriginalArgument { get; internal set; } =string.Empty;

     public static class ControlField
     {
         public static string OriginalUserControl { get; } = nameof(OriginalUserControl);
     }
 }
```

# パラメータのSave用メソッド
```cs
  public void ParamSave_Procedure()
  {

      var controlName = IniManagedData.ControlField.OriginalUserControl;
      int i = 0;

      //Add Number and Save setting.ini evey selector 
      foreach (var childCon in userControlList)
      {

          IniDefinition.SetValue(IniData.iniPath, controlName + "_" + $"{i}", "Arguments_" + $"{i}",
              childCon.ArgumentEditor.Text);

          IniDefinition.SetValue(IniData.iniPath, controlName + "_" + $"{i}", IniSettingsConst.ParameterLabel + "_" + $"{i}",
              childCon.ParamLabel.Text);

          i++;



          //if Check Selector Radio, Save Check State
          if (childCon.SlectorRadio.IsChecked is not null)
              if (childCon.SlectorRadio.IsChecked.Value)
              {
                  var radioCount = childCon.Name.Remove(0, controlName.Length);
                  IniDefinition.SetValue(IniData.iniPath, "CheckState", controlName + "_Check", radioCount);

              }
      }
```
Button押下時、もしくはアプリケーション終了時
```cs

        private void WriteIniButton_Click(object sender, RoutedEventArgs e)
        {
            ParamSave_Procedure();
            MessageBox.Show("iniに書き込みました");
        }
```


# 実行

　![bandicam 2025-03-30 18-04-39-569_Harua.mp4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145714/e7216a5f-a16e-4ed5-bb90-b072cbc30546.gif)

# あとがき
めんどい(T_T)。
当時はコロナ渦で暇だったんで数時間集中して自力で書きました。
今後いいねが付いたら、生成したコントロールに対してイベント登録までやってあげてもいいです（笑）
まあAIとか使わずに自力でやった方が勉強になると思うんで
やっても来月以降になるかなと