【Gorgeous】Building a WPF+Blazor Chat App from Scratch

【Gorgeous】Building a WPF+Blazor Chat App from Scratch

Starting from a WPF Hello World program, gradually introduce Blazor to create a free and decent chat app for fun.

Last updated 11/7/2022 11:11 PM
沙漠尽头的狼
42 min read
Category
WPF Blazor
Tags
.NET C# Blazor WPF

Hello everyone, I'm the wolf at the end of the desert.

.NET is a free, cross-platform, open-source developer platform for building all kinds of applications.

This article demonstrates how to use Blazor in WPF to build beautiful UIs, injecting new energy into client-side development.

Note To enable WPF to support Blazor, the .NET version must be 6.0 or higher. All examples in this article use .NET 7.0. For version requirements, see the link. Screenshot shows the text below:

.NET version requirements

1. Default WPF Application

This article starts by creating a WPF Hello World program:

Create a default application using the WPF template, named WPFBlazorChat. The project structure is as follows:

Blank WPF project

Run the project, and an empty window appears:

WPF project blank window

Let's continue and add Blazor support. The code for this section is available at WPF default program source code.

2. Adding Blazor Support

Continuing with the same project, we'll add Blazor support. This section references the Microsoft documentation Build a Windows Presentation Foundation (WPF) Blazor app. This subsection will be brief.

2.1 Edit the project file

Double-click the project file WPFBlazorChat.csproj and make the following changes:

Project file changes comparison

  1. At the top of the project file, change the SDK to Microsoft.NET.Sdk.Razor.
  2. Add the node <RootNamespace>WPFBlazorChat</RootNamespace> to set the project namespace WPFBlazorChat as the root namespace of the application.
  3. Add the NuGet package Microsoft.AspNetCore.Components.WebView.Wpf, with the version depending on your .NET version.

2.2 Add the _Imports.razor file

The _Imports.razor file works like a global using file specifically for Razor components, placing commonly used namespaces to streamline code.

The content is as follows. It imports the namespace Microsoft.AspNetCore.Components.Web, which is a common Razor namespace containing types that provide information about browser events to the Blazor framework:

@using Microsoft.AspNetCore.Components.Web

2.3 Add the wwwroot\index.html file

Similar to Vue and React, an HTML file is needed to host Razor components. The page content looks like this:

<!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. The app.css file is defined below.
  2. Note <div id="app">Loading...</div> – this is where Razor components are hosted. All subsequently loaded Razor components will be rendered here.
  3. Other items are not important for now.

2.4 Add the wwwroot\css\app.css file

Basic styles for the page. Common styles can be placed in this file:

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 Add a Razor component

Add a classic Razor component Counter.razor. The Blazor Hello World program includes such a component. The file path is /RazorViews/Counter.razor. It's placed in the RazorViews directory to distinguish it from the common WPF Views directory. The component content is as follows:

<h1>Counter</h1>

<p>Happy, you clicked me! Now it's: <span style="color: red;">@currentCount</span></p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me quickly</button>

