インフラジスティックス・ジャパン株式会社Blog

インフラジスティックス・ジャパン株式会社のチームメンバーが技術トレンド、製品Tips、サポート情報からライセンス、日々の業務から感じることなど、さまざまなトピックについてお伝えするBlogです。

ドックマネージャーによる高度なレイアウト機能を備えた、コンポーネントを動的に追加できる Blazor アプリケーションを作る (前編)

Ignite UI for Blazor のドックマネージャーを使うことで、ペインのドッキングやサイズ変更といった、Visual Studio IDE のような高度なレイアウト機能を Blazor アプリケーションに搭載することができます。このドックマネージャーと Blazor の DynamicComponent コンポーネントを組み合わせると、ユーザーが自分で自分の好きなように選んだコンポーネントをドックマネージャーのペインとして追加・レイアウト・削除することができる Blazor アプリケーションを構築できます。

このブログシリーズでは、実行時にユーザーが自分が選んだコンポーネントをドックマネージャー上に追加・レイアウト・削除できる Blazor アプリケーションをどのようにして実装するかを、前編・後編の 2 回にわけて解説します。本ブログ記事はその前編です。(後編はこちら)

作成するサンプルプログラムとソースコード

このブログシリーズでは、以下の 2 つのコンポーネントを用意し、

  1. "Increment" ボタンをクリックするごとにカウンター数値が加算される "Counter" コンポーネント
  2. "Message" パラメータに指定した文字列を h1 要素で表示する "TextMessage" コンポーネント

これら 2 種類のコンポーネントからユーザーが選択してドックマネージャーのペインに複数追加できる Blazor アプリケーションを作成する方法について解説します。サンプル Blazor アプリケーションの画面イメージは下図のとおりです。なお、このブログシリーズでは、このサンプルプログラムを Blazor WebAssembly で実装しましたが、もちろん、同等のアプリケーションを Blazor Server でも実装できます。

急ぎ結果を知りたい方のために、このサンプルプログラムのソースコード一式を、下記リンク先の GitHub リポジトリに公開してあります。またこの Git リポジトリに記録されているコミット履歴は、このブログシリーズで解説している手順ごとにコミットしているため、開発途中の手順・コードの差分を確認いただくこともできます。

github.com

また、実際に動作するライブデモンストレーションサイトを用意しました。下記リンク先から開いて動作を試して頂けます。ぜひアクセスしてみてください。

igjp-sample.github.io

前編の目標

今回の前編では、Blazor の DynamicComponent を使って、ページ上の操作によって、ユーザーが選んだコンポーネントを実行時に動的に配置できるようにするところまでを解説します。

実はこの前編では、まだドックマネージャーは登場しません。本格的にドックマネージャーを組み込む作業は後編での紹介となります。とはいえ、ドックマネージャーを使っての動的なレイアウトを実装する場合、この前編で紹介する、Blazor で実行時に動的にコンポーネントを生成する技法は重要な基礎となるかと思います。よろしくお付き合いください。

Blazor WebAssembly プロジェクトの準備

それでは早速に Visual Studio や .NET CLI (dotnet コマンド) を使って、Blazor WebAssembly プロジェクトを作成していきます。対象フレームワークは、本ブログ記事執筆時点での最新版、.NET 7 としますが、.NET 6 で作成しても違いはないです。

そうして新規に作成した Blazor WebAssembly プロジェクトですが、作成した時点で、既にいろいろとファイルや実装が組み込まれています。しかしそれらのファイルは、本ブログ記事で紹介したいドックマネージャーのサンプルプログラムとしては不要なものです。

そこで、ドックマネージャーのサンプルとして本質的な部分に集中できるようにするため、まず手始めに不要なファイルやコード行を削除して、極力簡潔なソースコードから始めることとします。

