【華麗】ゼロからWPF+Blazorでチャットミニアプリを作る

【華麗】ゼロからWPF+Blazorでチャットミニアプリを作る

WPFのHello Worldプログラムから始めて、徐々にBlazorを導入し、無料で見られるチャットミニアプリを作って遊んでみる。

最終更新 2022/11/07 23:11
沙漠尽头的狼
読了目安 28 分
カテゴリ
WPF Blazor
タグ
.NET C# Blazor WPF

こんにちは、私は砂漠の果ての狼です。

.NETは無料、クロスプラットフォーム、オープンソースであり、すべてのアプリケーションを構築するための開発者プラットフォームです。

この記事では、WPFBlazorを使用して美しいUIを開発し、クライアント開発に新たな活力を与える方法を説明します。

WPFでBlazorをサポートするには、.NETのバージョンが6.0以上である必要があります。この記事のすべての例では.NET 7.0を使用しています。バージョン要件はリンクを参照し、スクリーンショットで次の文章をご確認ください。

.NETバージョン要件

1. WPFデフォルトプログラム

この記事では、WPF Hello Worldの開発から始めます。

WPFテンプレートを使用してデフォルトプログラムを作成し、名前を【WPFBlazorChat】とします。プロジェクト構成は以下の通りです。

空のWPFプロジェクト

プロジェクトを実行すると、空のウィンドウが表示されます。

WPFプロジェクトの空のウィンドウ

続けて、Blazorサポートを追加します。このセクションのコードはこちらWPFデフォルトプログラムのソースコードにあります。

2. Blazorサポートの追加

上のプロジェクトを引き続き使用し、Blazorサポートを追加します。この部分はマイクロソフトのドキュメント「Windows Presentation Foundation (WPF) Blazor アプリを作成する」を参照しています。このセクションは簡単に説明します。

2.1 プロジェクトファイルの編集

プロジェクトファイルWPFBlazorChat.csprojをダブルクリックして編集します。変更点は以下の通りです。

プロジェクトファイルの変更比較

  1. プロジェクトファイルの上部で、SDKをMicrosoft.NET.Sdk.Razorに変更します。
  2. ノード<RootNameSpace>WPFBlazorChat</RootNameSpace>を追加し、プロジェクトの名前空間WPFBlazorChatをアプリケーションのルート名前空間に設定します。
  3. NuGetパッケージMicrosoft.AspNetCore.Components.WebView.Wpfを追加します。バージョンは選択した.NETバージョンによって異なります。

2.2 _Imports.razorファイルの追加

_Imports.razorファイルは、Razorコンポーネント専用のグローバルusingファイルのようなものです。よく使われるグローバル名前空間を配置してコードを簡潔にします。

内容は以下の通りです。Microsoft.AspNetCore.Components.Web名前空間を導入します。これはRazorでよく使われる名前空間で、Blazorフレームワークにブラウザイベントに関する情報を提供する型が含まれています。

@using Microsoft.AspNetCore.Components.Web

2.3 wwwroot\index.htmlファイルの追加

VueReactと同様に、RazorコンポーネントをホストするHTMLファイルが必要です。ページの内容は次のようになります。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WPFBlazorChat</title>
    <base href="/" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="WpfBlazor.styles.css" rel="stylesheet" />
</head>

<body>
<div id="app">Loading...</div>

<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.webview.js"></script>
</body>

</html>
  1. app.cssファイルは以下で定義されます。
  2. <div id="app">Loading...</div>の部分は、Razorコンポーネントをホストする場所で、以降にロードされるすべてのRazorコンポーネントはここでレンダリングされます。
  3. その他は今は気にしません。

2.4 wwwroot\css\app.cssファイルの追加

ページの基本スタイルです。汎用的なスタイルはこのファイルに配置できます。

html, body {
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

h1:focus {
    outline: none;
}

a, .btn-link {
    color: #0071c1;
}

.btn-primary {
    color: #fff;
    background-color: #1b6ec2;
    border-color: #1861ac;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid red;
}

.validation-message {
    color: red;
}

#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;
}

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

2.5 Razorコンポーネントの追加

RazorのクラシックなコンポーネントCounter.razorを追加します。BlazorのHello Worldプログラムにもこのコンポーネントがあります。ファイルパス:/RazorViews/Counter.razor。WPFでよく使われるViewsディレクトリと区別するためにRazorViewsディレクトリに配置します。コンポーネントの内容は次の通りです。

<h1>Counter</h1>

<p>好开心,你点我了,现在是:<span style="color: red;">@currentCount</span></p>

<button class="btn btn-primary" @onclick="IncrementCount">快快点我</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

ボタン【快快点我】をクリックすると、@onclick="IncrementCount"により変数currentCountがインクリメントされ、ページにその値が表示されます。理解できると思います。

2.6 BlazorとWPFフォームの関連付け

これは両者の関係を確立する重要なステップです。MainWindow.xamlを開き、次のように変更します。

フォームXamlの変更

上記のコードの要点は以下の通りです。

  1. 上で導入したNuGetパッケージMicrosoft.AspNetCore.Components.WebView.Wpfの名前空間を追加し、blazorとエイリアスを設定します。主にBlazorWebViewコンポーネントを使用するためです。
  2. BlazorWebViewコンポーネントの属性HostPageはホストするHTMLファイルを指定し、ServicesはRazorコンポーネントのIoCコンテナを指定します。下のMainWindow()内の赤いコードを参照してください。
  3. RootComponentSelector="#app"属性は、Razorコンポーネントがレンダリングされる場所を示します。index.html内のidappのHTML要素を参照します。ComponentType#app内でレンダリングするRazorコンポーネントの型を示します。

MainWindow.xaml.csを開き、次のように変更します。

IoCコンテナの注入

WPFでは、Prismなどのフレームワークが提供するUnityDryIocなどのIoCコンテナを使用して、ビューとサービスの注入を実現できます。Razorコンポーネントでは、デフォルトでASP.NET CoreのIServiceCollectionコンテナを使用します。WPFフォームとRazorコンポーネントがデータを共有する必要がある場合は、後述するMessagerを使用してメッセージを送信するか、IoCコンテナの注入によって実現できます。例えば、WPFフォームから注入されたデータ(MainWindowコンストラクターで注入)を、IServiceCollectionコンテナを介してRazorコンポーネントで使用できるように注入することもできます。これについては後述します。

WPFとRazorコンポーネント間のIoCデータ転送