@code {
    private int currentCount = 0;

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

A button "Click me quickly" invokes @onclick="IncrementCount" to increment the variable currentCount, and the page displays the variable value. I think you can understand it.

2.6 Associating Blazor with the WPF Window

This is the key step to establish the relationship between the two. Open the form MainWindow.xaml and modify it as follows:

Form XAML changes

As shown in the code, the key points are:

  1. Add the namespace for the previously imported NuGet package Microsoft.AspNetCore.Components.WebView.Wpf, named blazor, mainly to use the BlazorWebView component.
  2. The BlazorWebView component's HostPage property specifies the hosting HTML file, and Services specifies the IoC container for Razor components. See the red-highlighted code in MainWindow() below.
  3. The RootComponent's Selector="#app" attribute indicates where the Razor component will be rendered. Refer to the HTML element with id app in index.html. The ComponentType specifies the type of Razor component to render in #app.

Open MainWindow.xaml.cs and modify it as follows:

Inject IoC container

In WPF, you can use IoC containers like Unity or DryIoc provided by frameworks such as Prism to inject views and services. For Razor components, the default container is ASP.NET Core's IServiceCollection. If the WPF window and Razor components need to share data, you can use the Messager (to be discussed later) to send messages, or inject data through the IoC container. For example, data injected from the WPF window (via the MainWindow constructor) can be re-injected via the IServiceCollection container for use by Razor components. This will also be mentioned later.

Data transfer between WPF and Razor components via IoC

After completing the above steps, run the program:

Default program with WPF integrated Blazor

OK, the integration of WPF and Blazor is successful. Are we done?

Wait, not yet. The source code for this section is available at Adding Blazor to WPF. Let's continue.

3. Custom Window

Default WPF window

The window border shown above is the default WPF style, which can sometimes be unattractive. Or, even if it's not ugly, designers may have other window style designs. Therefore, we often need to customize the window. This section shares some implementations of custom WPF and Blazor windows. For further customizations, you may need to research on your own.

3.1 WPF Custom Window

The typical implementation is to set three properties of the window: WindowStyle="None" AllowsTransparency="True" Background="Transparent". This hides the default window border, allowing you to draw your own title bar, minimize, maximize, close buttons, client area, etc., within the content area.

MainWindow.xaml: Hide the default WPF window border

<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>
        </blazor:BlazorWebView>
    </Grid>
</Window>

The code above only hides the default WPF window border. Running the program gives:

Hide default WPF window border

Notice that when you click the button inside the window (which is actually a Razor component button), the button click event does not execute, and the window disappears. What's going on? You can try to investigate why. I did not find a definitive answer, but temporarily I added a background to handle the penetration issue of BlazorWebView.

Simple WPF custom window style

Let's add some basic custom window styles:

Custom WPF window with basic styles

The MainWindow.xaml code is as follows:

<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="This is the window title bar. On the left you can put a logo, title, and on the right the window operation buttons: minimize, maximize, close, etc." />
                    <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>

We added a background Border for the entire window client area (you can remove the background color and try clicking the interface buttons). Then we nested a Grid to hold the custom title bar (title and window control buttons) and BlazorWebView (the browser component for rendering Razor components). Below are the event handlers for the window control buttons:

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

The code is simple, handling window minimize, maximize (restore), close, and double-click the title bar to maximize (restore). The above implementation is not a perfect custom window implementation, at least having these two issues:

  • When you maximize, the window fills the entire operating system desktop (including the taskbar area);
  • The two bottom corners of the window on the taskbar are not rounded (the area selected by the red rectangle). The author could not find a property or other method to make the BlazorWebView appear with rounded corners; the title bar area (selected by the green rectangle) is a WPF control, so the rounded corners display normally.

Window rounded corners

In the later subsection 3.4, the author uses a third-party library to solve the window rounded corner issue. For more good implementations of custom WPF windows, you can read this article: Three Implementations of Custom WPF Windows. The source code for this subsection is available at WPF Custom Window.

3.2 WPF Irregular-Shaped Window

Implementing irregular-shaped windows is relatively easy with WPF. I was going to write it, but it feels too far off-topic. Here's an article for you to check out: WPF Irregular-Shaped Window Demo. The irregular-shaped window effect is shown below:

WPF Irregular-Shaped Window

Now let's introduce a method that puts the window title bar inside the Razor component.

3.3 Blazor Implementation of Custom Window Effect

Above, we used WPF to create a custom window. But is there a need to place the menu in the title bar? That's easy; WPF can handle it well.

What about placing a Tab control? The Tab header is displayed in the title bar, and the TabItem is in the client area. The Tab header and TabItem share a consistent style, making it convenient to implement and maintain within a single set of code. So how is this achieved in the WPF + Blazor hybrid development? I believe that through the introduction of the Razor component as the title bar in this section, you can figure it out.

Restore the code in MainWindow.xaml, only hiding the default WPF window border and wrapping a background around the BlazorWebView:

WPF transparent window

The following code references the open-source project BlazorDesktopWPF-CustomTitleBar.

We'll implement the title bar in the Counter.razor component, i.e., placing the title bar and client area together in one component. Of course, you can separate them. For convenience, we'll demonstrate it here:

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">
        Test 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>Happy, you clicked me! Now it's: <span style="color: red;">@currentCount</span></p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me quickly</button>

@code {
    private int currentCount = 0;

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

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

The code explanation:

  1. The first div acts as the title bar area of the window. It registers a double-click event to call the window maximize (restore) method, and mouse down/up events to call the window move start/end methods.
  2. Inside the first div, three buttons control the window: minimize, maximize (restore), and close.
  3. There are two additional buttons demonstrating calling JavaScript's alert method on click.

WPF transparent window

Running effect:

WPF transparent window

To achieve this effect, there are also some other codes:

  1. The above code calls some methods to implement window operations like minimize, close, etc. The code is as follows:
  2. Since it's a Razor component (i.e., an HTML implementation), the HTML elements of the interface also define some CSS styles. The code is also provided.
  3. The buttons in the title bar use some SVG images, which are available in the repository.

Window Dragging

First, add the NuGet package Simplify.Windows.Forms to get the mouse cursor position:

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

Add the window helper class: 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);
    }
}