(補足: .NET 7 からは、"Blazor WebAssembly App Empty" という、ほぼ空の状態の Blazor WebAssembly プロジェクトを新規作成できるプロジェクトテンプレートが用意されています (日本語版の Visual Studio では「Blazor WebAssembly アプリが空です」という名前になっています)。この空のプロジェクトテンプレートを使うこともできますが、それでもなお、ルーティングや共通レイアウトの機能が組み込まれているため、以下で説明する不要コードの削除手順は適宜適用してください。)

なお、この "不要コードを削除する" 作業は手順が多いのですが、本質的には重要な作業ではありません。そのため、この「Blazor プロジェクトの準備」の節は、多少読み飛ばしても大丈夫でしょう。

それでは以下に不要部分の削除手順を記します。

まず手始めに wwwroot/css フォルダ内は app.css のみを残して他はすべて削除し、wwwroot/sample-data はフォルダごと削除します。

📂wwwroot
    📂css
        ❌📂bootstrap
        ❌📂open-iconic
    ❌📂sample-data

さらに wwwroot/index.html を編集し、css/bootstrap/bootstrap.min.css を参照している <link> 要素を削除しておきます。

<!-- 📄index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  ...
  <!-- 👇この link 要素の行を削除 -->
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
  ...

wwwroot/css/app.css を編集して、#blazor-error-ui {... より前の行はすべて削除し、

/* 📂wwwroot > 📂css > 📄app.css */

/* ここから上の行を削除。  */
#blazor-error-ui {
    background: lightyellow;
    ...

代わりに、新たに App.razor.css をプロジェクトに追加して、その内容として下記 CSS を記載しておきます。

/* 📄App.razor.css */
.container {
    margin-top: 12px;
    margin-bottom: 12px;
    display: flex;
    justify-content: center;
    flex-direction: column;
    align-items: center;
}

    .container [slot] {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100%;
    }

また、今回のサンプルでは、極力ソースコードを簡潔に保つためにルーティングも省略しますので、Shared および Pages フォルダも削除、

❌📂Pages
❌📂Shared

_Imports.razor 内の @using 節から {プロジェクトのルート名前空間}.Shared の行を削除し、

@* 📄_Imports.razor *@
...
<!-- 👇 下記 @using 行を削除 -->
❌@using {プロジェクトのルート名前空間}.Shared

App.razor ファイルの中身もいったん全削除しておきます。

@* 📄App.razor *@

<!-- いったん中身は全削除 -->

ひとまずここまでで、今回のサンプルの Blazor WebAssembly アプリケーションプロジェクトが準備完了です。このサンプル Blazor アプリケーションのページ表示は、App.razor 内を中心に実装していくことになります。

サンプルプログラムの開発を開始

Ignite UI for Blazor の組み込み

続けて、下記ドキュメントの手順に従って、Ignite UI for Blazor をこの Blazor WebAssembly プロジェクトに組み込んでおきます。

jp.infragistics.com

ユーザーが選択・追加するコンポーネントを実装

続けて "Counter" コンポーネントおよび "TextMessage" コンポーネントを、下記のとおり実装します。

@* 📂Components > 📄Counter.razor *@
<div>
    <p>Current Count: @_Count</p>
    <p><IgbButton Variant="ButtonVariant.Outlined" @onclick="OnClickIncrement">
        Increment
    </IgbButton></p>
</div>

@code {
    private int _Count = 0;

    private void OnClickIncrement() {
        _Count++;
    }
}
@* 📂Components > 📄TextMessage.razor *@
<h1>@this.Message</h1>

@code {
    [Parameter]
    public string Message { get; set; } = "";
}

これらコンポーネントは 📂Componets フォルダ内に作成しましたので、その名前空間が {プロジェクトのルート名前空間}.Components になります。名前空間を省略してこれらコンポーネントを参照できるよう 📄_Imports.razor ファイル内に @using {プロジェクトのルート名前空間}.Components の行を追加しておきます。

// 📄_Imports.razor
...
@using {プロジェクトのルート名前空間}.Components // 👈 この行を追加

"どのコンポーネントが画面に表示されているか" を示すデータ構造を作成