上記の手順が完了したら、プログラムを実行します。

WPF統合Blazorのデフォルトプログラム

OK、WPFBlazorの統合は成功です。これで終わりでしょうか?

まだです。このセクションのソースコードはこちらWPFへのBlazorの導入にあります。続きを読んでください。

3. カスタムフォーム

WPFデフォルトフォーム

上の図を見ると、フォームの境界線はWPFのデフォルトスタイルです。時々見栄えが悪かったり、デザイナーが別のフォームスタイルをデザインしたりすることがあります。多くの場合、フォームをカスタマイズする必要があります。このセクションでは、WPFとBlazorを使用したカスタムフォームの実装について一部共有します。よりカスタマイズされた機能はご自身で調査する必要があるかもしれません。

3.1 WPFカスタムフォーム

一般的な実装方法は、フォームの3つのプロパティWindowStyle="None" AllowsTransparency="True" Background="Transparent"を設定してデフォルトのフォーム境界線を非表示にし、コンテンツ領域で独自のタイトルバー、最小化・最大化・閉じるボタン、クライアント領域などを描画することです。

MainWindow.xaml:WPFデフォルトフォーム境界線の非表示

<Window
    x:Class="WPFBlazorChat.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
    Title="MainWindow"
    Width="800"
    Height="450"
    AllowsTransparency="True"
    Background="Transparent"
    WindowStyle="None"
    mc:Ignorable="d">
    <Grid>
        <blazor:BlazorWebView HostPage="wwwroot\index.html" Services="{DynamicResource services}">
            <blazor:BlazorWebView.RootComponents>
                <blazor:RootComponent ComponentType="{x:Type razorViews:Counter}" Selector="#app" />
            </blazor:BlazorWebView.RootComponents>
    </Grid>
</Window>

上記のコードはWPFデフォルトフォームの境界線を非表示にするだけです。プログラムを実行すると次のようになります。

WPFデフォルトフォーム境界線の非表示

上の図を見ると、フォーム内のボタン(実際にはRazorコンポーネントのボタン)をクリックしても、ボタンのクリックイベントが実行されず、フォームが消えてしまいました。これはなぜでしょうか?理由は調査していませんが、とりあえず背景を追加してBlazorWebViewの透過問題を処理します。

シンプルなWPFカスタムフォームスタイル

次に、カスタムフォームの基本的なスタイルを追加してみます。

基本スタイル付きWPFカスタムフォーム

MainWindow.xamlのコードは以下の通りです。

<Window
    x:Class="WPFBlazorChat.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
    Title="MainWindow"
    Width="800"
    Height="450"
    AllowsTransparency="True" Background="Transparent" WindowStyle="None"
    mc:Ignorable="d">
    <Window.Resources>
        <Style TargetType="{x:Type Button}">
            <Setter Property="Width" Value="35" />
            <Setter Property="Height" Value="25" />
            <Setter Property="Margin" Value="2" />
            <Setter Property="Background" Value="Transparent" />
            <Setter Property="BorderThickness" Value="0" />
            <Setter Property="Foreground" Value="White" />
        </Style>
    </Window.Resources>
    <Border Background="#7160E8" CornerRadius="5">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="35" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <Border
                Background="#7160E8" CornerRadius="5 5 0 0" MouseLeftButtonDown="MoveWindow_MouseLeftButtonDown">
                <Grid>
                    <TextBlock
                        Margin="10,10,5,5"
                        Foreground="White"
                        Text="这里是窗体标题栏,左侧可放Logo、标题,右侧放窗体操作按钮:最小化、最大化、关闭等" />
                    <StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
                        <Button Click="MinimizeWindow_Click" Content="―" />
                        <Button Click="MaximizeWindow_Click" Content="口" />
                        <Button Click="CloseWindow_Click" Content="X" />
                    </StackPanel>
                </Grid>
            </Border>
            <blazor:BlazorWebView Grid.Row="1" HostPage="wwwroot\index.html" Services="{DynamicResource services}">
                <blazor:BlazorWebView.RootComponents>
                    <blazor:RootComponent ComponentType="{x:Type razorViews:Counter}" Selector="#app" />
                </blazor:BlazorWebView.RootComponents>
            </blazor:BlazorWebView>
        </Grid>
    </Border>
</Window>

フォーム全体のクライアント領域に背景Borderを追加し(背景色を削除してインターフェイスボタンを試してみることもできます)、その中にGridをネストして、カスタムタイトルバー(タイトルとフォーム制御ボタン)とBlazorWebView(Razorコンポーネントをレンダリングするブラウザコンポーネント)を配置します。以下はフォーム制御ボタンの応答イベントです。

using Microsoft.Extensions.DependencyInjection;
using System.Windows;

namespace WPFBlazorChat;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        var serviceCollection = new ServiceCollection();
        serviceCollection.AddWpfBlazorWebView();
        Resources.Add("services", serviceCollection.BuildServiceProvider());
    }

    private void MoveWindow_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        if (e.ClickCount == 1)
        {
            this.DragMove();
        }
        else
        {
            MaximizeWindow_Click(null, null);
        }
    }

    private void CloseWindow_Click(object sender, RoutedEventArgs e)
    {
        this.Close();
    }

    private void MinimizeWindow_Click(object sender, RoutedEventArgs e)
    {
        this.WindowState = WindowState.Minimized;
    }

    private void MaximizeWindow_Click(object sender, RoutedEventArgs e)
    {
        this.WindowState = this.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
    }
}

コードはシンプルで、フォームの最小化、最大化(復元)、閉じる、タイトルバーのダブルクリックによる最大化(復元)を処理しています。上記の実装は完全なカスタムフォーム実装ではなく、少なくとも以下の2つの問題があります。

  • 最大化すると、フォームがデスクトップ全体(タスクバー領域も含む)を覆ってしまいます。
  • フォームのタスクバー上の2つの角丸が効いていません(赤い四角で囲んだ部分)。つまり、フォーム下部の2つの角丸が効いておらず、BlazorWebViewに角丸を設定する属性や他の方法が見つかりませんでした。タイトルバー領域(緑の四角で囲んだ部分)はWPFコントロールなので角丸は正常に表示されます。

フォーム角丸

後述の3.4セクションでは、サードパーティライブラリを使用してフォームの角丸問題を解決します。より良いWPFカスタムフォームの実装については、この記事を参照してください:WPF三种自定义窗体的实现。このセクションのサンプルソースコードはこちらWPFカスタムフォームです。

