インフラジスティックス・ジャパン株式会社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 を使い、ユーザーがページ上の操作で選んだコンポーネントを実行時に動的に配置できるところまで、サンプルプログラムの作り込みを進めました。また、どのコンポーネントを表示するかの情報をブラウザのローカルストレージに永続化し、ページを再読み込み・再訪問しても、状態が維持されるところまで実装しました。

そしてこの後編では、いよいよ Ignite UI for Blazor のドックマネージャーを導入し、Visual Studio IDE のような高度なレイアウト機能と組み合わせて、最終目標のサンプルプログラムへと仕上げていきます。

サンプルプログラムの開発を続ける

ドックマネージャー用の支援機能を導入

Ignite UI for Blazor のドックマネージャーは、いくつかの機能の利用にあたっては、JavaScript 相互運用機能を介しての操作が必要です。今回のサンプルプログラムの開発を円滑に進められるよう、ドックマネージャーに対する JavaScript 呼び出しをラップした「ドックマネージャー支援サービスクラス」を用意しました。下記の 2 つのソースコードを、今回のサンプルプログラムのプロジェクトに追加します。本ブログ記事の冒頭でご案内した GitHub リポジトリから該当するコードを取得することもできます。

📂wwwroot > 📄dockmanager-helper.js

// 📂wwwroot > 📄dockmanager-helper.js
const getDockManagerInternal = (dockManagerContainerSelector) => {
  const dockManagerContainer = document.querySelector(dockManagerContainerSelector);
  const dockManager =
    // for v.22.2 or later
    dockManagerContainer?.querySelector("igc-dockmanager") ||
    // for v.22.1
    dockManagerContainer
      ?.querySelector("igc-component-renderer-container")
      ?.shadowRoot
      ?.querySelector("igc-dockmanager");
  return dockManager || null;
}

const getDockManager = async (dockManagerContainerSelector) => {

  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

  for (let i = 0; i < 500; i++) {
    const dockManager = getDockManagerInternal(dockManagerContainerSelector);
    if (dockManager !== null) return dockManager;
    await delay(10);
  }
  throw new Error(`Dock Manager could not found: selecter = "${dockManagerContainerSelector}"`);
}

export const attachContentPane = async (dockManagerContainerSelector, contentId, header) => {
  const dockManager = await getDockManager(dockManagerContainerSelector);
  const slot = document.createElement("slot");
  slot.name = contentId;
  slot.slot = contentId;
  dockManager.appendChild(slot)

  const newPane = {
    type: "contentPane",
    contentId: contentId,
    header: header
  };
  dockManager.layout.rootPane.panes.push(newPane);
  dockManager.layout = { ...dockManager.layout };
}

export const restoreLayout = async (dockManagerContainerSelector, layout) => {
  const dockManager = await getDockManager(dockManagerContainerSelector);
  dockManager.layout = JSON.parse(layout);
}

📂Services > 📄DockManagerHelper.cs

// 📂Services > 📄DockManagerHelper.cs
using Microsoft.JSInterop;

namespace {プロジェクトのルート名前空間}.Service;

public class DockManagerHelper : IAsyncDisposable
{
    private readonly IJSRuntime _JSRuntime;

    private IJSObjectReference? _JSModule;

    public DockManagerHelper(IJSRuntime jSRuntime)
    {
        this._JSRuntime = jSRuntime;
    }

    private async ValueTask<IJSObjectReference> GetHelperJsModuleAsync()
    {
        if (this._JSModule == null)
        {
            this._JSModule = await this._JSRuntime.InvokeAsync<IJSObjectReference>("import", "./dockmanager-helper.js");
        }
        return this._JSModule;
    }

    public async ValueTask RestoreLayoutAsync(string selector, string layoutInfo)
    {
        var module = await this.GetHelperJsModuleAsync();
        await module.InvokeVoidAsync("restoreLayout", selector, layoutInfo);
    }

    public async ValueTask AttachContentPaneAsync(string selector, string id, string headerText)
    {
        var module = await this.GetHelperJsModuleAsync();
        await module.InvokeVoidAsync("attachContentPane", selector, id, headerText);
    }

    public async ValueTask DisposeAsync()
    {
        if (this._JSModule != null) { await this._JSModule.DisposeAsync(); }
    }
}

こうして用意したドックマネージャー支援サービスクラスを、Program.cs にて DI 機構に登録しておきます。