ここから、いよいよ大事な部分になってきます。

今回の Blazor アプリケーションでは、その実行時に表示するコンポーネントをユーザーが決めますので、"画面上にどのコンポーネントが表示されているか" を、.razor コンポーネントソース上に静的に直書きすることはできません。代わりに "画面上にどのコンポーネントが表示されているか" の情報をデータ構造としてメモリ上に保持し、その情報に従って画面を描画する必要があります。

そこでまずは、"画面上に表示されているコンポーネント" の 1 つ 1 つを表すレコードクラス (デスクリプタ) を、以下のとおり実装します (レコードクラス名は "ComponentDescriptor" としました)。

// 📄 ComponentDescriptor.cs
public record ComponentDescriptor(
    // 表示している個々のコンポーネントインスタンスを一意に識別する Id 文字列
    string Id,
    // 表示するコンポーネントの名前
    string ComponentName,
    // 表示するコンポーネントに引き渡すパラメータ
    Dictionary<string, object?> Parameters
);

また、上記レコードクラスにして示される ComponentName 文字列から、実際のコンポーネントの型 (Type) を表引きするための辞書を、App.razor コンポーネント内に実装しておきます。

@* 📄App.razor *@
@code {

  // 👇 この辞書を実装しておく
  private static readonly IReadOnlyDictionary<string, Type> _ComponentNameToType =
    new Dictionary<string, Type> {
      [nameof(Counter)] = typeof(Counter),
      [nameof(TextMessage)] = typeof(TextMessage),
    };
}

こうしてデータ型はできましたので、あとは App.razor 内にて、上記 ComponentDescriptor レコードクラスのリストを App コンポーネントのフィールド変数に保持するようにします。

@* 📄App.razor *@

@code {
    ...
    // 👇 このフィールド変数を追加
    private readonly List<ComponentDescriptor> _Components = new();
}

リストに保持された表示コンポーネントの情報から、実際に対応するコンポーネントを表示する

あとはこのリストに保持されている ComponentDescriptor オブジェクトを @foreach で列挙して、ComponentDescriptor で示される内容に従って対応するコンポーネントをページ上に表示できればよいこととなります。

しかしながら、「コンポーネントの型(クラス) (_ComponentNameToType フィールド変数で表引きされる) がわかったとして、それをどうやってページ上に表示するのか?」という問題が生じます。

ここで登場するのが Blazor に標準で備わっている DynamicComponent です。

DynamicComponent は、下記構文のように、「そのパラメータに渡された型のコンポーネントを表示する」というコンポーネントです。

<DynamicComponent Type="{ここに表示したいコンポーネントの型}" />

もちろん、Type パラメータに指定した型のコンポーネントに対してパラメータを渡すこともできます。詳しくは公式ドキュメントサイトもあわせて参照ください (下記リンク先)。

learn.microsoft.com

この DynamicComponent を使うことで、メモリ上に保持されたリストに基づいて、実行時に動的に指定されたコンポーネントを表示することができるようになります。具体的には、以下のとおり実装できます。

@* 📄App.razor *@

@foreach (var descriptor in this._Components)
{
  // デスクリプタで指定されたコンポーネント名から、対応するコンポーネントの型を表引き
  var typeOfComponent = _ComponentNameToType[descriptor.ComponentName];

  <div @key="descriptor.Id">
    <DynamicComponent Type="typeOfComponent"
                      Parameters="descriptor.Parameters" />
  </div>
}

なお、Blazor アプリケーションプログラミングにおいて、オブジェクトの繰り返しを描画する際は、@key 属性でオブジェクトと描画結果とを一意に関連付けることが大事ですから、忘れないようにしておきます。

"Counter" コンポーネントを表示に追加する処理を書く

ここまでの実装では、表示するコンポーネントを示すリスト _Components の中身が空 (new() で初期化されているだけ) ですから、実際にはまだページ上には何も表示されません。