3.2 WPF異形フォーム

異形フォームの要件は、WPFを使用すると比較的簡単に実現できます。ここでは主題から逸れすぎるため、次の記事をご参照ください:WPF异形窗体演示。記事内の異形フォームの効果は以下の通りです。

WPF異形フォーム

以下では、フォームのタイトルバーもRazorコンポーネントで実装する方法を紹介します。

3.3 Blazorによるカスタムフォーム効果の実現

上記ではWPFを使用してカスタムフォームを作成しましたが、メニューをタイトルバーに配置したいという要件はありませんか?これはWPFで簡単に実現できます。

もしTab系コントロールを配置する場合、Tab Headerはタイトルバーに、TabItemはクライアント領域に表示されますが、スタイルを統一し、1つのコードセット内で実装・保守したい場合、WPF+Blazorのハイブリッド開発ではどのように実現するのでしょうか?このセクションのRazorコンポーネントによるタイトルバーの実装を理解すれば、応用できるはずです。

MainWindow.xamlのコードを元に戻し、WPFデフォルトフォームの境界線を非表示にする設定だけ残し、BlazorWebViewに背景を追加します。

WPF透明フォーム

以下のコードは、BlazorDesktopWPF-CustomTitleBarというオープンソースプロジェクトを参考にしています。

タイトルバーをCounter.razorコンポーネントに組み込みます。つまり、タイトルバーとクライアント領域を1つのコンポーネントにまとめます。もちろん分離することもできますが、ここでは説明を簡単にするためにまとめます。

Counter.razor

@using WPFBlazorChat.Services

<div class="titlebar" @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
    <button class="titlebar-btn" onclick="alert('js alert: navigation pressed');">
        <img src="svg/navigation.svg" />
    </button>
    <div class="window-title">
        测试窗体标题
    </div>
    <div style="flex-grow:1"></div>
    <button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
        <img src="svg/settings.svg" />
    </button>
    <button class="titlebar-btn" @onclick="WindowService.Minimize">
        <img src="svg/minimize.svg" />
    </button>
    <button class="titlebar-btn" @onclick="WindowService.Maximize">
        @if (WindowService.IsMaximized())
        {
            <img src="svg/restore.svg" />
        }
        else
        {
            <img src="svg/maximize.svg" />
        }
    </button>
    <button class="titlebar-cbtn" @onclick="()=>WindowService.Close(false)">
        <img src="svg/dismiss.svg" />
    </button>
</div>

<p>好开心,你点我了,现在是:<span style="color: red;">@currentCount</span></p>

<button class="btn btn-primary" @onclick="IncrementCount">快快点我</button>

@code {
    private int currentCount = 0;

    protected override void OnInitialized()
    {

        WindowService.Init();
        base.OnInitialized();
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}

以下にコードの簡単な説明を示します。

  1. 最初のdivはフォームのタイトルバー領域として機能し、ダブルクリックイベントでフォームの最大化(復元)を呼び出し、マウス押下・解放でフォームの移動開始・終了を呼び出します。
  2. 最初のdiv内には、フォームの最小化、最大化(復元)、閉じるを呼び出す3つのボタンがあります。
  3. さらに2つのボタンがあり、クリックでJavaScriptのalertメソッドを呼び出してメッセージを表示します。

WPF透明フォーム

実行結果は次の通りです。

WPF透明フォーム

この効果を実現するには、まだいくつかのコードが必要です。

  1. 上記のコードでは、フォームの最小化、閉じるなどの操作を実現するいくつかのメソッドを呼び出しています。コードは以下の通りです。
  2. Razorコンポーネント、つまりhtmlで実装されたインターフェイスなので、html要素にはいくつかのcssスタイルも定義されており、コードも併せて記載します。
  3. タイトルバーのボタンにはいくつかのsvg画像を使用しています。これらはリポジトリにあり、自由に取得できます。

フォームのドラッグ

まず、NuGetパッケージSimplify.Windows.Formsを追加し、マウスカーソルの位置を取得します。

<PackageReference Include="Simplify.Windows.Forms" Version="1.1.2" />

フォームヘルパークラスを追加します:Services\WindowService.cs

using System;
using System.Linq;
using System.Windows;
using System.Windows.Forms;
using System.Windows.Threading;
using Application = System.Windows.Application;

namespace WPFBlazorChat.Services;

public class WindowService
{
    private static bool _isMoving;
    private static double _startMouseX;
    private static double _startMouseY;
    private static double _startWindLeft;
    private static double _startWindTop;

    public static void Init()
    {
        DispatcherTimer dispatcherTimer = new();
        dispatcherTimer.Tick += UpdateWindowPos;
        dispatcherTimer.Interval = TimeSpan.FromMilliseconds(17);
        dispatcherTimer.Start();
    }

    public static void StartMove()
    {
        _isMoving = true;
        _startMouseX = GetX();
        _startMouseY = GetY();
        var window = GetActiveWindow();
        if (window == null)
        {
            return;
        }

        _startWindLeft = window.Left;
        _startWindTop = window.Top;
    }

    public static void StopMove()
    {
        _isMoving = false;
    }

    public static void Minimize()
    {
        var window = GetActiveWindow();
        if (window != null)
        {
            window.WindowState = WindowState.Minimized;
        }
    }

    public static void Maximize()
    {
        var window = GetActiveWindow();
        if (window != null)
        {
            window.WindowState =
                window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
        }
    }


    public static bool IsMaximized()
    {
        var window = GetActiveWindow();
        if (window != null)
        {
            return window.WindowState == WindowState.Maximized;
        }

        return false;
    }

    public static void Close(bool allWindow = false)
    {
        if (allWindow)
        {
            Application.Current?.Shutdown();
            return;
        }

        var window = GetActiveWindow();
        if (window != null)
        {
            window.Close();
        }
    }


    private static void UpdateWindowPos(object? sender, EventArgs e)
    {
        if (!_isMoving)
        {
            return;
        }

        double moveX = GetX() - _startMouseX;
        double moveY = GetY() - _startMouseY;
        Window? window = GetActiveWindow();
        if (window == null)
        {
            return;
        }

        window.Left = _startWindLeft + moveX;
        window.Top = _startWindTop + moveY;
    }

    private static int GetX()
    {
        return Control.MousePosition.X;
    }

    private static int GetY()
    {
        return Control.MousePosition.Y;
    }

    private static Window? GetActiveWindow()
    {
        return Application.Current.Windows.Cast<Window>().FirstOrDefault(currentWindow => currentWindow.IsActive);
    }
}

上記のコードは、フォームの最小化、最大化(復元)、閉じるなどの機能を実現します。Razorコンポーネント内でこれらのメソッドを正しく呼び出す必要があります。

  1. Counter.razorコンポーネントのOnInitialized初期化ライフサイクルメソッド内でWindowService.Init();を呼び出します。上記コードのように、このメソッドはタイマーを起動し、定期的にUpdateWindowPosメソッドを呼び出してマウスが押されているかを確認し、押されている場合はインターバル内のフォーム位置の変化範囲をチェックし、フォームの位置を変更します。これによりフォーム位置の移動を実現します(WPFのDragMoveメソッドは使用できません。試しに使用してエラーメッセージを確認してみてください)。より良いフォーム移動方法があれば、コメントをお寄せください。

  2. Razorコンポーネント内のフォーム制御ボタンの使用方法は上記のコードを見れば理解できるでしょう。詳細な説明は省略します。

上記の効果のスタイルファイルは以下のように変更しました。wwwroot\css\app.css

/*
BlazorDesktopWPF-CustomTitleBar - © Copyright 2021 - Jam-Es.com
Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
*/

html, body {
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
    padding: 0;
    margin: 0;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid red;
}

.validation-message {
    color: red;
}

#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;
}

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

