BlazorでTreeコンポーネントを使ってみる

Blazor WebAssemblyでTreeコントロールを利用したいと思いググったところ以下のようなコンポーネントを見つけました。

github.com

「Tree / Tag Selector / Page Panel」の3つのコンポーネントが提供されていますが、そのうちTreeについて試してみたいと思います。

1. こんなの作ります

Corporation→Teamの階層構造をTree表示します。
f:id:daigo-knowlbo:20200606172614p:plain

選択した項目名が表示されます。
f:id:daigo-knowlbo:20200606172732p:plain

選択したTeamを上下に移動可能です。
f:id:daigo-knowlbo:20200606172948p:plain

2. 実装手順

2.1. プロジェクト作成

Visual Studioで Blazor WebAssembly App プロジェクトを作成します。

f:id:daigo-knowlbo:20200606171045p:plain

プロジェクト名は「UseMwBlazorTree」としました。

※「dotnet new blazorwasm -o UseMwBlazorTree」でも同様。

2.2. nugetで「MW.Blazor.Tree」を追加

「Nuget パッケージの管理」で「MW.Blazor.Tree」パッケージを追加します。

www.nuget.org

2.3. Teamモデルクラスを追加

ツリーの要素となるモデルクラスを作成します。
ここでは「Teamクラス」としました。
プロジェクトルート配下に Models フォルダを作成し、その下に Team.cs を追加します。

f:id:daigo-knowlbo:20200606171441p:plain

// Models/Team.cs

using System.Collections.Generic;

namespace UseMwBlazorTree.Models
{
  /// <summary>
  /// チームクラス
  /// </summary>
  public class Team
  {
    /// <summary>
    /// インデックス(並び順)を取得または設定します。
    /// </summary>
    public int Index { get; set; }
    
    /// <summary>
    /// 名称を取得または設定します。
    /// </summary>
    public string Name { get; set; }
    
    /// <summary>
    /// 親チームを取得または設定します。
    /// </summary>
    public Team Parent { get; set; }
    
    /// <summary>
    /// 子チームリストを取得または設定します。
    /// </summary>
    public IList<Team> Children { get; } = new List<Team>();

    /// <summary>
    /// 子チームを追加します。
    /// </summary>
    /// <param name="name">チーム名</param>
    /// <returns>追加したTeamオブジェクト</returns>
    public Team AddChild(string name)
    {
      Team team = new Team() { Name = name, Index = this.Children.Count, Parent = this };
      this.Children.Add(team);
      return team;
    }
  }
}

2.4. MW.Blazor.Tree用 css(js)参照の追加

wwwroot/index.html に「MW.Blazor.Treeのcss」および「Treeが利用しているfontawesome css」への StyleSheet Linkを追加します。

// wwwroot/Index.html

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
  <title>UseMwBlazorTree</title>
  <base href="/" />
  <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
  <link href="css/app.css" rel="stylesheet" />

  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.13.0/css/all.css" />
  <link rel="stylesheet" href="_content/MW.Blazor.Tree/styles.css" />
</head>

<body>
  <app>Loading...</app>

  <div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
  </div>
  <script src="_framework/blazor.webassembly.js"></script>
</body>

</html>

※fontawesomeのcssCDN提供が無くなるんでしたっけ?v6をfreeライセンス登録してjsとして参照利用する場合は、以下のような置き換えになります。

<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.13.0/css/all.css" />
↓↓↓
<script src="https://kit.fontawesome.com/xxxxx.js" crossorigin="anonymous"></script>

2.5. Treeを配置した Pages/Index.razor を実装

Index.razor全体の実装は以下の通りです。

// Pages/Index.razor

@page "/"
@using UseMwBlazorTree.Models
@using MW.Blazor
@using System.Linq

<h1>
  MW.Blazor.Treeの利用サンプル
</h1>

<Tree Nodes="Teams"
    @bind-ExpandedNodes="ExpandedNodes"
    @bind-SelectedNode="SelectedItem"
    HasChildNodes="@(item => item.Children?.Any() == true)"
    ChildSelector="@(item => item.Children.OrderBy(i => i.Index))">
  <TitleTemplate>@context.Name</TitleTemplate>
</Tree>
<br />
<p>
  選択中:@SelectedItem?.Name
</p>

<button class="btn btn-primary" @onclick="OnUpTeam">上に移動</button>
<button class="btn btn-primary" @onclick="OnDownTeam">下に移動</button>