続けて、表示するコンポーネントを追加する処理を実装していきます。まずは "Counter" コンポーネントを追加できるようにしてみます。とは言っても、本質的にやるべきことは、フィールド変数 _Components に、然るべき値で初期化した ComponentDescriptor オブジェクトを追加するだけです。先に実装したとおり、_Components に保持されている ComponentDescriptor オブジェクトを列挙してページ上に表示する仕掛けとなっているからです。

まずは App.razor に下記のようにボタンを配置し、

@* 📄App.razor *@
...
<IgbButton @onclick="OnClickAddCounter">
  Add Counter
</IgbButton>
...

およびこの "Add Counter" ボタンがクリックされたときのハンドラメソッドを以下のように、表示するコンポーネント名として "Counter" を設定した ComponentDescriptor オブジェクトをリストに追加するように実装します。Id は一意に区別できればよいので、Guid 値を使うことにします。および、"Counter" コンポーネントはとくにパラメータは持たないので空のパラメータ辞書を渡しておきます。

@* 📄App.razor *@
...
@code {
  ...
  private void OnClickAddCounter() {
    var id = Guid.NewGuid().ToString();
    this._Components.Add(new (id, nameof(Counter), new()));
  }
}
...

これで、ページ上に "Add Counter" が表示されるようになり、その "Add Counter" ボタンをクリックするごとに、"Counter" コンポーネントがページ上に追加されて表示されるようになりました。もちろん、複数の "Counter" コンポーネントがページ上に表示されていても、各 "Counter" コンポーネントそれぞれで状態を持っていますから、ちゃんと個別にカウンター数値が加算されます。

"TextMessage" コンポーネントを表示に追加する処理を書く

この調子で "TextMessage" コンポーネントを表示に追加する処理も実装していきます。要点は先の "Counter" コンポーネントと同じですが、"TextMessage" コンポーネントは、そのコンポーネント内に表示する文字列を Message パラメータで指定できます。そこで、"TextMessage" コンポーネント内に表示するテキストを入力・指定した上で、"TextMessage" コンポーネントを表示に追加するように作り込んでいきます。

表示するテキストを入力する入力フィールドと、追加用のボタンを App.razor に配置し (テキスト入力は即座にバインド先のフィールド変数に反映されるようにし、ボタンは、テキスト入力が空のときは押せないようにしておきます)、

@* 📄App.razor *@
...
<IgbInput @bind-Value="this._Message" 
          InputOcurred="@(e => this._Message = e.Detail)" />
<IgbButton @onclick="OnClickAddTextMessage" Disabled="@(this._Message == "")">
  Add TextMessage
</IgbButton>
...

テキスト入力欄への入力内容をバインドする先の string 型のフィールド変数、および、追加ボタンがクリックされたときのハンドラメソッドを以下のように実装します。"Counter" コンポーネントを追加するときとの違いは、コンポーネント名文字列が異なるほか、Message パラメータを辞書を介して渡していることです。

@code {
  ...
  // テキスト入力欄にバインドするフィールド変数
  private string _Message = "";

  private void OnClickAddTextMessage() {
    var id = Guid.NewGuid().ToString();
    this._Components.Add(new(id, nameof(TextMessage), new()
    {
      // ページ上の入力欄に入力されたテキストを、辞書の一項目として追加して渡す
      [nameof(TextMessage.Message)] = this._Message
    }));

    // 1つ TextMessage コンポーネントを追加したら、入力欄はクリアしておく
    this._Message = "";
  }
  ...
}

これで、ページ上の入力欄に入力したメッセージテキストを表示する "TextMessage" コンポーネントを追加できるようになりました。

ページを再読み込みしても初期状態に戻らないようにする

方針

さてところで、こうしてできあがった Blazor アプリケーションですが、"Counter" や "TextMessage" を追加したあと、ページを再読み込みすると、再びまっさらなページに戻ってしまいます。実世界におけるアプリケーションでは、ユーザーが選択した結果は次回のページ訪問時にも保持されているのが自然でしょう。そこでこのサンプルプログラムでも、ユーザーが選択して追加したコンポーネントを保持し、再読み込み時に復元するように作り込んでいきます。