The above code implements window minimize, maximize (restore), close, etc. These methods need to be called correctly in the Razor component:

  1. In the Counter.razor component's OnInitialized lifecycle method, call WindowService.Init(); as shown above. This method starts a timer that periodically calls the UpdateWindowPos method to check if the mouse is pressed. If pressed, it checks the change in window position within the interval and then modifies the window position, thus moving the window (WPF's DragMove method cannot be used; try it and see what error it gives). If you have a better way to move the window, feel free to comment.

  2. The use of the window control buttons in the Razor component should be easy to understand from the code above; no further explanation is needed.

The style file for the above effect is modified as follows, in 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;
}

The above code implements the window title display, minimize, maximize (restore), close, and move operations using the Razor component. However, the issues mentioned at the end of 3.1 still exist, namely the window rounded corners and the window covering the taskbar when maximized. We'll try to solve these in the next subsection.

Section summary: From the above code, if you want to place a Tab control that fills the entire window, do you have an idea now?

The source code for this subsection is available at Razor component implementing window title bar functionality

3.4 A More Perfect Implementation of Blazor and WPF

Actually, the above code can be used for learning, even though it has significant flaws (haha). In this subsection, we'll use a third-party library to solve the window rounded corner and maximization issues.

First, add the NuGet package ModernWpfUI. This WPF control library is introduced at this link: Open Source WPF Control Library: ModernWpf:

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

Then, open App.xaml and reference the styles of the above open-source WPF controls:

<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>