.page-container {
    display: flex;
    flex-direction: column;
    height: 100vh;
}

.content-container {
    padding: 0px 20px 20px 20px;
    flex-grow: 1;
    overflow-y: scroll;
}

.titlebar {
    width: 100%;
    height: 32px;
    min-height: 32px;
    background-color: #7160E8;
    display: flex;
    flex-direction: row;
}

.titlebar-btn, .titlebar-cbtn {
    width: 46px;
    background-color: #7160E8;
    color: white;
    border: none;
    border-radius: 0;
}

.titlebar-btn:hover {
    background-color: #5A5A5A;
}

.titlebar-btn:focus, .titlebar-cbtn:focus {
    outline: 0;
}

.titlebar-cbtn:hover {
    background-color: #E81123;
}

.window-title {
    display: flex;
    flex-direction: column;
    justify-content: center;
    margin-left: 5px;
    color: white;
}

上記のコードにより、Razorコンポーネントでフォームのタイトル表示、最小化、最大化(復元)、閉じる、移動などの操作を実現します。ただし、3.1の最後で発生した問題、つまりフォームの角丸と最大化時にタスクバーを覆ってしまう問題は依然として残ります。次のセクションで解決を試みます。

セクションのまとめ:上記のコードにより、Tabコントロールをフォーム全体に配置する方法についてアイデアが得られるでしょうか?

このセクションのソースコードはこちらRazorコンポーネントによるフォームタイトルバー機能の実装です。

3.4 BlazorとWPFの比較的完璧な実装

実は上記のコードは学習用と考えてください。たとえ欠点があっても(笑)。このセクションでは、サードパーティパッケージを使用してフォームの角丸と最大化の問題を解決します。

まず、NuGetパッケージModernWpfUIを追加します。このWPFコントロールライブラリは当サイトで紹介しています:オープンソースWPFコントロールライブラリ:ModernWpf

<PackageReference Include="ModernWpfUI" Version="0.9.7-preview.2" />

次にApp.xamlを開き、上記のオープンソースWPFコントロールのスタイルを参照します。

<Application x:Class="WPFBlazorChat.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:ui="http://schemas.modernwpf.com/2019"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ui:ThemeResources />
                <ui:XamlControlsResources />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

最後にMainWindow.xamlを開き、以下のように変更します(主に導入した属性ui:xxxxx):

<Window x:Class="WPFBlazorChat.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:ui="http://schemas.modernwpf.com/2019"
        xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
        xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800"
        ui:TitleBar.ExtendViewIntoTitleBar="True"
        ui:TitleBar.IsBackButtonVisible="False"
        ui:TitleBar.Style="{DynamicResource AppTitleBarStyle}"
        ui:WindowHelper.UseModernWindowStyle="True">
    <Border Background="#7160E8" CornerRadius="5">
        <blazor:BlazorWebView HostPage="wwwroot\index.html" Services="{DynamicResource services}">
            <blazor:BlazorWebView.RootComponents>
                <blazor:RootComponent Selector="#app" ComponentType="{x:Type razorViews:Counter}" />
            </blazor:BlazorWebView.RootComponents>
        </blazor:BlazorWebView>
    </Border>
</Window>

上記の3か所を変更したら、実行してみましょう。

WPFとBlazorのカスタムフォーム比較的完璧な解決

3.3と同じ効果に見えますか?よく見ると、フォーム下部の角丸も機能しています。

フォーム角丸

結局、WPFがすべての問題を解決してくれました。

私は笑った

最大化時にタスクバーを覆わない方法や、フォームの角丸問題(BlazorWebViewの一部を透明にできるようにする方法)の具体的な実装については、このコンポーネントの関連コードを確認してください。この記事では深く追求しません。

また、WPFに詳しい方ならお気づきかもしれませんが、これまでのコードではフォームのサイズをドラッグで変更できませんでした(お気づきでないかもしれませんが、気づいていないものとします)。このライブラリを使用することで、その問題も解決されます。

フォームの手動サイズ変更

このセクションのソースコードはこちら角丸と最大化問題の解決です。これでこの記事の後半部分に入ります。やっとここまで来ました、疲れました。

疲れた

4. サードパーティBlazorコンポーネントの追加

仕事をうまくこなすには、まず道具を整えよ!

多くの開発者はフロントエンドの基礎がそれほど得意ではないかもしれませんが、Blazorを使えばJavaScriptをあまり使わなくても済むようになります。しかし、美しく便利なBlazorコンポーネントライブラリがあれば、それは虎に翼を添えるようなものです。この記事ではMasa Blazorを例として使用します。現在数多くのBlazorコンポーネントライブラリがあるので、自分に合ったものを選んでください。

Masa Blazor

先日、MAUIでMasa Blazorコンポーネントライブラリを使用するという記事を紹介しました。このセクションの考え方も同様です。私のパフォーマンスをご覧ください。

私のパフォーマンスを見て

Masa Blazorのドキュメントサイトを開きます:https://blazor.masastack.com/getting-started/installation。一緒にWPFにこのBlazorコンポーネントライブラリを導入しましょう。

