C#定石 - ワーキングダイアログ（プログレスダイアログ）

## はじめに

C# ソフト開発時に、決まり事として実施していた内容を記載します。

::: note
「ワーキングダイアログ」は、「プログレスダイアログ」のことです。
本記事サンプルコードのクラス名から「ワーキングダイアログ」という呼称を利用しています。
:::


### 参考情報

下記情報を参考にさせて頂きました。

- [BackgroundWorkerを使った進捗表示画面の実装 #C# - Qiita](https://qiita.com/kent-u/items/bd5c9b3a7a155afd7dc0)
- [スレッド セーフなコントロールの呼び出し | C# プログラミング解説](https://so-zou.jp/software/tech/programming/c-sharp/thread/thread-safe-call.htm)


### 素材と画像加工

サイクル GIF として下記サイトを利用させて頂きました。

- [【フリーアイコン】 くるくる回る](https://sozai.cman.jp/icon/runtime/circle/)

GIF背景透過 加工サイトとして下記サイトを利用させて頂きました。

- [Ezgif - free online animated GIF editor](https://www.unscreen.com/)


### テスト環境

ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。
- Windows Forms - .NET Framework 4.8
- Windows Forms - .NET 8
- WPF - .NET Framework 4.8
- WPF - .NET 8

::: note
記載したソースコードは .NET 8 ベースとしています。
.NET Framework 4.8 の場合は、コメントで記載している null 許容参照型の明示 `?` を削除してください。
:::

::: note
Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。
:::


## ワーキングダイアログ

時間がかかる処理を行う際に、ワーキングダイアログを表示することがありました。
ワーキングダイアログは、サブ画面（Windows Form の場合は Form、WPF の場合は Window）、BackgroundWorker を利用した実装です。

![Chart.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/bc07367a-bb77-4624-aa97-97ce231cb4bc.png)


::: note
上記図は基本的なメカニズムを表現したものです。
今回のサンプルでは、BackgroundWorker を内包した WorkingDialogManager を用意します。
:::

::: note
BackgroundWorker.DoWork イベントハンドラ型 DoWorkEventHandler は下記の通りです。

```CSharp
public event System.ComponentModel.DoWorkEventHandler? DoWork;

public delegate void DoWorkEventHandler(object? sender, DoWorkEventArgs e);
```
:::

キャンセル動作は下記流れとなります。
- サブ画面キャンセルボタンで BackgroundWorker.CancelAsync 実行
- メイン画面処理で定期的に BackgroundWorker.CancellationPending で確認
  - キャンセル実施時には true となり、DoWorkEventArgs に値を設定して処理終了

ワーキングダイアログのレイアウトを決めます。
BackgroundWorker には、プログレスバーを考慮したメソッド/イベントがあります。
しかし、プログレスバーは更新タイミングに、モヤモヤすることがあるので、下記レイアウトで、STEP 実行内容を更新する形態を選択します。

![layout-original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/2caa1465-7dc4-4255-bf5c-455577567b86.png)

## 前準備

[【フリーアイコン】 くるくる回る](https://sozai.cman.jp/icon/runtime/circle/) で「処理中 丸２」を選択、「背景色 - 透明」にしてダウンロードします。
「背景色 - 透明」にしても、透過 GIF になってくれなかったので、[Ezgif - free online animated GIF editor](https://www.unscreen.com/) を利用して、「Upload Clip」で対象 GIF を選択、変換後「Download」でダウンロードして、Circle.gif にリネームします。

## マネージャ

複数の画面、複数の処理で、ワーキングダイアログを手軽に利用できるようにしたいと思ます。
今回は、ワーキングダイアログ操作をまとめたクラス（マネージャという位置づけ）を用意します。

［プロジェクト］［追加］［新しい項目］で、［クラス］を選択して、WorkingDialogManager.cs を作成します。

Windows Forms と WPF では、軽微な差異があるので、まず、Windows Forms のサンプルコードを記載して、次に WPF の差分を記載します。

### Windows Forms

```CSharp:WorkingDialogManager.cs
public class WorkingDialogManager
{
  public BackgroundWorker Worker { get; } = new BackgroundWorker();
  public bool IsCancelling { get { return Worker.CancellationPending; } }

  private WorkingDialog? Dialog = null;  // .NET Framework 時 ? 不要

  public WorkingDialogManager()
  {
    Worker.WorkerSupportsCancellation = true;
    Worker.WorkerReportsProgress = false;
  }
  // ワーキングダイアログ表示して処理実行
  public DialogResult ShowDialog(Form form, DoWorkEventHandler proc)
  {
    // ワーキングダイアログ表示中の処理登録
    Worker.DoWork += proc;

    // ワーキングダイアログ表示
    Dialog = new WorkingDialog(this);
    Dialog.Owner = form;
    var result = Dialog.ShowDialog();
    Dialog.Dispose();
    Dialog = null;

    // 再利用するので、登録したイベントハンドラ解除
    Worker.DoWork -= proc;

    // 必要に応じて下記処理実施
    // Application.DoEvents();  // メッセージキュー滞留を全て処理
    // form.Activate();         // アクティブ化

    return result;
  }
  // ワーキングダイアログ表示更新
  public void SetStepMessage(int count, int max, string msg)
  {
    SetStepMessage(string.Format($"STEP[{count}/{max}]\r\n{msg}"));
  }
  public void SetStepMessage(string msg)
  {
    Dialog?.SetStepMessage(msg);
  }
}
```

::: note
ダイアログ表示メッセージ更新は、BackgroundWorker で用意されてるプログレスバーを考慮したメソッド/イベントを利用することもできますが、SetStepMessage を作成しています。
:::

::: note
ワーキングダイアログ呼び出し元の操作については、複数の画面、複数の処理で手軽に利用という目的で、本クラスで全てラップしました。
ワーキングダイアログ側の固有操作は、とりあえず、本クラスの対象から除外しています。必要に応じて、本クラスに追加してください。
:::

### WPF
WPF では、下記差異があるので、ShowDialog を書き換えます。
- 画面は Form ではなく Window 
- モーダル表示の戻り値が DialogResult ではなく bool?

```CSharp:WorkingDialogManager.cs
public bool? ShowDialog(Window window, DoWorkEventHandler proc)
{
  // ワーキングダイアログ表示中の処理登録
  Worker.DoWork += proc;

  // ワーキングダイアログ表示
  Dialog = new WorkingDialog(this);
  Dialog.Owner = window;
  var result = Dialog.ShowDialog();

  // 再利用するので、登録したイベントハンドラ解除
  Worker.DoWork -= proc;

  //// 必要に応じて下記処理実施 - DispatcherPriority は要確認
  //Application.Current.Dispatcher.Invoke(
  //    System.Windows.Threading.DispatcherPriority.Background,
  //    new Action(delegate { }));  // UIスレッドキューを全て処理
  //window.Activate();              // アクティブ化

  return result;
}
```

::: note
WPF でワーキングダイアログ利用は今までなく、今回 WPF 化をしました。
コメントアウトして記載している Dispatcher 処理は、Copilot に Application.DoEvents 代替えを確認した回答に基づいたコードで、マニュアル、および、コメントを外して動作させて問題ないことのみを確認しています。
このため、実際に利用する場合には、適切な処理に書き換えてください。
:::


## サブ画面
Windows Forms と WPF では、大幅な差異があるので、Windows Forms と WPF のサンプルそれぞれを記載します。

### Windows Forms

［プロジェクト］［追加］［新しい項目］で、［Form（Windows フォーム）］を選択して、WorkingDialog.cs を作成します。
WorkingDialog.cs デザイナ画面で、下記レイアウトを作成してください。

![layout-WinForm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/9556377c-10da-4898-979b-d38683ad5543.png)


デザイナ画面では、位置/サイズなどを設定してください。
画面横幅は、表示する文字列の最大幅を考慮して設定することを推奨します。
後述ソースコードで「AutoSizeMode.GrowOnly」「FormStartPosition.CenterParent」としていますが、途中で画面横幅が大きくなっても、画面左上位置はそのままで、自動的にセンタリングはされないためです。

PictureBox pbWorking には下記プロパティをセットしてください。

| プロパティ | 設定値                                                           |
| --------- | ---------------------------------------------------------------- |
| SizeMode  | StretchImage                                                     |
| Image     | ローカルリソース インポートを選択、前準備で用意した Circle.gif を設定 |

サンプルコードを記載します。

```CSharp
public partial class WorkingDialog : Form
{
  private WorkingDialogManager? wdManager = null;     // .NET Framework 時 ? 不要

  public WorkingDialog(WorkingDialogManager manager)
  {
    InitializeComponent();

    // クラス変数に保持
    wdManager = manager;

    // Formプロパティとイベントハンドラ
    AutoSize = true;
    AutoSizeMode = AutoSizeMode.GrowOnly;
    ControlBox = false;
    FormBorderStyle = FormBorderStyle.FixedDialog;
    MaximizeBox = false;
    ShowIcon = false;
    ShowInTaskbar = false;
    StartPosition = FormStartPosition.CenterParent;
    Text = "処理中";
    Shown += WorkingDialog_Shown;
    FormClosed += WorkingDialog_FormClosed;

    // 配置コントロール設定
    SetStepMessage("初期化中...");
    if (!wdManager.Worker.WorkerSupportsCancellation)
    {
      btnCancel.Enabled = false;
    }
    else
    {
      btnCancel.Click += btnCancel_Click;
    }
    // BackgroundWorker イベントハンドラ登録
    wdManager.Worker.RunWorkerCompleted += BackgroundWorker_RunWorkerCompleted;
  }

  // .NET Framework 時 object? の ? 不要
  private void WorkingDialog_Shown(object? sender, EventArgs e)
  {
    // 処理中でないことを確認
    if (wdManager?.Worker.IsBusy == false)
    {
      wdManager.Worker.RunWorkerAsync();
    }
  }

  // .NET Framework 時 object? の ? 不要
  private void WorkingDialog_FormClosed(object? sender, FormClosedEventArgs e)
  {
    // wdManager.Worker 再利用するので、登録したイベントハンドラ解除
    if (wdManager?.Worker != null)
    {
      wdManager.Worker.RunWorkerCompleted -= BackgroundWorker_RunWorkerCompleted;
    }
  }

  // .NET Framework 時 object? の ? 不要
  private void btnCancel_Click(object? sender, EventArgs e)
  {
    btnCancel.Enabled = false;
    // 処理中確認
    if (wdManager?.Worker.IsBusy == true)
    {
      // 処理をキャンセル
      wdManager.Worker.CancelAsync();
    }
  }

  // .NET Framework 時 object? の ? 不要
  private void BackgroundWorker_RunWorkerCompleted(object? sender,
                                                   RunWorkerCompletedEventArgs e)
  {
    if ((e.Error is null) == false)
    {
      // エラー時の処理 - TODO

      DialogResult = DialogResult.Abort;
    }
    else if (e.Cancelled)
    {
      // キャンセル時の処理
      DialogResult = DialogResult.Cancel;
    }
    else
    {
      // 完了時の処理
      DialogResult = DialogResult.OK;
    }
    // ダイアログを閉じる
    this.Close();
  }
  // ワーキングダイアログ表示更新
  public void SetStepMessage(string msg)
  {
    MethodInvoker method = () => { lblStepMessage.Text = msg; };

    // メインスレッド以外で、コントロール更新は Invoke 利用
    if (this.InvokeRequired)
    {
      Invoke(method);
    }
    else
    {
      method();
    }
  }
}
```

::: note
SetStepMessage での Invoke 処理、[スレッド セーフなコントロールの呼び出し | C# プログラミング解説](https://so-zou.jp/software/tech/programming/c-sharp/thread/thread-safe-call.htm) を参考にさせて頂きました。
このようにシンプルな記述にできることは知りませんでした。
:::

### WPF

WPF - Image コントロールの標準機能では、アニメーション GIF がサポートされていないため、[NuGet Gallery | WpfAnimatedGif](https://www.nuget.org/packages/WpfAnimatedGif) を導入します。

```shell-session
PM> Install-Package WpfAnimatedGif
```

プロジェクトに Images フォルダを作成して、前準備で用意した Circle.gif を Images フォルダ下にコピーします。（.NET Framework 4.8 の場合、自動的にプロジェクトに追加されないようなので、プロジェクトに追加してください）
ソリューションエクスプローラで、コピーした Circle.gif を「ビルドアクション - リソース」とします。（.NET Framework 4.8 の場合、Resource と英文表記となっています）

［プロジェクト］［追加］［新しい項目］で、［ウィンドウ（WPF）］を選択して、WorkingDialog.xaml を作成します。
xaml サンプルコードを記載します。

```xml:WorkingDialog.xaml
<Window x:Class="YourAppName.WorkingDialog"
        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:gif="http://wpfanimatedgif.codeplex.com"
        xmlns:local="clr-namespace:YourAppName"
        mc:Ignorable="d"
        Title="WorkingDialog" Height="140" Width="300" >
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="48"/>
            <RowDefinition Height="30"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="48"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="80"/>
        </Grid.ColumnDefinitions>
        <Image Name="imgCircle" Grid.Row="0" Grid.Column="0"
            gif:ImageBehavior.AnimatedSource="pack://application:,,,/Images/Circle.gif"
            Stretch="Uniform"/>
        <Label Name="lblStepMessage" Grid.Row="0" Grid.Column="1" Grid.ColumnSpan="2"
            Content="初期化中..." VerticalAlignment="Center" Margin="10,0,0,0" />
        <Button Name="btnCancel" Grid.Row="1" Grid.Column="3"
            Content="キャンセル" Margin="0,10,0,0" />
    </Grid>
</Window>
```

![layout-WPF.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/73ffcf17-5afd-45a2-9d1c-af7d86add667.png)


::: note
WpfAnimatedGif 用に `xmlns:gif="http://wpfanimatedgif.codeplex.com"` を追記しています。

`x:Class="YourAppName.WorkingDialog"` および `xmlns:local="clr-namespace:YourAppName"` に記述されている `YourAppName` は作成したプロジェクトの namespece に変更してください。

かなり雑なレイアウトで申し訳ありません。
Window の Height/Width、および、各コントールのレイアウトは適宜変更してください。
:::

C# サンプルコードを記載します。

```CSharp:WorkingDialog.xaml.cs
public partial class WorkingDialog : Window
{
  private WorkingDialogManager? wdManager = null;     // .NET Framework 時 ? 不要

  public WorkingDialog(WorkingDialogManager manager)
  {
    InitializeComponent();

    // クラス変数に保持
    wdManager = manager;

    // GIF イメージ設定
    var image = new BitmapImage(new Uri("pack://application:,,,/Images/Circle.gif"));
    ImageBehavior.SetAnimatedSource(imgCircle, image);

    // Windowプロパティとイベントハンドラ
    ResizeMode = ResizeMode.NoResize;
    ShowInTaskbar = false;
    SizeToContent = SizeToContent.Manual;
    Title = "処理中";
    WindowStartupLocation = WindowStartupLocation.CenterOwner;
    ContentRendered += Window_ContentRendered;
    Closed += Window_Closed;

    // 配置コントロール設定
    SetStepMessage("初期化中...");
    if (!wdManager.Worker.WorkerSupportsCancellation)
    {
      btnCancel.IsEnabled = false;
    }
    else
    {
      btnCancel.Click += btnCancel_Click;
    }
    // BackgroundWorker イベントハンドラ登録
    wdManager.Worker.RunWorkerCompleted += BackgroundWorker_RunWorkerCompleted;
  }

  protected override void OnSourceInitialized(EventArgs e)
  {
    base.OnSourceInitialized(e);

    // Windows Fomrs での ControlBox=false 相当 - 最小化、最大化、クローズボタン非表示
    IntPtr handle = new WindowInteropHelper(this).Handle;
    long style = (long)NativeMethods.GetWindowLong(handle, NativeMethods.GWL_STYLE);
    NativeMethods.SetWindowLong(handle, NativeMethods.GWL_STYLE, 
                                style & ~NativeMethods.WS_SYSMENU);
  }
  private static class NativeMethods
  { 
    [DllImport("user32.dll")]
    public static extern IntPtr SetWindowLong(IntPtr hWnd, int nIndex, long dwLong);

    [DllImport("user32.dll")]
    public static extern IntPtr GetWindowLong(IntPtr hWnd, int nIndex);

    public const int GWL_STYLE = (-16);
    public const int WS_SYSMENU = 0x00080000;
  }

  // .NET Framework 時 object? の ? 不要
  private void Window_ContentRendered(object? sender, EventArgs e)
  {
    // 処理中でないことを確認
    if (wdManager?.Worker.IsBusy == false)
    {
      wdManager.Worker.RunWorkerAsync();
    }
  }
  // .NET Framework 時 object? の ? 不要
  private void Window_Closed(object? sender, EventArgs e)
  {
    // wdManager.Worker 再利用するので、登録したイベントハンドラ解除
    if (wdManager?.Worker != null)
    {
      wdManager.Worker.RunWorkerCompleted -= BackgroundWorker_RunWorkerCompleted;
    }
  }
  // .NET Framework 時 object? の ? 不要
  private void btnCancel_Click(object? sender, RoutedEventArgs e)
  {
    btnCancel.IsEnabled = false;
    // 処理中確認
    if (wdManager?.Worker.IsBusy == true)
    {
      // 処理をキャンセル
      wdManager.Worker.CancelAsync();
    }
  }

  // .NET Framework 時 object? の ? 不要
  private void BackgroundWorker_RunWorkerCompleted(object? sender,
                                                   RunWorkerCompletedEventArgs e)
  {
    if ((e.Error is null) == false)
    {
      // エラー時の処理 - TODO

      DialogResult = false;
    }
    else if (e.Cancelled)
    {
      // キャンセル時の処理
      DialogResult = false;
    }
    else
    {
      // 完了時の処理
      DialogResult = true;
    }
    // ダイアログを閉じる
    this.Close();
  }
  // ワーキングダイアログ表示更新
  public void SetStepMessage(string msg)
  {
    // 本来は MVVM とすべきですが、サンプルなので簡易的に下記としています
    Dispatcher.Invoke((Action)(() => { lblStepMessage.Content = msg; }));
  }
}
```

## 呼び出し元

Windows Forms と WPF では、軽微な差異があるので、まず、Windows Forms のサンプルコードを記載して、次に WPF の差分を記載します。

### Windows Forms

WorkingDialogManager をクラス変数として作成します。

```CSharp
// WorkingDialog 操作クラス
private readonly WorkingDialogManager wdManager = new WorkingDialogManager();
```

WorkingDialog 利用は、WorkingDialogManager 経由で下記手順となります。

```CSharp:WorkingDialogManager利用方法
// ワーキングダイアログ表示して処理実行
DialogResult result = wdManager.ShowDialog(this, DoWorkSample10);

//// wdManager 再利用テストの待機
// Thread.Sleep(500);
//
//// wdManager 再利用テスト
// result = wdManager.ShowDialog(this, DoWorkSample15);
```

```CSharp:テスト用処理
// .NET Framework 時 object? の ? 不要
private void DoWorkSample10(object? sender, DoWorkEventArgs e)
{
  DoWorkSample(10, "〇〇〇処理中...", e);
}
// .NET Framework 時 object? の ? 不要
private void DoWorkSample15(object? sender, DoWorkEventArgs e)
{
  DoWorkSample(15, "△△△処理中...", e);
}
private void DoWorkSample(int max, string msg, DoWorkEventArgs e)
{
  for (int count = 1; count <= max; count++)
  {
    // ワーキングダイアログ表示更新
    wdManager.SetStepMessage(count, max, msg);
    // ワーキングダイアログでのキャンセル確認
    if (wdManager.IsCancelling)
    {
      e.Cancel = true;
      return;
    }

    // 実際の処理代替で待機
    Thread.Sleep(500);
  }
}
```

::: note
WorkingDialogManager.ShowDialog 第２引数で指定した処理では、クラス変数の参照/更新、コントロール参照が可能です。
コントロール更新は、WorkingDialog.SetStepMessage と同様な形態で、Invoke が必要となります。
:::

### WPF
WPF では、下記差異があるので、`WorkingDialogManager利用方法` を書き換えます。
- モーダル表示の戻り値が DialogResult ではなく bool?


```CSharp:WorkingDialogManager利用方法
// ワーキングダイアログ表示して処理実行
bool? result = wdManager.ShowDialog(this, DoWorkSample10);

//// wdManager 再利用テストの待機
// Thread.Sleep(500);
//
//// wdManager 再利用テスト
// result = wdManager.ShowDialog(this, DoWorkSample15);
```

## 補足

本実装では、WorkingDialogManager から、WorkingDialog を呼び出す際に、引数として WorkingDialogManager を渡しています。
エラー情報などは、WorkingDialogManager にプロパティを追加することで、相互に情報伝達させることも可能です。

```CSharp
public int ErrorCode { get; set; } = 0;
public string ErrorMessage { get; set; } = string.Empty;
```

::: note
WorkingDialogManager を再利用するケースを考慮して、初期化のタイミングを忘れずに入れてください。
必要であれば、エラー情報初期化のメソッドを追加してください。
:::