Finally, open MainWindow.xaml and modify it as follows (mainly the introduced attributes 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>

With just these three changes, let's run it:

A more perfect solution for WPF and Blazor custom window

Is the effect the same as in 3.3? Actually, if you look closely, the bottom rounded corners of the window are now present:

Window rounded corners

In the end, WPF solves all problems...

I smiled

For the specifics of how the window maximization does not cover the taskbar and how the rounded corners are solved (making the BlazorWebView partially transparent), you can check the library's code. This article won't go into further detail.

Also, experienced WPF developers might notice that the previous code couldn't change the window size by dragging (I'll assume you didn't notice). Using this library, that issue is also resolved:

Manually resizing the window

The source code for this subsection is available at Solving rounded corners and maximization issues. Now we begin the second half of this article. I'm tired; finally, here we are.

I'm tired

4. Adding Third-Party Blazor Components

Sharp tools make good work!

Since many students may not have strong front-end skills, even though Blazor allows little to no JavaScript, a beautiful and convenient Blazor component library is like adding wings to a tiger. This article uses Masa Blazor as an example. There are many Blazor component libraries out there; choose the one you like and feel comfortable with:

Masa Blazor

The author previously introduced Using Masa Blazor Components in MAUI. This subsection follows a similar approach. Watch my performance.

Watch my performance

Open the Masa Blazor documentation site: https://blazor.masastack.com/getting-started/installation. Let's add this Blazor component library to WPF.

4.1 Add the Masa.Blazor Package

Open the project file WPFBlazorChat.csproj and directly copy the package version below, or search for Masa.Blazor in the NuGet Package Manager and install it:

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

4.2 Add Resources from Masa.Blazor

Open wwwroot\index.html and add the following resources inside the <head></head> tags:

<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>

The complete code is as follows:

<!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 Introduce the Masa.Blazor Namespace

Open the _Imports.razor file and modify it as follows:

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

4.4 Add Masa.Blazor to the Razor Component

Open MainWindow.xaml.cs and add the line serviceCollection.AddMasaBlazor();

Add Masa Blazor to IoC

4.5 Try Masa.Blazor Examples

After the preparation work in the 4 steps above, let's simply use the Masa.Blazor components.

Open the Tab component link: https://blazor.masastack.com/components/tabs. Let's try this demo:

Masa Blazor Tab component example

I'll almost directly copy the demo code. Open the RazorViews\Counter.razor file, keep the title bar from section 3.4, and replace the client area content. The code is as follows:

@using WPFBlazorChat.Services

<MApp>
    <!-- Title bar from previous section starts -->
    <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">
            Test 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>
    <!-- Title bar from previous section ends -->
    
    <!-- New Masa.Blazor Tab example code starts -->
    <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>
    <!-- New Masa.Blazor Tab example code ends -->
</MApp>

@code {

    #region Masa.Blazor Tab example C# code
    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();
    }
}

Running effect:

Masa Blazor Tab component example integration

Does it feel right? Now try moving the Tabs to the title bar, as previously mentioned:

Tab in title bar

For the above effect, the code is modified as follows. Remove the original title bar code and place the window operation buttons inside MToolbar, and add double-click event, mouse down, and release events to the MToolbar to enable window dragging:

<MApp>

    <!-- New Masa.Blazor Tab example code starts -->
    <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>
    <!-- New Masa.Blazor Tab example code ends -->
</MApp>

The background color of the window operation buttons is also partially modified:

Style partial modifications

Actually, there is still a minor flaw: notice the vertical scrollbar on the right side of the window? Before introducing Masa.Blazor, the right side displayed normally; after introducing it, an extra vertical scrollbar appears:

Extra vertical scrollbar after introducing Masa.Blazor

To remove it, simply add the following style to wwwroot\css\app.css (it took a while to figure out, but finally a group member in the Masa.Blazor community provided the solution, many thanks):

Problem solving process

The CSS code to solve the problem:

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

Since the Razor component is rendered inside BlazorWebView (which is essentially a small browser), the above style sets the scrollbar width to 0, making it disappear. Now the effect is as follows, isn't it more comfortable?

Interface after fixing

That concludes the introduction of Masa.Blazor. The sample code for this subsection is available at Using Masa.Blazor in WPF. Next, we'll discuss the issue of multi-window message notification in WPF and Blazor hybrid development.

5. Multi-Window Message Notification

Generally, communication between C/S (client-server) forms uses delegates and events. In WPF development, you can use abstract event publish/subscribe components provided by some frameworks, such as Prism's IEventAggregator or MvvmLight's Messager. In B/S (browser-server) development, in-process event notification often uses the MediatR component. Regardless of C/S or B/S development, these components can be used universally across different program templates. Of course, distributed message queues like RabbitMQ and Kafka are universal inter-process communication standards.

The above is some general talk. Based on reading the source code of Prism's event aggregator and MvvmLight's Messager, the author has simply encapsulated a Messager that can suit general business needs.

5.1 Messager Encapsulation

I originally didn't want to paste the code directly, but since it's not too much code, I'll just present it.

Message

An abstract class for defining message types. Specific messages need to inherit from this class, like the later OpenSecondViewMessage for opening a child window.

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