4.1 Masa.Blazorパッケージの導入

プロジェクトファイルWPFBlazorChat.csprojを開き、以下のパッケージバージョンを直接コピーするか、NuGetパッケージマネージャーでMasa.Blazorを検索してインストールします。

<PackageReference Include="Masa.Blazor" Version="0.6.0" />

4.2 Masa.Blazorがもたらすリソースの追加

wwwroot\index.htmlを開き、<head></head>ノードに以下のリソースを追加します。

<link href="_content/Masa.Blazor/css/masa-blazor.min.css" rel="stylesheet" />

<link href="https://cdn.masastack.com/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://cdn.masastack.com/npm/materialicons/materialicons.css" rel="stylesheet">
<link href="https://cdn.masastack.com/npm/fontawesome/v5.0.13/css/all.css" rel="stylesheet">

<script src="_content/BlazorComponent/js/blazor-component.js"></script>

完全なコードは以下の通りです。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WPFBlazorChat</title>
    <base href="/" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="WpfBlazor.styles.css" rel="stylesheet" />

    <link href="_content/Masa.Blazor/css/masa-blazor.min.css" rel="stylesheet" />

    <link href="https://cdn.masastack.com/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">
    <link href="https://cdn.masastack.com/npm/materialicons/materialicons.css" rel="stylesheet">
    <link href="https://cdn.masastack.com/npm/fontawesome/v5.0.13/css/all.css" rel="stylesheet">

    <script src="_content/BlazorComponent/js/blazor-component.js"></script>
</head>

<body>
<div id="app">Loading...</div>

<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.webview.js"></script>
</body>

</html>

4.3 Masa.Blazor名前空間の導入

_Imports.razorファイルを開き、以下のように変更します。

@using Microsoft.AspNetCore.Components.Web
@using Masa.Blazor
@using BlazorComponent

4.4 RazorコンポーネントへのMasa.Blazorの追加

MainWindow.xaml.csを開き、serviceCollection.AddMasaBlazor();という1行のコードを追加します。

IoCへのMasa Blazorの追加

4.5 Masa.Blazorのサンプルを試す

上記4つの準備が完了したら、簡単にMasa.Blazorコンポーネントを使用してみましょう。

Tabコンポーネントのリンクを開きます:https://blazor.masastack.com/components/tabs。このデモを試してみてください。

Masa BlazorのTabコンポーネントのサンプル

デモのコードをほぼそのまま使います。RazorViews\Counter.razorファイルを開き、3.4セクションのタイトルバーはそのままに、クライアント領域の内容を置き換えます。コードは以下の通りです。

@using WPFBlazorChat.Services

<MApp>
    <!--上一小节的标题栏开始-->
    <div class="titlebar" @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
        <button class="titlebar-btn" onclick="alert('js alert: navigation pressed');">
            <img src="svg/navigation.svg"/>
        </button>
        <div class="window-title">
            测试窗体标题
        </div>
        <div style="flex-grow: 1"></div>
        <button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
            <img src="svg/settings.svg"/>
        </button>
        <button class="titlebar-btn" @onclick="WindowService.Minimize">
            <img src="svg/minimize.svg"/>
        </button>
        <button class="titlebar-btn" @onclick="WindowService.Maximize">
            @if (WindowService.IsMaximized())
            {
                <img src="svg/restore.svg"/>
            }
            else
            {
                <img src="svg/maximize.svg"/>
            }
        </button>
        <button class="titlebar-cbtn" @onclick="() => WindowService.Close(false)">
            <img src="svg/dismiss.svg"/>
        </button>
    </div>
    <!--上一小节的标题栏结束-->
    
    <!--新增的Masa.Blazor Tab案例代码开始-->
    <MCard>
        <MToolbar Color="cyan" Dark Flat>
            <ChildContent>
                <MAppBarNavIcon></MAppBarNavIcon>

                <MToolbarTitle>Your Dashboard</MToolbarTitle>

                <MSpacer></MSpacer>

                <MButton Icon>
                    <MIcon>mdi-magnify</MIcon>
                </MButton>

                <MButton Icon>
                    <MIcon>mdi-dots-vertical</MIcon>
                </MButton>
            </ChildContent>

            <ExtensionContent>
                <MTabs @bind-Value="tab"
                       AlignWithTitle
                       SliderColor="yellow">
                    @foreach (var item in items)
                    {
                        <MTab Value="item">
                            @item
                        </MTab>
                    }
                </MTabs>
            </ExtensionContent>
        </MToolbar>

        <MTabsItems @bind-Value="tab">
            @foreach (var item in items)
            {
                <MTabItem Value="item">
                    <MCard Flat>
                        <MCardText>@text</MCardText>
                    </MCard>
                </MTabItem>
            }
        </MTabsItems>
    </MCard>
    <!--新增的Masa.Blazor Tab案例代码结束-->
</MApp>

@code {

    #region Masa.Blazor Tab案例C#代码
    StringNumber tab;

    List<string> items = new()
    {
        "web", "shopping", "videos", "images", "news",
    };

    string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";

    #endregion
    
    
    protected override void OnInitialized()
    {
        WindowService.Init();
        base.OnInitialized();
    }
}

実行結果は以下の通りです。

Masa BlazorのTabコンポーネントの統合

それらしくなってきましたか?さらに、Tabをタイトルバーに移動してみます。前に触れた効果です。

Tabをタイトルバーに配置

上記の効果のコードは以下のように変更しました。元のタイトルバーコードを削除し、フォーム操作ボタンをMToolbar内に配置し、MToolbarにダブルクリックイベント、マウス押下・解放イベントを追加してフォームのドラッグを実現しています。

<MApp>

    <!--新增的Masa.Blazor Tab案例代码开始-->
    <MCard>
        <MToolbar Color="cyan" Dark Flat @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
            <MTabs @bind-Value="tab"
                   AlignWithTitle
                   SliderColor="yellow">
                @foreach (var item in items)
                {
                    <MTab Value="item">
                        @item
                    </MTab>
                }
            </MTabs>
            
            <div style="flex-grow: 1"></div>
            <button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
                <img src="svg/settings.svg"/>
            </button>
            <button class="titlebar-btn" @onclick="WindowService.Minimize">
                <img src="svg/minimize.svg"/>
            </button>
            <button class="titlebar-btn" @onclick="WindowService.Maximize">
                @if (WindowService.IsMaximized())
                {
                    <img src="svg/restore.svg"/>
                }
                else
                {
                    <img src="svg/maximize.svg"/>
                }
            </button>
            <button class="titlebar-cbtn" @onclick="() => WindowService.Close(false)">
                <img src="svg/dismiss.svg"/>
            </button>
        </MToolbar>

        <MTabsItems @bind-Value="tab">
            @foreach (var item in items)
            {
                <MTabItem Value="item">
                    <MCard Flat>
                        <MCardText>@text</MCardText>
                    </MCard>
                </MTabItem>
            }
        </MTabsItems>
    </MCard>
    <!--新增的Masa.Blazor Tab案例代码结束-->
