😽

Blazor で HTML を書きたくないよぉ…(Fluent UI Blazor 編 on .NET 8)

2023/11/19に公開

はじめに

同じようなタイトルで 1 年と半年前に以下の記事を書きました。

https://zenn.dev/microsoft/articles/i-dont-want-to-use-html-to-create-ui

この時には Fluent UI Blazor イマイチだなぁと思っていたのですが、あれから 1 年半で Fluent UI Blazor が結構進化していました。そして .NET 8 で Blazor が大進化を遂げたのにも、しっかり対応していました。
まだ、細かいところをみると .NET 8 の Blazor の進化への対応は、ドタバタしてる感じはありますが、即対応版を出してくれているところは安心感があります。

ということで .NET 8 で Fluent UI Blazor を試してみて感想を書いていこうと思います。

Fluent UI Blazor とは

Fluent UI Blazor は Microsoft のデザイン システムの Fluent UI に沿ったコンポーネントを提供する Blazor 向けの UI フレームワークです。1 年半前はドキュメントの整備もイマイチで元になる JavaScript の世界がにじみ出ていたりしてイマイチだったのですが、現在ではしっかりした Blazor のコンポーネントとして使えるようになっています。

GitHub 上で OSS で開発されています。

https://github.com/microsoft/fluentui-blazor/

さらにデモサイトも用意されていて、以下のようにコンポーネントごとに動きなどが確認できます。

画面全体のレイアウトを作るコンポーネントやメニューや DataGrid などなど様々なコンポーネントが提供されているので、それっぽい見た目で HTML/CSS をなるべく書かないで作りたいという要望に答えてくれそうな雰囲気を感じます。

使ってみよう

では、README に従って導入をしてみようと思います。

Blazor Web App のプロジェクトテンプレートで Interactive render mode を Auto にして Interactivity location を Per page/component にした状態で作成します。

Interactive render mode は別にお好みでいいのですが、フル機能を使う場合は Auto になります。ただし Auto でちゃんと WASM/Server/SSR をきちんと使いこなすのはちょっと大変です…。無難なのは Server あたりですが対話機能の必要なページでは WebSocket の接続が必須になるので、Azure にデプロイするときは SignalR Service なども併せてデプロイしたほうが良いなど、若干デプロイが手間になります。

Auto でも結局 WebSocket の接続は必要なので、対話機能を極力 WASM にも回せるように作るなどをしないと WebSocket の接続を結構捌く必要があると思うので、そこらへんは結局必要です。スケーラビリティに全振りするなら基本的には SSR + Streaming SSR で動くようにして、対話機能は初回だけ Server で二回目以降は WASM になるようにするのが理想ですが、それでちゃんと動くようにするには、それなりの注意が必要です…。

さて、横道にそれてしまいました。Fluent UI Blazor の導入ですが基本的に 3 つの NuGet パッケージを追加します。後ろの 2 つはオプションですがデザインセンスが皆無な私は、ありあわせのアイコンなどを最大限活用したいと思うので、基本的には入れておこうと思っています。

バージョンは 3 つとも v4 系を入れます。注意点としてはサーバー側とクライアント側の両方のプロジェクトに追加する必要がある点です。

  • Microsoft.FluentUI.AspNetCore.Components
  • Microsoft.FluentUI.AspNetCore.Components.Icons
  • Microsoft.FluentUI.AspNetCore.Components.Emoji

次に JavaScript と CSS の導入を行います。CSS は Reboot と呼ばれるものを入れるのですが、これはオプションです。ただ、CSS 力がないので基本的にはこれを使う感じで行こうと思います。

bootstrap とは決別するので App.razor から以下の 1 行を消します。

App.razor
    <link rel="stylesheet" href="bootstrap/bootstrap.min.css" />

そして wwwroot/app.css を以下の内容に置き換えます。これは、Fluent UI のプロジェクトテンプレートから拝借してきてフッターまわりの定義が不要だったので削除したものになります。

app.css
@import '/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css';

body {
    --body-font: "Segoe UI Variable", "Segoe UI", sans-serif;
    font-family: var(--body-font);
    font-size: var(--type-ramp-base-font-size);
    line-height: var(--type-ramp-base-line-height);
    margin: 0;
}