Message interface, defining three interfaces:

  1. Subscribe: Subscribe to a message
  2. Unsubscribe: Unsubscribe from a message
  3. Publish: Send a message
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

The implementation of message management and relaying:

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

If interested, the encapsulation code is simple and provided in full above. All subsequent message notifications are implemented based on these three classes, which are quite central.

5.2 Code Organization

Section 5 involves multiple windows and multiple Razor components, so it's necessary to create directories to store these files for easier classification.

Organized code

  1. A: Contains Message classes, i.e., some message notification classes.
  2. B: Contains Razor components. If you need to share Razor components with MAUI/Blazor Server (Wasm), you can create a Razor class library to store them.
  3. C: Contains common services. Here, only a static class for window management is placed. In real scenarios, you could place Redis services, RabbitMQ message services, etc.
  4. D: Contains WPF views. In this example, the WPF window is just a shell hosting the BlazorWebView.

5.3 Example and Code Explanation

First, look at the effect of this example, then the relevant code explanation:

Message notification example

There are three operations in the picture:

  1. Click the [+] button on main window A, which sends an OpenSecondViewMessage message, opening child window B.
  2. After opening child window B, click the [heart] button on main window A, which sends a SendRandomDataMessage message. The second TabItem Header of child window B displays the number sent with the message.
  3. Click the [Android] icon button on child window B, which sends a ReceivedResponseMessage message to main window A. Upon receiving it, main window A shows a dialog.

The definitions of the three message classes are as follows:

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)
    {
    }
}

Except for SendRandomDataMessage, which carries a business Number property, the other two messages are merely notifications (hence no additional properties). In real development, you may need to pass business data.

5.3.1 Opening Multiple Windows

This is the first operation above: clicking the [+] button on main window A sends an OpenSecondViewMessage message, opening child window B.

In RazorViews\MainView.razor, the button click executes and sends the message to open the child window:

...
<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));
}
...
}

In App.xaml.cs, subscribe to the OpenSecondViewMessage message:

public partial class App : Application
{
    public App()
    {
        // Subscribe to the open child window message, triggered by clicking the [ + ] button on the main window
        Messenger.Default.Subscribe<OpenSecondViewMessage>(this, msg =>
        {
            var chatWin = new SecondWindowView();
            chatWin.Show();
        }, ThreadOption.UiThread);
    }
}

In real development, the situation may be more complex. The sent OpenSecondViewMessage could carry WPF form routing (a set of rules to find the form or ViewModel), and the subscription location might not be in the main program, but in a submodule's Module class.

5.3.2 Sending Business Data

This is the second operation: after opening child window B, click the [heart] button on main window A, which sends a SendRandomDataMessage message. The second TabItem Header of child window B displays the number from the message.

  1. In RazorViews\MainView.razor, the button click executes and sends the business message (using the millisecond part of the current time):
...
<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. In RazorViews\SecondView.razor, in the OnInitialized() method, subscribe to the business message:
@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()
    {
        // Subscribe to business message, triggered when the heart button on the main window is clicked
        Messenger.Default.Subscribe<SendRandomDataMessage>(this, msg =>
        {
            this.InvokeAsync(() => { this.tagCount = msg.Number; });
            this.StateHasChanged();
        }, ThreadOption.UiThread);
    }

    void ReponseMessage()
    {
        // Notify the main window that we have received the message, please stop sending
        Messenger.Default.Publish(this, new ReceivedResponseMessage(this));
    }
}

Notice that when a message is received, there are two methods to mention briefly. See the code in OnInitialized():

  • InvokeAsync: The code that assigns Number to the variable tagCount is executed inside the InvokeAsync method. This is similar to WPF's Dispatcher.Invoke: the data is received on a background thread, but the assignment operation needs to be immediately bound to <MBadge Color="green" Content="tagCount">, which requires the UI thread to synchronize.
  • StateHasChanged: Equivalent to the PropertyChanged event notification in WPF MVVM. It notifies the UI that a value has changed and requests a refresh to display the latest value.