</MApp>

フォーム操作ボタンの背景色も一部変更しました。

スタイルの一部変更

実際には、上記のフォーム効果にはまだ少し欠点があります。右側の垂直スクロールバーに気づきましたか?Masa.Blazorを導入する前は右側は正常に表示されていましたが、導入後に垂直スクロールバーが追加されました。

Masa.Blazor導入後に垂直スクロールバーが追加

これを消すのも簡単です。wwwroot\css\app.cssにスタイルを追加します(これには少し時間がかかりましたが、最終的にMasa.Blazorグループのメンバーが解決策を提供してくれました。感謝します)。

問題解決の過程

問題解決のcssコード:

::-webkit-scrollbar {
    width: 0px;
}

RazorコンポーネントはBlazorWebView内でレンダリングされます。つまり、BlazorWebViewは小さなブラウザのようなものです。上記のスタイルはブラウザのスクロールバーの幅を0に設定するため、スクロールバーが表示されなくなります。現在の効果は以下の通りです。すっきりしましたね。

修正後のインターフェイス

Masa.Blazorの導入はここまでです。このセクションのサンプルコードはこちらWPFでのMasa.Blazorの使用です。次に、WPFとBlazorのハイブリッド開発におけるマルチフォームのメッセージ通知について説明します。

5. マルチフォームメッセージ通知

一般的に、C/Sフォーム間の通信にはデリゲートやイベントが使用されます。WPF開発では、PrismのイベントアグリゲーターIEventAggregatorやMvvmLightのMessagerなど、いくつかのフレームワークが提供する抽象的なイベントの「発行/購読」コンポーネントを使用できます。B/S開発では、プロセス内イベント通知にはMediatRコンポーネントがよく使用されます。C/SとB/Sの両方の開発において、これらのコンポーネントはある程度、さまざまなプログラムテンプレートで汎用的に使用できます。もちろん、分散メッセージキューRabbitMQKafkaは万能なプロセス間通信の標準選択肢です。

上記は一般論です。私はPrismのイベントアグリゲーターとMvvmLightのMessagerのソースコードを読んで、簡単なMessagerをカプセル化しました。一般的なビジネス要件に適用できます。

5.1 Messagerのカプセル化

コードを直接貼り付けてソースコードのリンクを提供するだけで済ませようと思いましたが、コードも多くないのでそのまま掲載します。

Message

メッセージの抽象クラス。メッセージタイプを定義するために使用され、具体的なメッセージはこのクラスを継承する必要があります。後述の子フォームを開くメッセージOpenSecondViewMessageなどがその例です。

using System;

namespace WPFBlazorChat.Messages;

public abstract class Message
{
    protected Message(object sender)
    {
        this.Sender = sender ?? throw new ArgumentNullException(nameof(sender));
    }

    public object Sender { get; }
}

IMessenger

メッセージインターフェイス。3つのインターフェイスのみを定義しています。

  1. Subscribe:メッセージの購読
  2. Unsubscribe:メッセージの購読解除
  3. Publish:メッセージの送信
using System;

namespace WPFBlazorChat.Messages;

public interface IMessenger
{
    void Subscribe<TMessage>(object recipient, Action<TMessage> action,
        ThreadOption threadOption = ThreadOption.PublisherThread) where TMessage : Message;

    void Unsubscribe<TMessage>(object recipient, Action<TMessage>? action = null) where TMessage : Message;

    void Publish<TMessage>(object sender, TMessage message) where TMessage : Message;
}

public enum ThreadOption
{
    PublisherThread,
    BackgroundThread,
    UiThread
}

Messenger

メッセージの管理、メッセージの中継などの実装。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace WPFBlazorChat.Messages;

public class Messenger : IMessenger
{
    public static readonly Messenger Default = new Messenger();
    private readonly object registerLock = new object();

    private Dictionary<Type, List<WeakActionAndToken>>? recipientsOfSubclassesAction;

    public void Subscribe<TMessage>(object recipient, Action<TMessage> action, ThreadOption threadOption)
        where TMessage : Message
    {
        lock (this.registerLock)
        {
            var messageType = typeof(TMessage);

            this.recipientsOfSubclassesAction ??= new Dictionary<Type, List<WeakActionAndToken>>();

            List<WeakActionAndToken> list;

            if (!this.recipientsOfSubclassesAction.ContainsKey(messageType))
            {
                list = new List<WeakActionAndToken>();
                this.recipientsOfSubclassesAction.Add(messageType, list);
            }
            else
            {
                list = this.recipientsOfSubclassesAction[messageType];
            }

            var item = new WeakActionAndToken
            { Recipient = recipient, ThreadOption = threadOption, Action = action };

            list.Add(item);
        }
    }

    public void Unsubscribe<TMessage>(object? recipient, Action<TMessage>? action) where TMessage : Message
    {
        var messageType = typeof(TMessage);

        if (recipient == null || this.recipientsOfSubclassesAction == null ||
            this.recipientsOfSubclassesAction.Count == 0 || !this.recipientsOfSubclassesAction.ContainsKey(messageType))
        {
            return;
        }

        var lstActions = this.recipientsOfSubclassesAction[messageType];
        for (var i = lstActions.Count - 1; i >= 0; i--)
        {
            var item = lstActions[i];
            var pastAction = item.Action;

            if (pastAction != null
                && recipient == pastAction.Target
                && (action == null || action.Method.Name == pastAction.Method.Name))
            {
                lstActions.Remove(item);
            }
        }
    }