より具体的には、"どのコンポーネントが画面に表示されているか" の情報、すなわち _Components リストの内容を何かしらの保存先に永続化しておき、ページの初期表示時に復元できればよいです。今回はサンプルということで、手軽に使える Web ブラウザのローカルストレージに保存することにします。(もちろん実用上のアプリケーションでは、API サーバー側のデータベースに保存などといった形態になることが想像されます)

Blazor アプリケーションからブラウザのローカルストレージを読み書きする

Blazor アプリケーションで、Web ブラウザのローカルストレージを読み書きするには、JavaScript との相互呼び出しが必要です。しかし幸い、オープンソースで提供されている下記 NuGet パッケージを利用することで、JavaScript を意識せずにローカルストレージを利用することができるようになります。

www.nuget.org

今回はこの "Blazored.LocalStorage" NuGet パッケージを利用することにします。"Blazored.LocalStorage" NuGet パッケージをプロジェクトに追加しましたら、Program.cs にて LocalStorage サービスを DI 機構に登録しておきます。

// 📄 Program.cs
using Blazored.LocalStorage; // 👈 名前空間を開く
...
builder.Services.AddIgniteUIBlazor();
builder.Services.AddBlazoredLocalStorage(); // 👈 これを追加
...

"どのコンポーネントが画面に表示されているか" をローカルストレージに保存する

次は App.razor で、_Components をローカルストレージに保存・復元する処理を実装します。まず、LocalStorage サービスを DI 機構からプロパティに注入してもらうよう、@inject ディレクティブを、App.razor の先頭に書き足します。

*@ 📄App.razor @*
@inject Blazored.LocalStorage.ILocalStorageService LocalStorage *@ 👈この行を追加 @*
...

_Components をローカルストレージに保存するべきタイミングは、表示するコンポーネント (のデスクリプタ) が追加されたときです。すなわち、"Add Counter" ボタンがクリックされたときと、"Add TextMessage" ボタンが追加されたとき、の2箇所になります。しかし2箇所に同じ処理を書くのも非効率ですので、「表示するコンポーネントを追加する」処理をひとつのメソッドにまとめ、そこでローカルストレージへの保存処理も実装することにします。メソッド名は AddComponentAsync、ローカルストレージに保存するときのキー名は "components" としてみました。

*@ 📄App.razor @*
...
@code {
  ...
  // 👇 このメソッドを追加
  private async ValueTask AddComponentAsync(ComponentDescriptor descriptor)
  {
    this._Components.Add(descriptor);

    // 表示コンポーネントの情報を、ブラウザのローカルストレージに JSON 形式で保存
    await this.LocalStorage.SetItemAsync("components", this._Components);
  }
}

"Add Counter" ボタンがクリックされたときと、"Add TextMessage" ボタンが追加されたときのハンドラメソッドでは、これまでは _Components リストに直接ディスクリプタを追加していましたが、これを少し書き換えて、上記で実装した AddComponentAsync メソッドを呼び出すようにします。なお、AddComponentAsync メソッドは、ローカルストレージへの書き込み処理が非同期処理である都合から、非同期メソッドになっていますので、"Add ~" ボタンクリック時のハンドラメソッドも非同期メソッドに変更しておきます。