.main {
    height: calc(100dvh - 102px);
    color: var(--neutral-foreground-rest);
}

.content {
    margin: 0 10px;
}

.alert {
    border: 1px dashed var(--accent-fill-rest);
    padding: 5px;
}


#blazor-error-ui {
    background: lightyellow;
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
    position: fixed;
    width: 100%;
    z-index: 1000;
    margin: 20px 0;
}

    #blazor-error-ui .dismiss {
        cursor: pointer;
        position: absolute;
        right: 0.75rem;
        top: 0.5rem;
    }

.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
    padding: 1rem 1rem 1rem 3.7rem;
    color: white;
}

    .blazor-error-boundary::before {
        content: "An error has occurred. "
    }

.loading-progress {
    position: relative;
    display: block;
    width: 8rem;
    height: 8rem;
    margin: 20vh auto 1rem auto;
}

    .loading-progress circle {
        fill: none;
        stroke: #e0e0e0;
        stroke-width: 0.6rem;
        transform-origin: 50% 50%;
        transform: rotate(-90deg);
    }

        .loading-progress circle:last-child {
            stroke: #1b6ec2;
            stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
            transition: stroke-dasharray 0.05s ease-in-out;
        }

.loading-progress-text {
    position: absolute;
    text-align: center;
    font-weight: bold;
    inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
}

    .loading-progress-text:after {
        content: var(--blazor-load-percentage-text, "Loading");
    }

code {
    color: #c02d76;
}

そして、App.razor の blazor.web.js を追加している script タグの下に以下の script タグを追加します。

App.razor
    <script src="_content/Microsoft.FluentUI.AspNetCore.Components/js/web-components-v2.5.16.min.js" type="module" async></script>

そして Program.csbuilder.Services.AddFluentUIComponents(); を追加します。追加は var app = builder.Build(); の手前あたりです。追加すると以下のようになります。

Program.cs
using BlazorApp38.Client.Pages;
using BlazorApp38.Components;
using Microsoft.FluentUI.AspNetCore.Components;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

// 追加
builder.Services.AddFluentUIComponents();

var app = builder.Build();

… 以下省略 …

最後に 2 つあるプロジェクトの _Imports.razor の両方に以下の 1 行を追加します。これを追加すると Fluent UI Blazor のコンポーネントが razor ファイル内で名前空間の指定なしに使えるようになります。

_Imports.razor
@using Microsoft.FluentUI.AspNetCore.Components

これで下準備は完了です。トーストやメッセージダイアログみたいなものを使いたい場合は追加で Provider 系のコンポーネントの追加も必要ですが、それは必要になった時にやる感じにしましょう。

レイアウトを組む

では、Fluent UI を使って基本的なレイアウトを組んでいこうと思います。FluentMainLayout コンポーネントを使うと基本的なメニューやヘッダーやフッターやコンテンツ領域を持ったレイアウトを簡単に作ることが出来ますが、メニュー部分の折りたたみなどはユーザーとの対話機能になるので InteractiveServer の指定が必須になります。これをやるとアプリ全体で WebSocket が必須になるので、今回は FluentMainLayout よりもちょっと例レイヤーのコンポーネントになる FluentLayout コンポーネントを使って、メニューの折りたたみ等は InteractiveWebAssembly を使って実装しようと思います。

まずは、既存の NavMenu.razorNavMenu.razor.css を削除します。そして、Client 側のプロジェクトに Components/Layout/NavMenu.razorComponents/Layout/NavMenu.razor.css を作成します。

余談ですが Ctrl + Shift + A で出る項目の追加で、こうやって一気に指定できるの便利。

css の方は以下のようにしてアイコンの余白をいい感じにあけます。これも Fluent UI Blazor のプロジェクトテンプレートから拝借してきました。

NavMenu.razor.css
::deep .fluent-nav-icon {
    padding-top: 5px;
}

NavMenu.razor も以下のようにします。アイコンはそれっぽいものを選んでみました。このアイコンのページから、実際に見た目を確認しながら検索できるのでアイコンはそれで探すのがお勧めです。

NavMenu.razor
@rendermode InteractiveWebAssembly