    public void Publish<TMessage>(object sender, TMessage message) where TMessage : Message
    {
        var messageType = typeof(TMessage);

        if (this.recipientsOfSubclassesAction != null)
        {
            var listClone = this.recipientsOfSubclassesAction.Keys.Take(this.recipientsOfSubclassesAction.Count)
                .ToList();

            foreach (var type in listClone)
            {
                List<WeakActionAndToken>? list = null;

                if (messageType == type || messageType.IsSubclassOf(type) || type.IsAssignableFrom(messageType))
                {
                    list = this.recipientsOfSubclassesAction[type]
                        .Take(this.recipientsOfSubclassesAction[type].Count)
                        .ToList();
                }

                if (list is { Count: > 0 })
                {
                    this.SendToList(message, list);
                }
            }
        }
    }

    private void SendToList<TMessage>(TMessage message, IEnumerable<WeakActionAndToken> weakActionsAndTokens)
        where TMessage : Message
    {
        var list = weakActionsAndTokens.ToList();
        var listClone = list.Take(list.Count()).ToList();

        foreach (var item in listClone)
        {
            if (item.Action is { Target: { } })
            {
                switch (item.ThreadOption)
                {
                    case ThreadOption.BackgroundThread:
                        Task.Run(() => { item.ExecuteWithObject(message); });
                        break;
                    case ThreadOption.UiThread:
                        SynchronizationContext.Current!.Post(_ => { item.ExecuteWithObject(message); }, null);
                        break;
                    default:
                        item.ExecuteWithObject(message);
                        break;
                }
            }
        }
    }
}

public class WeakActionAndToken
{
    public object? Recipient { get; set; }

    public ThreadOption ThreadOption { get; set; }

    public Delegate? Action { get; set; }

    public string? Tag { get; set; }

    public void ExecuteWithObject<TMessage>(TMessage message) where TMessage : Message
    {
        if (this.Action is Action<TMessage> factAction)
        {
            factAction.Invoke(message);
        }
    }
}

興味がある方は上記のコードをご覧ください。カプセル化コードは上記で全て提供しました。以降のメッセージ通知は上記の3つのクラスに基づいて実装されています。非常に重要な部分です。

5.2 コード整理

第5節では、マルチフォームとRazorコンポーネントを扱うため、これらのファイルを配置するためのディレクトリを作成し、分類管理を容易にする必要があります。

整理後のコード

  1. A:Messageを配置。つまり、いくつかのメッセージ通知クラスです。
  2. B:Razorコンポーネントを配置。Maui\Blazor Server(Wasm)などとRazorコンポーネントを共有する必要がある場合は、Razorクラスライブラリを作成して保存できます。
  3. C:汎用サービスを配置。ここではフォーム管理の静的クラスのみを配置していますが、実際の状況に応じてRedisサービスやRabbitMQメッセージサービスなども配置できます。
  4. D:WPFビューを配置。この例では、WPFフォームは単なるシェルで、BlazorWebViewをホストするために使用されます。

5.3 サンプルとコードの説明

まずサンプルの効果を確認し、次に関連するコードを説明します。

メッセージ通知のサンプル

図には3つの操作があります。

  1. メインフォームAの【+】ボタンをクリックすると、OpenSecondViewMessageメッセージが送信され、子フォームBが開きます。
  2. 子フォームBを開いた後、メインフォームAの【ハート】ボタンをクリックすると、SendRandomDataMessageメッセージが送信され、子フォームBの2番目のTabItem Headerにメッセージで送られた数字が表示されます。
  3. 子フォームBの【Android】アイコンボタンをクリックすると、メインフォームAにReceivedResponseMessageメッセージが応答され、メインフォームはダイアログを表示します。

3つのメッセージクラスは次のように定義されます。

public class OpenSecondViewMessage : Message
{
    public OpenSecondViewMessage(object sender) : base(sender)
    {
    }
}

public class SendRandomDataMessage : Message
{
    public SendRandomDataMessage(object sender, int number) : base(sender)
    {
        Number = number;
    }

    public int Number { get; set; }
}

public class ReceivedResponseMessage : Message
{
    public ReceivedResponseMessage(object sender) : base(sender)
    {
    }
}

SendRandomDataMessageはビジネス用のNumberプロパティを渡しますが、他の2つのメッセージは単に通知として機能します(そのため追加のプロパティはありません)。実際の開発では、ビジネスデータを渡す必要がある場合があります。

5.3.1 マルチフォームを開く

上記の最初の操作です。メインフォームAの【+】ボタンをクリックすると、OpenSecondViewMessageメッセージが送信され、子フォームBが開きます。

RazorViews\MainView.razorでボタンクリックを実行し、子フォームを開くメッセージを送信します。

...
<MCol>
    <MButton class="mx-2" Fab Dark Color="indigo" OnClick="OpenNewSecondView">
        <MIcon>mdi-plus</MIcon>
    </MButton>
</MCol>
...

@code{
...
void OpenNewSecondView()
{
    Messenger.Default.Publish(this, new OpenSecondViewMessage(this));
}
...
}

App.xaml.csで子フォームを開くメッセージを購読します。

public partial class App : Application
{
    public App()
    {
        // 子ウィンドウを開くメッセージを購読、メインウィンドウで【+】ボタンをクリック
        Messenger.Default.Subscribe<OpenSecondViewMessage>(this, msg =>
        {
            var chatWin = new SecondWindowView();
            chatWin.Show();
        }, ThreadOption.UiThread);
    }
}

実際の開発では状況がより複雑になる可能性があります。送信されるメッセージOpenSecondViewMessageにはWPFフォームのルート(フォームやViewModelを見つけるための定義済みパスルール)が含まれる場合があり、購読箇所がメインプログラムではなく、サブモジュールのModuleクラス内にある場合もあります。

5.3.2 ビジネスデータを送信する

2番目の操作です。子フォームBを開いた後、メインフォームAの【ハート】ボタンをクリックすると、SendRandomDataMessageメッセージが送信され、子フォームBの2番目のTabItem Headerにメッセージで送られた数字が表示されます。

  1. RazorViews\MainView.razorでボタンクリックを実行し、ビジネスメッセージ(現在時刻のMillisecond)を送信します。
...
<MCol>
    <MButton class="mx-2" Fab Small Dark Color="pink" OnClick="SendNumber">
        <MIcon>mdi-heart</MIcon>
    </MButton>
</MCol>
...

@code{
...
void SendNumber()
{
    Messenger.Default.Publish(this, new SendRandomDataMessage(this, DateTime.Now.Millisecond));
}
...
}
  1. RazorViews\SecondView.razorOnInitialized()メソッド内でビジネスメッセージ通知を購読します。