*@ 📄App.razor @*
...
@code {
  ...
  private async Task OnClickAddCounter() // 👈 非同期メソッドに変更し...
  {
    ...
    // 👇 _Components.Add(...) を AddComponentAsync(...) に変更
    await this.AddComponentAsync(new(id, nameof(Counter), new()));
  }
  ...
  private async Task OnClickAddTextMessage() // 👈 非同期メソッドに変更し...
  {
    ...
    // 👇 _Components.Add(...) を AddComponentAsync(...) に変更
    await this.AddComponentAsync(new(id, nameof(TextMessage), new()
      { ... }));
    ...
  }
  ...

これで表示するコンポーネントを追加すると、同時に、その状態がブラウザのローカルストレージに保存されるようになりました。

"どのコンポーネントが画面に表示されているか" をローカルストレージから復元する

あとはこうしてローカルストレージに保存された情報を、ページの初回表示時に復元します。復元のタイミングとして最適なのは、Blazor コンポーネントのライフサイクルメソッドのひとつである OnAfterRenderAsync() メソッドです (※ OnInitializedAsync() メソッドだと、JavaScript 相互運用機能がまだ立ち上がっていないことがあり、その結果、ローカルストレージの利用ができない場合があります)。OnAfterRenderAsync() メソッドをオーバーライドし、もしもローカルストレージに "components" のキーで項目が見つかったら、_Components リストの内容をそこから復元できるように実装します。

*@ 📄App.razor @*
...
@code {
  ...
  // 👇 OnAfterRenderAsync メソッドをオーバーライド
  protected override async Task OnAfterRenderAsync(bool firstRender)
  {
    if (firstRender)
    {
      // ブラウザのローカルストレージに JSON 形式で保存しておいた表示コンポーネントの情報を、逆シリアル化して復元
      var components = await this.LocalStorage.GetItemAsync<ComponentDescriptor[]>("components");
      if (components != null) this._Components.AddRange(components);
      this.StateHasChanged();
    }
  }
  ...

これでページの初回訪問時や再読み込み時も、ユーザーが選んで追加したコンポーネントの状態が復元して表示されるようになります。

ローカルストレージからの復元における不具合対応

しかしながら、一点、まだ不具合があります。_Components の内容をローカルストレージに保存・復元するにあたっては、JSON 文字列への変換・逆変換が発生しています。その際、表示コンポーネントの情報を表している ComponentDescriptor レコードクラスのメンバーに Dictionary<string, object> オブジェクトが含まれていますが、この辞書オブジェクトの値の型が object という緩い型指定であるために、JSON シリアライザがこれをどのような具象型に当てはめて良いか判断が付かず、JSON 文字列からの復元 (逆変換、逆シリアル化) が期待どおりに行なわれないのです。幸い、下記 NuGet パッケージを利用することで、JSON 文字列を Dictionary<string, object> オブジェクトによしなに逆変換するよう実装できます。

www.nuget.org

上記 NuGet パッケージをプロジェクトに追加したら、ComponentDescriptor レコードクラスのソースコード中、属性指定を追記して逆変換の方法をカスタム指定することで解決です。

// 📄 ComponentDescriptor.cs
using Toolbelt.Text.Json.Serialization; // 👈 この名前空間を開く
...
public record ComponentDescriptor
(
  ...,
  [property: DictionaryStringObjectJsonConverter] // 👈 この属性指定を追加
  Dictionary<string, object?> Parameters
);

ここまで実装することで、ページを再読み込みしても、以前に追加した表示コンポーネントがそのときのまま表示されるようになりました。

なお、このブログシリーズの前編では、表示コンポーネントの削除処理は作り込みは省略します (後編、ドックマネージャーへの組み込み後に削除に対応します)。表示コンポーネントをリセットしたい場合は、Web ブラウザの開発者ツールを開いて、"アプリケーション" のタブに移動し、"ローカルストレージ" のセクションを開いて、保存されている項目を削除してから、ページを再読み込みしてください。

Ignite UI for Blazor ad 202211

まとめ

以上で、Blazor の DynamicComponent を使い、ユーザーが選んだコンポーネントを実行時に動的に配置できる Blazor アプリケーションを作ることができました。また、どのコンポーネントを表示するかの情報をブラウザのローカルストレージに永続化し、ページを再読み込み・再訪問しても、状態が維持されるところまでできました。

引き続きこのブログシリーズの後編では、いよいよ Ignite UI for Blazor のドックマネージャーを導入し、Visual Studio IDE のような高度なレイアウト機能と組み合わせていきます。

(後編はこちら)