<nav>
    <FluentNavMenu Width="250" Collapsible="true" Title="Custom navigation menu">
        <FluentNavLink Href="/" Match="NavLinkMatch.All" Icon="new Icons.Regular.Size24.Home()" IconColor="Color.Accent">Home</FluentNavLink>
        <FluentNavLink Href="counter" Icon="new Icons.Regular.Size24.Add()" IconColor="Color.Accent">Counter</FluentNavLink>
        <FluentNavLink Href="weather" Icon="new Icons.Regular.Size24.WeatherSunny()" IconColor="Color.Accent">Weather</FluentNavLink>
    </FluentNavMenu>
</nav>

MainLayout.razor を以下のように書き換えます。

MainLayout.razor
@using BlazorApp38.Client.Components.Layout
@inherits LayoutComponentBase

<FluentLayout>
    <FluentHeader>
        Fluent UI Blazor サンプル アプリケーション
    </FluentHeader>
    <FluentStack Class="main" Orientation="Orientation.Horizontal" Width="100%">
        <NavMenu />
        <FluentBodyContent>
            <div class="content">
                @Body
            </div>
        </FluentBodyContent>
    </FluentStack>
    <FluentFooter>
        Fluent UI Blazor サンプル アプリケーション フッター
    </FluentFooter>
</FluentLayout>

<div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

これで実行すると以下のように Fluent UI Blazor のレイアウトコンポーネントを適用した形のアプリが出来上がります。

Fluent UI Blazor のコンポーネントを使ってみる

試しに Weather のコンポーネントを Fluent UI Blazor のコンポーネントを使って更新してみましょう。Fluent UI Blazor の FluentDataGrid を使ってデータを出したり、FluentPaginator を使ってページングを実装してみます。また、データが読み込み中の時には FluentPgogress を使ってプログレスバーを出すようにしています。

ページングなどはユーザーとの対話操作になるので @rendermode InteractiveServer にしています。

Weather.razor
@page "/weather"
@rendermode InteractiveServer

<PageTitle>Weather</PageTitle>

<FluentLabel Typo="Typography.Header">Weather</FluentLabel>

<FluentLabel Typo="Typography.Body">This component demonstrates showing data.</FluentLabel>

@if (forecasts == null)
{
    <FluentProgress />
}
else
{
    <FluentDataGrid Items="forecasts" Pagination="paginationState" TGridItem="WeatherForecast">
        <PropertyColumn Property="c => c.Date" Align="Align.Start" Sortable="true" Title="日付" />
        <PropertyColumn Property="c => c.TemperatureC" Align="Align.End" Sortable="true" Title="温度(C)" />
        <PropertyColumn Property="c => c.TemperatureF" Align="Align.End" Sortable="true" Title="温度(F)" />
        <PropertyColumn Property="c => c.Summary" Sortable="true" Align="Align.Start" Title="サマリー" />
    </FluentDataGrid>

    <FluentPaginator State="paginationState" />
}

@code {
    private PaginationState paginationState = new PaginationState { ItemsPerPage = 3 };
    private IQueryable<WeatherForecast>? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // Simulate asynchronous loading to demonstrate streaming rendering
        await Task.Delay(500);

        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = summaries[Random.Shared.Next(summaries.Length)]
        }).ToArray().AsQueryable();
    }

    private class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

実行してみると、データの読み込み中は以下のようにプログレスバーが表示されます。

データが表示されるとページングやソート機能を持ったデータグリッドが表示されます。

FluentDataGrid と .NET 8 で追加された QuickGrid は、どっちが高機能なんでしょうね…。

まとめ

ということで簡単にですが Fluent UI Blazor を使ってみました。触った感じ本当に 1 年半前とは別物になっていて、これなら最初に Fluent UI Blazor を検討してみていいのではないかと思いました。一応マイクロソフトが作ってメンテナンスしているので、安心感があります。まぁマイクロソフトがメンテナンスしていても無くなってしまうものは無くなってしまうのですが…。ASP.NET Core Blazor の一部として出荷してほしい…。

他のサードパーティ製品も、コンポーネントベンダーの有償サポートが受けられるといった実際のプロジェクトでは重要な要素や Fluent UI Blazor にはないコンポーネントをサポートしているケースもあると思います。実際にやりたいことに合わせて検討してみるといいと思います。

Microsoft (有志)

Discussion