// 📄Program.cs
using {プロジェクトのルート名前空間}.Services; // 👈 この名前空間を開き...
...
builder.Services.AddScoped<DockManagerHelper>(); // 👈 このサービス登録を追加
...

および、このドックマネージャー支援サービスクラスを App.razor 内で使いますので、@inject ディレクティブを追記して、DI 機構からプロパティに注入してもらうようにします。

*@ 📄App.razor @*
...
*@ 👇 この @inject を追加 @*
@inject IgbDockManagerDemo.Services.DockManagerHelper Helper
...

IgbDockManager で動的に表示するコンポーネントを囲む

いよいよ、App.razor 内にドックマネージャーコンポーネントを配置していきますが、その際に、ドックマネージャーの初期レイアウト情報を与える必要があります。そこで下記のように、App.razor のフィールド変数として、ドックマネージャー用のレイアウト情報オブジェクトを用意しておきます。

*@ 📄App.razor @*
...
@code {
  ...
  // 👇 このフィールド変数を追加
  private readonly IgbDockManagerLayout _InitialLayout = new() {
    RootPane = new IgbSplitPane {
      PaneType = DockManagerPaneType.SplitPane,
      Orientation = SplitPaneOrientation.Horizontal,
    }.WithPanes()
  };
  ...

(※なお、本ブログ記事作成時点では、上記ソースコードに対し BL005 の警告が発生される場合があります。このソースコードにおいては問題ありませんので、該当箇所の部分だけ BL005 の警告を無視するか #pragam 等で抑止してください。)

@foreach のループで表示コンポーネントを列挙して表示している箇所を、ドックマネージャーコンポーネント (<IgbDockManager>) で囲みます。
このとき、先に用意したレイアウト情報の初期値 _Layout フィールド変数を、ドックマネージャーのパラメータに渡します。
また、ドックマネージャー支援サービスクラスを介してこのドックマネージャーの DOM 要素を識別できるよう、id="dockmanager1" として HTML の id 属性を付与しておきます。
さらに、個々の表示コンポーネントを囲む <div> 要素には、slot 属性を書き加え、これに表示コンポーネントの ID を指定しておきます。ドックマネージャーが、この slot 属性に書かれた識別子をドックマネージャー内の個々のペインと関連付けすることで、ペイン内に各コンポーネントが表示されます。

*@ 📄App.razor @*
...
<!-- 👇 表示コンポーネントを foreach で列挙して表示する部分を、IgbDockManager で囲む -->
<IgbDockManager id="dockmanager1" Layout="this._InitialLayout" Width="800px" Height="400px">
  @foreach (var descriptor in this._Components)
  {
      var typeOfComponent = _ComponentNameToType[descriptor.ComponentName];

      //                       👇slot 属性を追加し、表示コンポーネントの ID を指定する
      <div @key="descriptor.Id" slot="@descriptor.Id">
          <DynamicComponent Type="typeOfComponent" Parameters="descriptor.Parameters" />
      </div>
  }
</IgbDockManager>
...

追加したコンポーネントをドックマネージャーに "取り付け" する

ここまでで、いったん、ブラウザの開発者ツールを開いてローカルストレージをクリアし、ページを再読み込みしてから、改めて "Add Counter" ボタンをクリックしてみます。すると残念ながら、まだドックマネージャー上には何の変化も現れません。ブラウザ上の DOM 構造には、たしかに "Counter" コンポーネントの DOM 要素が出現してはいます。しかし、その追加されたコンポーネントをドックマネージャーのペイン内で表示するよう、新規ペインの追加、および、先に書きましたとおり slot 属性を介したペインとの関連付けが行なわれていないためです。

これを行なうためには、ドックマネージャー支援サービスクラスの AttachContentPaneAsync() メソッドを呼び出して、「追加されたコンポーネントを、ドックマネージャーに "取り付ける" (新規ペインを追加し、slot 属性を介した関連付けを設定する)」処理が必要です。

具体的には、前編にて実装しました「表示するコンポーネントを追加する」メソッド、AddComponentAsync() の中に、この "取り付け" 処理を書くのが最適でしょう。取り付け先のドックマネージャーは、先にドックマネージャーの属性で指定した id="dockmanager1" を元に CSS セレクター形式で第1引数に指定します。追加した表示コンポーネントの slot 属性には、表示コンポーネントの Id を指定していますから、これを新規ペインに関連付けるよう第2引数で指定します。さらにドックマネージャーのペインには "ヘッダー文字列" というものがあり、新規ペインを追加する以上そのヘッダー文字列を何とするかの指定が必要なので、今回のサンプルプログラムではコンポーネント名をそのまま指定することとしました。

*@ 📄App.razor @*
...
@code {
  ...
  private async ValueTask AddComponentAsync(ComponentDescriptor descriptor)
  {
    ...
    // 👇 ドックマネージャーに追加した表示コンポーネントを取り付ける、この行を追加
    await this.Helper.AttachContentPaneAsync(
      "#dockmanager1", 
      descriptor.Id,
      descriptor.ComponentName);
    ...
  }
  ...

ここまでで実装したら、もういちどブラウザのローカルストレージをクリアしページを再読み込みし、"Add Counter" ボタンをクリックしてみると、今度は期待どおりドックマネージャーのペインとして "Counter" コンポーネントが追加され出現します! 同じように "Add TextMessage" ボタンも機能します。

ページ再読み込みに対応する

さてここまでの状態で、いくつかペインにコンポーネントを追加したりレイアウト変更したりしたあと、ページを再読み込みするとどうなるでしょうか。前編までの実装により、表示コンポーネントの状態はブラウザのローカルストレージに保存された JSON 文字列から復元されます。しかし、ドックマネージャーのペインについては、当然のことながらそのレイアウト状態の保存・復元について何も実装していませんので、復元されません。引き続き、ドックマネージャーのレイアウト状態の保存・復元を作り込んでいきます。

ドックマネージャーのレイアウト状態の保存先は、表示コンポーネントの情報と同じくブラウザのローカルストレージに保存することにします。

ドックマネージャーのレイアウト状態は、どのタイミングで、どのようにして保存すればよいでしょうか? 幸い、ドックマネージャーには 「レイアウトが変更されたとき」に発生するイベントが用意されています。この「レイアウトが変更されたとき」イベントを Blazor アプリケーション側でハンドルし、レイアウト状態をローカルストレージに保存すればよいです。ドックマネージャーのレイアウト状態は、JSON 文字列で扱えます。

ドックマネージャー (<IgbDockManager>) には標準で LayoutChanged パラメーターが用意されており、ドックマネージャー上でレイアウト変更が発生すると、このパラメーターに指定したコールバック関数が呼び出されるように実装できます。ところがあいにくと、今回のサンプルプログラムの用途ではこの LayoutChanged パラメーターは使い勝手がよくありません。そこで今回は、ドックマネージャーがレイアウト変更時に発射するイベントを、JavaScript ネイティブのイベントとして捕捉・ハンドルすることにします。

幸いこれは難しくありません。

まず、Blazor に対し、layoutChange というカスタムイベント (これが、ドックマネージャー上でのレイアウト変更時に発生する、JavaScript ネイティブイベントのイベント名です) と、そのイベント引数を Blazor 用に変換する関数を登録する、以下の JavaScript ファイル (wwwroot/dockmanager-events.js) を作成します。

// 📂wwwroot > 📄dockmanager-events.js
(() => {
  Blazor.registerCustomEventType("layoutchange", {
    browserEventName: 'layoutChange',
    createEventArgs: (e) => ({ layout: JSON.stringify(e.srcElement.layout) })
  });
})()

こうして作成した JavaScript ファイルを wwwroot/index.html ファイル内で読み込むようにします。

<!-- 📂wwwroot >📄index.html -->
    ...
    <script src="_framework/blazor.webassembly.js"></script>
    <!-- 👇 この script 要素を追加 -->
    <script src="dockmanager-events.js"></script>
</body>
</html>

続けて、この layoutchange JavaScript ネイティブのイベントを Blazor 側で捕捉する際の、イベント引数の C# クラスを定義します ( Types/LayoutChangeEventArgs .cs)。

// 📂Types > 📄LayoutChangeEventArgs.cs
namespace {プロジェクトのルート名前空間}.Types;

public class LayoutChangeEventArgs : EventArgs
{
    public string? Layout { get; set; }
}

最後に、この layoutChange JavaScript ネイティブイベントを Blazor 側で捕捉できるように、Blazor に登録を行なう C# クラスを作成します (Types/DockManagerEventHandlers.cs)。

// 📂Types > 📄DockManagerEventHandlers.cs
using Microsoft.AspNetCore.Components;

namespace {プロジェクトのルート名前空間}.Types;

[EventHandler("onlayoutchange", typeof(LayoutChangeEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
public static class EventHandlers
{
}

以上で、ドックマネージャーにてレイアウトが変更されたときに発生する JavaScript ネイティブのイベント layoutChange を、Blazor 側で @onlayoutchanged="..." の Razor 構文で捕捉することができるようになります。

あとは Blazor 側でこのイベントをハンドルし、そのハンドラメソッド内でドックマネージャーのレイアウト情報 (イベント引数に渡されます) をブラウザのローカルストレージに保存すればよいです。ローカルストレージに保存する際のキーは "layout" としました (下記)。

*@ 📄App.razor @*
  ...
  @* 👇 @onlayoutchange="..." を追加 *@
  <IgbDockManager ... @onlayoutchange="OnLayoutChange">
  ...

@code {
  ...
  // 👇 この OnLayoutChange メソッドを追加
  private async Task OnLayoutChange(LayoutChangeEventArgs args)
  {
    if (args.Layout == null) return;
    // 引数に渡されたレイアウト情報の JSON 文字列を、ローカルストレージに保存
    await this.LocalStorage.SetItemAsStringAsync("layout", args.Layout);
  }
  ...

次に必要なのは、こうしてローカルストレージに保存されたドックマネージャーのレイアウト情報を、ページの初回表示時に復元することです。ドックマネージャーのレイアウト状態の復元のために、ドックマネージャー支援サービスに RestoreLayoutAsync() メソッドを用意してあります。再び OnAfterRenderAsync() の実装箇所に戻り、ローカルストレージからレイアウト情報の取得とレイアウト復元の処理を書き足します。

*@ 📄App.razor @*
...
@code {
  ...
  protected override async Task OnAfterRenderAsync(bool firstRender)
  {
    if (firstRender)
    {
      ...
      this.StateHasChanged();

      // ローカルストレージに保存しておいたレイアウト情報から、レイアウトを復元
      var layout = await this.LocalStorage.GetItemAsStringAsync("layout");
      if (!string.IsNullOrEmpty(layout)) {
        await this.Helper.RestoreLayoutAsync("#dockmanager1", layout);
      }
      ...

ここまでできたら、もういちどブラウザのローカルストレージをクリアしページを再読み込みします。その上で "Add Counter" ボタンをクリックしたりペインのレイアウトを変更してみたりしたあと、再びページを再読み込みすると、今度はドックマネージャーのレイアウト状態も含めて状態が復元されるはずです。

ペインが閉じられたときに対応する

これでペインの追加やレイアウトの変更には対応できました。しかし実は、ペインを閉じたときの処理が不完全です。ペイン右上の [X] をクリックすると、一見、期待どおりペインが消えたように見えます。しかし思い出してください。"どのコンポーネントをページ上に表示するか" は、App.razor のフィールド変数 _Components に基づいて表示しているのでした。しかしこの段階では、プログラムのどこにも _Components から要素を削除する処理は存在しません。つまり、実は、閉じられたペインに取り付けられていたコンポーネントは、ペインが閉じられたことで表示する場所を失ってページ上は不可視となっているものの、ブラウザの DOM ツリーの中に存在し続けているのです (ブラウザの開発者ツールで要素を確認するとわかります)。

そこで、ドックマネージャーのペインが閉じられたら、そのペインに "取り付け" られていた表示コンポーネント (に対応するディスクリプタ) を _Components から削除する必要があります。ドックマネージャーには「ペインが閉じられるとき」に発生する JavaScript ネイティブのイベント paneClose が用意されています。そこで、先ほどのレイアウト変更時イベントの捕捉と同じように、ペインが閉じられるときのイベントを捕捉できるようにし、そのイベントハンドラ内で削除されたペインに取り付けていた表示コンポーネントのディスクリプタを _Components フィールド変数から削除すればよいでしょう。

実際に進めてみます。

まず、wwwroot/dockmanager-events.js に、paneClose カスタムイベントの Blazor への登録を書き足します。

// 📂wwwroot > 📄dockmanager-events.js
(() => {
  ...

  // 👇 ここから以下を書き足す
  Blazor.registerCustomEventType("paneclose", {
    browserEventName: 'paneClose',
    createEventArgs: (e) => ({ panes: e.detail.panes })
  });
})()

および、この paneClose JavaScript ネイティブのイベントを Blazor 側で捕捉する際の、イベント引数の C# クラスを定義します ( Types/PaneCloseEventArgs.cs)。

// 📂Types > 📄PaneCloseEventArgs.cs
using IgniteUI.Blazor.Controls;

namespace {プロジェクトのルート名前空間}.Types;

public class PaneCloseEventArgs : EventArgs
{
    public IgbContentPane[]? Panes { get; set; }
}

最後に、この paneClose JavaScript ネイティブイベントを Blazor 側で捕捉できるように、Blazor に登録を行なう C# クラス (Types/DockManagerEventHandlers.cs) に登録を書き足します。

// 📂Types > 📄DockManagerEventHandlers.cs
using Microsoft.AspNetCore.Components;

namespace {プロジェクトのルート名前空間}.Types;

// 👇 以下の1行を書き足す
[EventHandler("onpaneclose", typeof(PaneCloseEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
...
public static class EventHandlers
{
}

以上で、ドックマネージャーにてペインが閉じられるときに発生する JavaScript ネイティブのイベント paneClose を、Blazor 側で @onpaneclose="..." の Razor 構文で捕捉することができるようになります。

あとは Blazor 側でこのイベントをハンドルし、そのハンドラメソッド内で、閉じられるペインの ID (イベント引数に渡されます) から、該当する表示コンポーネントのディスクリプタを _Components フィールド変数から削除するように実装すればよいです (下記)。

*@ 📄App.razor @*
  ...
  @* 👇 @onpaneclose="..." を追加 *@
  <IgbDockManager ... @onpaneclose="OnPaneClose">
  ...

@code {
  ...
  // 👇 この OnPaneClose メソッドを追加
  private async Task OnPaneClose(PaneCloseEventArgs args)
  {
    // 引数に渡された閉じられたペインの ID 配列から、
    // 同じ ID を持つ表示コンポーネントのディスクリプタを _Components から除去
    var componentsToRemove = (args.Panes ?? Array.Empty<IgbContentPane>())
      .Select(pane => this._Components.Find(descriptor => descriptor.Id == pane.ContentId))
      .Where(descriptor => descriptor != null)
      .ToArray();
    foreach (var descriptor in componentsToRemove) {
      this._Components.Remove(descriptor!);
    }

    // フィールド変数 _Components が変更されるのでローカルストレージに保存
    await LocalStorage.SetItemAsync("components", this._Components);
  }
  ...

これで本当に完成です! ペインが閉じられたら、そのペインに取り付けられていた表示コンポーネントも正しく DOM ツリーから消え去ります。

まとめ

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

Web アプリケーションのユーザーインターフェースであっても、デスクトップアプリケーションに劣らない高度な自由レイアウトを提供する Blazor アプリケーションを作成し、高い作業効率と生産性をユーザーに提供できます。

Blazor ドックマネージャーの利用にあたって、Ignite UI for Blazor のライセンスをお持ちでない場合は、30 日間のサポート付きトライアル版にてお試しいただけます。 トライアル版のダウンロードは、下記リンク先のフォームに必要事項を記入頂くことで可能です。

🚀 Ignite UI for Blazor 無料トライアル版のダウンロード

以上、本記事が皆様のアプリケーション開発のお役に立てば幸いです。 Ignite UI for Blazor ad 202211

開発全般に関するご相談はお任せください!

インフラジスティックス・ジャパンでは、各プラットフォームの特別技術トレーニングの提供や、開発全般のご支援を行っています。

  • 「古い技術やサポート終了のプラットフォームから脱却する必要があるが、その移行先のプラットフォームやフレームワークの検討が進まない、知見がない」
  • 「新しい開発テクノロジーを採用したいが、自社内にエキスパートがいない。日本語リソースも少ないし、開発を進められるか不安」
  • 「自社のメンバーで開発を進めたいが、これまで開発フェーズを外部ベンダーに頼ってきたため、ツールや技術に対する理解が乏しい」
  • 「UIを刷新したい。UIデザインやUI/UXに関する検討の進め方が分からない。外部のデザイン会社に頼むと、開発が難しくなるのではないか、危惧している」

といったご相談を承っています。

お問い合わせはこちらから

お問い合わせフォームをご用意しております。ぜひお気軽にご連絡ください。

jp.infragistics.com