@code
{
  public List<Team> Teams { get; set; } = new List<Team>();
  public Team SelectedItem { get; set; }
  public IList<Team> ExpandedNodes { get; set; } = new List<Team>();

  protected override void OnInitialized()
  {
    // ツリーに表示するTeam要素を作成
    //  ルート要素
    Team org = new Team() { Name = "Sample Corp" };
    //  ルートの1つ目の子要素
    Team team = org.AddChild("Team1");
    team.AddChild("Team1-1");
    team.AddChild("Team1-2");
    //  ルートの2つ目の子要素
    Team team2 = org.AddChild("Team2");
    team2.AddChild("Team2-1");
    team2.AddChild("Team2-2");
    team2.AddChild("Team2-3");
    team2.AddChild("Team2-4");

    // Treeコンポーネントにバインドするルート要素を追加
    this.Teams.Add(org);

    // 初期表示で開いておくTeam要素を指定
    this.ExpandedNodes.Add(org);
    this.ExpandedNodes.Add(team2);

    base.OnInitialized();
  }

  /// <summary>
  /// Team要素を上に移動
  /// </summary>
  protected void OnUpTeam()
  {
    if (this.SelectedItem == null || this.SelectedItem.Parent == null)
      return;

    if (this.SelectedItem.Index > 0)
    {
      var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index - 1);
      target.Index++;
      this.SelectedItem.Index--;
    }
  }

  /// <summary>
  /// Team要素を下に移動
  /// </summary>
  protected void OnDownTeam()
  {
    if (this.SelectedItem == null || this.SelectedItem.Parent == null)
      return;

    if (this.SelectedItem.Index < this.SelectedItem.Parent.Children.Count - 1)
    {
      var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index + 1);
      target.Index--;
      this.SelectedItem.Index++;
    }
  }
}

各実装要素について以下に補足説明を記述します。

using追加

モデルクラス・Treeを使うためのMW.Blazor・Linqへのusingを追加しています。

@using UseMwBlazorTree.Models
@using MW.Blazor
@using System.Linq

Treeeコンポーネント定義

<tree>はusing宣言しているため、つまり「MW.Blazor.Treeコンポーネント」としての宣言となります。

<Tree Nodes="Teams"
      @bind-ExpandedNodes="ExpandedNodes"
      @bind-SelectedNode="SelectedItem"
      HasChildNodes="@(item => item.Children?.Any() == true)"
      ChildSelector="@(item => item.Children.OrderBy(i => i.Index))">
    <TitleTemplate>@context.Name</TitleTemplate>
</Tree>

@codeセクションでの実装と関連しますが、「@bind-ExpandedNodes」は初期状態でツリーの子要素が開いている項目をデータバインドで指定しています。
@bind-SelectedNodeは、選択中の項目をデータバインドしています。
HasChildNodesは、ツリーノード表示時に「+」アイコンを表示する・しないの判定に利用されます。
ChildSelectorは、対象ノードの子要素のセレクタを指定します。今回は並び順を要素(Team)のIndexプロパティの値で表し、Index値の大小により並び順を制御するため、「item.Children.OrderBy(i => i.Index)」という宣言を行っています。
TitleTemplateは、要素の表示テンプレートの宣言になります。ここでは単純に「Nameプロパティ」を表示するテンプレート宣言を行っています。

OnInitialized()でデータ初期化

<tree>にバインドするデータの初期化を行っています。

    protected override void OnInitialized()
    {
        // ツリーに表示するTeam要素を作成
        //  ルート要素
        Team org = new Team() { Name = "Sample Corp" };
        //  ルートの1つ目の子要素
        Team team = org.AddChild("Team1");
        team.AddChild("Team1-1");
        team.AddChild("Team1-2");
        //  ルートの2つ目の子要素
        Team team2 = org.AddChild("Team2");
        team2.AddChild("Team2-1");
        team2.AddChild("Team2-2");
        team2.AddChild("Team2-3");
        team2.AddChild("Team2-4");

        // Treeコンポーネントにバインドするルート要素を追加
        this.Teams.Add(org);

        // 初期表示で開いておくTeam要素を指定
        this.ExpandedNodes.Add(org);
        this.ExpandedNodes.Add(team2);

        base.OnInitialized();
    }

Team要素の上下移動

選択した要素を上下に移動するためのボタンとそのイベントハンドラ定義を行っています。

<button class="btn btn-primary" @onclick="OnUpTeam">上に移動</button>
<button class="btn btn-primary" @onclick="OnDownTeam">下に移動</button>

...省略...

/// <summary>
/// Team要素を上に移動
/// </summary>
protected void OnUpTeam()
{
  if (this.SelectedItem == null || this.SelectedItem.Parent == null)
    return;

  if (this.SelectedItem.Index > 0)
  {
    var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index - 1);
    target.Index++;
    this.SelectedItem.Index--;
  }
}

/// <summary>
/// Team要素を下に移動
/// </summary>
protected void OnDownTeam()
{
  if (this.SelectedItem == null || this.SelectedItem.Parent == null)
    return;

  if (this.SelectedItem.Index < this.SelectedItem.Parent.Children.Count - 1)
  {
    var target = this.SelectedItem.Parent.Children.First(c => c.Index == this.SelectedItem.Index + 1);
    target.Index--;
    this.SelectedItem.Index++;
  }
}

前述の通り、<tree>の子要素セレクタ定義で要素の並び順は Index 順としています。

  ChildSelector="@(item => item.Children.OrderBy(i => i.Index))">

OnUpTeam() / OnDownTeam()の各イベントハンドラでは選択されている要素のIndexプロパティ値を操作するロジックを実装しています。
バインド対象であるTeam要素の変更により、Treeコンポーネント上の表示項目にも並び順が変更された状態が反映されます(データバインディングの仕組み)。

3. まとめ

ということで、Blazor WebAssemblyでもTreeコントロールを気軽に使えるコンポーネント環境が整っていますね。
今回作成したサンプルソースは、以下のGithubに置いておきました。
github.com

ではーー。