@using WPFBlazorChat.Messages
<MApp>
    <MToolbar>
        <MTabs BackgroundColor="primary" Grow Dark>
            <MTab>
                <MBadge Color="pink" Dot>
                    Item One
                </MBadge>
            </MTab>
            <MTab>
                <MBadge Color="green" Content="tagCount">
                    Item Two
                </MBadge>
            </MTab>
            <MTab>
                <MBadge Color="deep-purple accent-4" Icon="mi-masa">
                    Item Three
                </MBadge>
            </MTab>
        </MTabs>
    </MToolbar>
    
    <MRow>
        <MButton class="mx-2" Fab Dark Large Color="purple" OnClick="ReponseMessage">
            <MIcon>
                mdi-android
            </MIcon>
        </MButton>
    </MRow>
</MApp>

@code
{
    private int tagCount = 6;

    protected override void OnInitialized()
    {
        // ビジネスメッセージを購読、メインウィンドウでハートボタンがクリックされたときにトリガー
        Messenger.Default.Subscribe<SendRandomDataMessage>(this, msg =>
        {
            this.InvokeAsync(() => { this.tagCount = msg.Number; });
            this.StateHasChanged();
        }, ThreadOption.UiThread);
    }

    void ReponseMessage()
    {
        // メインフォームに通知:メッセージを受信したので、もう送信しないでください
        Messenger.Default.Publish(this, new ReceivedResponseMessage(this));
    }
}

注意:上記のメッセージ受信時には、2つのメソッドについて簡単に説明します。OnInitialized()内のコードを参照してください。

  • InvokeAsync:Numberを変数tagCountに代入するコードはInvokeAsyncメソッド内で実行されます。これはWPFのDispatcher.Invokeと同じ意味で、データの受信がサブスレッドで行われ、代入操作が即座に<MBadge Color="green" Content="tagCount">にバインドされるため、UIスレッドでの同期が必要です。
  • StateHasChanged:WPF MVVMのPropertyChangedイベント通知に相当し、UIに値が変更されたことを通知し、最新の値を表示するために更新するよう要求します。

上記のコードには子フォームからのメッセージ応答も含まれています。AndroidのアイコンボタンをクリックするとReceivedResponseMessageメッセージが送信され、メインフォームのRazorViews\MainView.razorでもこのメッセージを購読しています。上記のコードと同様です。

...
    <!--确认对话框开始-->
    <PConfirm Visible="_showComfirmDialog"
              Title="子窗体来回应了"
              Type="AlertTypes.Warning"
              OnCancel="() => _showComfirmDialog = false"
              OnOk="() => _showComfirmDialog = false">
        说你别没事一直发,它们烦!
    </PConfirm>
    <!--确认对话框结束-->
</MApp>

@code{
...
    // 確認ダイアログを表示するかどうか
    bool _showComfirmDialog;
    protected override void OnInitialized()
    {
        WindowService.Init();

        // 子フォームからの応答メッセージを購読、メッセージを受信したので少し休んでから再送信
        Messenger.Default.Subscribe<ReceivedResponseMessage>(this, msg =>
        {
            this.InvokeAsync(() => { _showComfirmDialog = true; });
            this.StateHasChanged();
        }, ThreadOption.UiThread);
        base.OnInitialized();
    }
...
}

OnInitialized()メソッド内でメッセージReceivedResponseMessageを購読し、受信後に変数_showComfirmDialogtrueに設定します。これはダイアログのプロパティVisibleにバインドされている値です。同様に、InvokeAsync()内でデータの受信を処理し、StateHasChangedを呼び出してUIにデータ変更を通知する必要があります。

上記の説明は一部のコードについてであり、不明瞭な点もあるかもしれません。このセクションのサンプルソースコードをご覧ください:マルチフォームメッセージ通知

6. この記事のサンプル

完全なデモの説明を書くつもりでしたが、基本的なポイントは上記で説明しました。重複したコードを貼り付けるのはきりがないので、ソースコードを取得してWPFとBlazorのハイブリッド開発デモをご覧いただき、実行してみてください。以下はプロジェクトのコード構成です。

デモコード構成

以下は最終的なサンプル効果図です。以前の記事で既に公開したものもありますが、再度掲載します。笑。

ユーザー一覧ウィンドウ

ユーザー一覧

子ウィンドウを開く

子ウィンドウを開く

チャットウィンドウ

チャットウィンドウ

メッセージ送信のデモ

7. Click Once 公開の試み

前回の記事リンク:ソフトウェアインストーラの高速作成 - ClickOnce。この記事のサンプル Click Once インストールページ:https://dotnet9.com/WPFBlazorChat

8. Q&A

8.1 なぜWPFでBlazorを使うのですか?暇すぎるのですか?

WPFはWinformに比べて比較的美しいUIを作成するのは比較的容易ですが、Blazor、あるいは直接言えばhtmlでのインターフェイス開発には及びません。さらに、htmlのリソースは豊富です。試してみて何が悪いのでしょうか?

8.2 WPF + BlazorはどのOSをサポートしていますか?

最低でもWindows 7 SP1をサポートします。一部の方がWindows 7で正常に動作することを確認しています。この記事のサンプル Click Once インストールページ:https://dotnet9.com/WPFBlazorChat

8.3 Blazor ハイブリッド開発は他にどのような既存フレームワークをサポートしていますか?

Blazor ハイブリッド開発は、WPFの他に、MAUI(クロスプラットフォームフレームワーク。Windows/Mac/Linux/Android/iOSなどをサポート)、Winform(WPFと同様にWindowsプラットフォームのみで動作)などをサポートしています。Microsoftのドキュメントを読んで学習を続けることをお勧めします。この記事はあくまできっかけです。

MicrosoftドキュメントでBlazorを学ぶ

8.4 BlazorコンポーネントライブラリはMasa.Blazorの他に何がありますか?

8.5 この記事のサンプルコードは?

記事内の各セクションのコード、および最終的なサンプルコードへのリンクは各所で提供しています。戻ってご確認ください。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2025/01/26

WPF カスタムXMLファイルによる国際化

この記事では、WPFプログラムでカスタムXMLファイルを使用して国際化を実現する方法について詳しく説明します。必要なNuGetパッケージのインストール、言語リストの動的取得、言語の動的切り替え、コードおよびXAMLインターフェースでの翻訳文字列の使用などを含み、ソースコードのリンクも提供し、開発者がWPFアプリケーションの国際化を簡単に実装できるように支援します。

続きを読む