The child window's response message is also included: clicking the Android icon button sends the ReceivedResponseMessage message. The main window's RazorViews\MainView.razor also subscribes to this message, similar to the code above:

...
    <!-- Confirmation dialog starts -->
    <PConfirm Visible="_showComfirmDialog"
              Title="Child window responded"
              Type="AlertTypes.Warning"
              OnCancel="() => _showComfirmDialog = false"
              OnOk="() => _showComfirmDialog = false">
        They said don't keep sending, it's annoying!
    </PConfirm>
    <!-- Confirmation dialog ends -->
</MApp>

@code{
...
    // Whether to show the confirmation dialog
    bool _showComfirmDialog;
    protected override void OnInitialized()
    {
        WindowService.Init();

        // Subscribe to the response message from the child window; it has received the message, I can rest a bit before sending again
        Messenger.Default.Subscribe<ReceivedResponseMessage>(this, msg =>
        {
            this.InvokeAsync(() => { _showComfirmDialog = true; });
            this.StateHasChanged();
        }, ThreadOption.UiThread);
        base.OnInitialized();
    }
...
}

In the OnInitialized() method, subscribe to the ReceivedResponseMessage. Upon receiving it, set the variable _showComfirmDialog to true, which is the value bound to the dialog's Visible property. Similarly, data must be processed inside InvokeAsync(), and StateHasChanged must be called to notify the UI of data changes.

The above covers some code; it may not be explained very clearly. You can refer to the sample code for this section: Multi-window Message Notification.

6. Example for This Article

I originally intended to write a complete demo explanation, but I found that I've already covered the basic points above. Pasting repetitive code would be endless. If interested, pull the source code WPF and Blazor Hybrid Development Demo to view and run. Below is the project code structure:

Demo code structure

Below is the final example effect diagram. Some of these have already been shown earlier in the article, but I'll post them again, haha:

User list window

User list

Open child window

Open window

Chat window

Chat window

Demonstrate sending a message

7. Click Once Publishing Attempt

Link to the previous article: Quickly create software installation packages - ClickOnce. The Click Once installation page for this example: https://dotnet9.com/WPFBlazorChat

8. Q&A

8.1 Why use Blazor in WPF? Is it just for fun?

Although WPF can create relatively beautiful UIs more easily compared to WinForms, it still falls short compared to Blazor, or directly compared to HTML development. Moreover, HTML resources are more abundant. Why not give it a try?

8.2 Which operating systems does WPF + Blazor support?

Minimum support is Windows 7 SP1. Some community members have successfully run it on Windows 7. The Click Once installation page for this example: https://dotnet9.com/WPFBlazorChat

8.3 What other existing frameworks does Blazor hybrid development support?

Blazor hybrid development supports not only WPF, but also MAUI (a cross-platform framework supporting Windows, Mac, Linux, Android, iOS, etc.), WinForms (like WPF, only runs on Windows), etc. It is recommended to continue learning from the Microsoft documentation. This article is just a starting point:

Microsoft documentation learning Blazor

8.4 What other Blazor component libraries are there besides Masa.Blazor?

8.5 Source code for this article's examples?

Links to the code for each subsection and the final example code have been provided. You can go back and check.

Keep Exploring

Related Reading

More Articles
Same category / Same tag 1/26/2025

Implementing Internationalization in WPF Using Custom XML Files

This article details the method of implementing internationalization in WPF applications using custom XML files, including installing the necessary NuGet packages, dynamically retrieving the language list, dynamically switching languages, using translated strings in code and XAML interfaces, and provides a source code link to help developers easily achieve internationalization in WPF applications.

Continue Reading
Same category / Same tag 11/6/2024

Why My Blog Website Returned to Blazor

The development of the blog website has gone through many hardships, with nearly 10 versions including MVC, Vue, Go, etc. Now it has returned to Blazor and adopted static SSR, resulting in a significant speed increase and successful launch.

Continue Reading