Nowadays, a very common scenario is that you have your WPF app ready and running for some time, and your boss tells you that it's time to go further and port this app to the web, to have a larger market and be run in multiple platforms.
This is not a simple thing, because WPF is not multi-platform and, although it was ported to .NET Core, it's still a Windows Platform.
You can think of rewriting the whole app, but even if you decide to use Asp.Net Core and Razor pages, or Blazor, you will have a huge effort to do it, because writing the UI in these platforms is completely different than using XAML.
Xamarin is a viable alternative, it's close to WPF and it uses XAML, but it's not a web platform, you can only write native apps for iOs, Android or Mac.
But things are not lost, at all. The guys at Uno Platform (https://www.platform.uno) created a nice project that uses WebAssembly to run the code you've created on the browser.
And what is WebAssembly? WebAssembly is a technology that allow browsers to run non-javascript code. When people hear this, they usually think of browsers plugins, like Silverlight or Adobe Flash. In fact, these are completely different. While the plugins were apps created to run in the browser, they were installed and supported by the vendors, and could suffer all sort of security vulnerabilities and were, at some point, abandoned by the browsers.
WebAssembly, on the other side, is an open standard fully supported by the browsers and a web standard. You can create code in may languages and compile it into a wasm module, thus running your C++/Rust code in a browser. This link shows a list of languages that are used in WebAssembly.
Uno Platform uses WebAssembly to run UWP code in the web. So, if you have an UWP program, you can compile it with Uno and run it on the web, with minimal changes. That's great when you want to port your UWP app to the web.
You may have noticed that in the previous paragraph I didn't mention WPF, but only UWP/WinUI. Uno Platform works with UWP projects, and not WPF. But UWP/WinUI is still XAML and has many similarities with WPF. So, while it's not a direct port, it's not a complete rewrite either. Let's see what must be done to port a WPF program to the web using Uno Platform.
For this article, I will be using the WPF project that shows how to use MVVM with the MVVM Toolkit, which I've shown in this article. The source code for this article is here.
Installing UNO Platform
To install the UNO Platform in Visual Studio, you need to have the UWP, Xamarin and Asp.NET workloads installed. You can open the Visual Studio installer and verify if the three workloads are installed:
One other prerequisite is to have .NET 5 SDK or later installed. If you haven't done so, you can install .NET 5 from https://dotnet.microsoft.com/download/dotnet-core/5.0 or .NET 6 from https://dotnet.microsoft.com/en-us/download/dotnet/6.0.
Once they are installed, in Visual Studio, go to Extensions > Manage Extensions and install Uno Platform Templates.
With the extension installed, you can create a new solution using Uno:
We will create a new Multi-Platform App (Uno Platform|.net 6).
When you create the app, you will see multiple projects there:
As you can see, there are projects for Mobile (Android, iOS and Mac, GTK, WPF, UWP and Wasm). We will be using the Wasm project, so you should select it as the startup project. When you run it, it will open a browser window with the app:
It doesn't seem too much, but when you think that the underlying code is XAML, that's pretty nice!
Converting the WPF Project
As you can see in the Solution Explorer, there is a project .Shared, that contains the shared resources for our project. Putting your pages here will share the pages for all projects in the solution.
Before we port the solution, we should port the customer lib, that contains the classes and the repository. In fact this is an easy port, we don't have to do anything, as it's already a .NET Standard library.
Just add the files for the project in the same folder of the other projects and add the new project to the solution.
After that, you can add the project as a reference to the other projects you are creating.
Then, you should create the ViewModel for the project. Before that, we will have to add the package Microsoft.Toolkit.Mvvm to all the projects you are using. In the Solution Explorer, right-click the dependencies node in the Wasm project and select Manage Nuget Packages and add the Microsoft.Toolkit.Mvvm package. You must also add the reference to the other projects.
Then, create a folder named ViewModel in the Shared project and add the file MainViewModel.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using CustomerLib;
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Input;
namespace MVVMUnoApp.ViewModel
{
public class MainViewModel : ObservableObject
{
private readonly ICustomerRepository _customerRepository;
private readonly IEnumerable<Customer> _allCustomers;
private Customer _selectedCustomer;
public MainViewModel(ICustomerRepository customerRepository)
{
_customerRepository = customerRepository ??
throw new ArgumentNullException("customerRepository");
_allCustomers = _customerRepository.Customers;
AddCommand = new RelayCommand(DoAdd);
RemoveCommand = new RelayCommand(DoRemove, () => SelectedCustomer != null);
SaveCommand = new RelayCommand(DoSave);
SearchCommand = new RelayCommand<string>(DoSearch);
}
public IEnumerable<Customer> Customers {get; private set;}
public Customer SelectedCustomer
{
get => _selectedCustomer;
set
{
SetProperty(ref _selectedCustomer, value);
RemoveCommand.NotifyCanExecuteChanged();
}
}
public IRelayCommand AddCommand { get; }
public IRelayCommand RemoveCommand { get; }
public IRelayCommand SaveCommand { get; }
public IRelayCommand<string> SearchCommand { get; }
private void DoAdd()
{
var customer = new Customer();
_customerRepository.Add(customer);
SelectedCustomer = customer;
OnPropertyChanged("Customers");
}
private void DoRemove()
{
if (SelectedCustomer != null)
{
_customerRepository.Remove(SelectedCustomer);
SelectedCustomer = null;
OnPropertyChanged("Customers");
}
}
private void DoSave()
{
_customerRepository.Commit();
}
private void DoSearch(string textToSearch)
{
if (!string.IsNullOrWhiteSpace(textToSearch))
Customers = _allCustomers.Where(c => ((Customer)c).Country.ToLower().Contains(textToSearch.ToLower()));
else
Customers = _allCustomers;
OnPropertyChanged("Customers");
}
}
}
This file is almost the same as the one in the original project, with a change in DoSearch due to the fact that the CollectionViewSource works in a different way in UWP/WinUI than in WPF. For that, we removed the code that filters CollectionViewSource:
private void DoSearch(string textToSearch)
{
var coll = CollectionViewSource.GetDefaultView(Customers);
if (!string.IsNullOrWhiteSpace(textToSearch))
coll.Filter = c =>
((Customer)c).Country.ToLower().Contains(textToSearch.ToLower());
else
coll.Filter = null;
}
And did an explicit filter on the customer list returned by the repository.
The next step is to add the views to the Shared project. The main view uses a DataGrid that doesn't exist in UWP/WinUI, but there is a solution, here: add the DataGrid of the Windows Control Toolkit, that works fine in UWP/WinUI. For that, add the NuGet reference to Uno.Microsoft.Toolkit.Uwp.UI.Controls.Datagrid to all the used projects. In the UWP project, you should not add this reference, but add Microsoft.Toolkit.Uwp.UI.Controls.Datagrid.
Then, change the code in MainPage.xaml in the Shared project to:
<Page
x:Class="MVVMUnoApp.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.Toolkit.Uwp.UI.Controls"
xmlns:local="using:MVVMUnoApp"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="40" />
<RowDefinition Height="*" />
<RowDefinition Height="2*" />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Country" VerticalAlignment="Center" Margin="5"/>
<TextBox x:Name="searchText" VerticalAlignment="Center" Margin="5,3" Width="250" VerticalContentAlignment="Center"/>
<Button x:Name="PesqBtn" Content="Find" Width="75" Margin="10,5" VerticalAlignment="Center"
Command="{Binding SearchCommand}" CommandParameter="{Binding ElementName=searchText,Path=Text}"/>
</StackPanel>
<controls:DataGrid AutoGenerateColumns="False" x:Name="master" Grid.Row="1"
ItemsSource="{Binding Customers}" SelectedItem="{Binding SelectedCustomer, Mode=TwoWay}">
<controls:DataGrid.Columns>
<controls:DataGridTextColumn x:Name="customerIDColumn" Binding="{Binding CustomerId}" Header="Customer ID" />
<controls:DataGridTextColumn x:Name="companyNameColumn" Binding="{Binding CompanyName}" Header="Company Name" Width="160" />
<controls:DataGridTextColumn x:Name="contactNameColumn" Binding="{Binding ContactName}" Header="Contact Name" Width="160" />
<controls:DataGridTextColumn x:Name="contactTitleColumn" Binding="{Binding ContactTitle, Mode=TwoWay}" Header="Contact Title" />
<controls:DataGridTextColumn x:Name="addressColumn" Binding="{Binding Address}" Header="Address" Width="130" />
<controls:DataGridTextColumn x:Name="cityColumn" Binding="{Binding City}" Header="City" />
<controls:DataGridTextColumn x:Name="regionColumn" Binding="{Binding Region}" Header="Region" />
<controls:DataGridTextColumn x:Name="postalCodeColumn" Binding="{Binding PostalCode}" Header="Postal Code" />
<controls:DataGridTextColumn x:Name="countryColumn" Binding="{Binding Country}" Header="Country" />
<controls:DataGridTextColumn x:Name="faxColumn" Binding="{Binding Fax}" Header="Fax" Width="100" />
<controls:DataGridTextColumn x:Name="phoneColumn" Binding="{Binding Phone}" Header="Phone" Width="100" />
</controls:DataGrid.Columns>
</controls:DataGrid>
<local:Detail Grid.Row="2" DataContext="{Binding ElementName=master, Path=SelectedItem, Mode=OneWay}" Margin="5" x:Name="detail"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" Grid.Row="3">
<Button Width="75" Margin="5,0" Content="Add" Command="{Binding AddCommand}" />
<Button Width="75" Margin="5,0" Content="Remove" Command="{Binding RemoveCommand}" />
<Button Width="75" Margin="5,0" Content="Save" Command="{Binding SaveCommand}" />
</StackPanel>
</Grid>
</Page>
In this case, there were almost no changes to do: the Toolkit DataGrid is very similar to the one in WPF. We must set the DataContext property in MainPage.xaml.cs:
public MainPage()
{
this.InitializeComponent();
DataContext = App.Current.MainVM;
}
We are using the same code we've used in the WPF project. For that, we must use the Dependency Injection provided in the MVVM toolkit in App.xaml.cs:
public App()
{
InitializeLogging();
this.InitializeComponent();
var services = new ServiceCollection();
services.AddSingleton<ICustomerRepository, CustomerRepository>();
services.AddSingleton<MainViewModel>();
Services = services.BuildServiceProvider();
#if HAS_UNO || NETFX_CORE
this.Suspending += OnSuspending;
#endif
}
public new static App Current => (App)Application.Current;
public IServiceProvider Services { get; }
public MainViewModel MainVM => Services.GetService<MainViewModel>();
As you can see, there are no changes here.
The next step is to add the Details page, which has almost not changes from the WPF project:
<UserControl
x:Class="MVVMUnoApp.Detail"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="detailControl"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="300"
d:DesignWidth="400">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Customer Id:" Grid.Column="0" Grid.Row="0" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="0" Margin="3" Name="customerIdTextBox" Text="{Binding CustomerId, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Company Name:" Grid.Column="0" Grid.Row="1" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="1" Margin="3" Name="companyNameTextBox" Text="{Binding CompanyName, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Contact Name:" Grid.Column="0" Grid.Row="2" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="2" Margin="3" Name="contactNameTextBox" Text="{Binding ContactName, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Contact Title:" Grid.Column="0" Grid.Row="3" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="3" Margin="3" Name="contactTitleTextBox" Text="{Binding ContactTitle, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Address:" Grid.Column="0" Grid.Row="4" HorizontalAlignment="Left" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="4" Margin="3" Name="addressTextBox" Text="{Binding Address, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="City:" Grid.Column="0" Grid.Row="5" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="5" Margin="3" Name="cityTextBox" Text="{Binding City, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Postal Code:" Grid.Column="0" Grid.Row="6" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="6" Margin="3" Name="postalCodeTextBox" Text="{Binding PostalCode, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Region:" Grid.Column="0" Grid.Row="7" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="7" Margin="3" Name="regionTextBox" Text="{Binding Region, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Country:" Grid.Column="0" Grid.Row="8" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="8" Margin="3" Name="countryTextBox" Text="{Binding Country, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Phone:" Grid.Column="0" Grid.Row="9" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="9" Margin="3" Name="phoneTextBox" Text="{Binding Phone, Mode=TwoWay}" VerticalAlignment="Center" />
<TextBlock Text="Fax:" Grid.Column="0" Grid.Row="10" Margin="3" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="10" Margin="3" Name="faxTextBox" Text="{Binding Fax, Mode=TwoWay}" VerticalAlignment="Center" />
</Grid>
</UserControl>
The only change is to remove the ValidatesOnExceptions=true and NotifyOnValidationError=true from the textboxes, because UWP/WinUI doesn't have native validation. If you want to add validation to UWP/WinUI, you should check the Template10 Validation project.
Now, the project is ok and ready to be run. When you run it, it shows the main window, but doesn't show any data:
That's really strange. If you change the startup project to UWP and run it, it runs fine:
We should investigate a little more. When using the Wasm project, we have a web application. In this case, do the web tools work ? Run the Wasm project again and press F12 to open the dev tools:
As you can see, we have the diagnostic tools available and they show us that Customers.xml is not found. That makes sense, as we're not running a local app, but a web app, and local files aren't available. We should use another mechanism to get our data. Searching in the internet, we come to this site, which says that the Storage files available in UWP are also available in Wasm, so we should change our code from
var doc = XDocument.Load("Customers.xml");
To
var file = await Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Customers.xml"));
var content = await FileIO.ReadTextAsync(file);
var doc = XDocument.Parse(content);
Unfortunately, this way is not supported in Wasm, when using a library file. We have two solutions here:
- Create a server that will serve the data and use REST calls to the server in the repository
- Add the repository files to the Shared project and use the UWP storage method
There is no doubt that in a normal project I would go to the first solution. It's the way to go when you are creating web applications, it's more flexible and extensible. But, as I'm only showing you how to port a small WPF project to the web and I don't want to create a server and change the repository now (it would be beyond the scope of the article), I won't do it (if you want to see that solution, write in the comments, if there are enough requests, I will do it 😃).
So, what we'll do is to create a new folder named Repository in the Shared project and add all the files that are in the library to this folder. After that, you can delete the library from the solution.
If you run the application, you still won't see the data, but the error will disappear. The issue now is that we have a synchronization problem: the data now is obtained in async mode (in the original project it was obtained in sync mode). While that is happening, the ViewModel is being built and asks for the customers, which haven't been read, so the _allCustomers field will be empty and nothing will be shown.
To fix this issue, we must have a function named GetCustomersAsync that retrieves the customers and use it in the ViewModel.
The ICustomerRepository interface will be:
public interface ICustomerRepository
{
bool Add(Customer customer);
bool Remove(Customer customer);
bool Commit();
Task<IEnumerable<Customer>> GetCustomersAsync();
}
And the CustomerRepository class becomes:
public class CustomerRepository : ICustomerRepository
{
private IList<Customer> customers;
public async Task<IEnumerable<Customer>> GetCustomersAsync()
{
var file = await Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Customers.xml"));
var content = await FileIO.ReadTextAsync(file);
var doc = XDocument.Parse(content);
customers = new ObservableCollection<Customer>((from c in doc.Descendants("Customer")
select new Customer
{
CustomerId = GetValueOrDefault(c, "CustomerID"),
CompanyName = GetValueOrDefault(c, "CompanyName"),
ContactName = GetValueOrDefault(c, "ContactName"),
ContactTitle = GetValueOrDefault(c, "ContactTitle"),
Address = GetValueOrDefault(c, "Address"),
City = GetValueOrDefault(c, "City"),
Region = GetValueOrDefault(c, "Region"),
PostalCode = GetValueOrDefault(c, "PostalCode"),
Country = GetValueOrDefault(c, "Country"),
Phone = GetValueOrDefault(c, "Phone"),
Fax = GetValueOrDefault(c, "Fax")
}).ToList());
return customers;
}
#region ICustomerRepository Members
public bool Add(Customer customer)
{
if (customers.IndexOf(customer) < 0)
{
customers.Add(customer);
return true;
}
return false;
}
public bool Remove(Customer customer)
{
if (customers.IndexOf(customer) >= 0)
{
customers.Remove(customer);
return true;
}
return false;
}
public bool Commit()
{
try
{
var doc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
var root = new XElement("Customers");
foreach (Customer customer in customers)
{
root.Add(new XElement("Customer",
new XElement("CustomerID", customer.CustomerId),
new XElement("CompanyName", customer.CompanyName),
new XElement("ContactName", customer.ContactName),
new XElement("ContactTitle", customer.ContactTitle),
new XElement("Address", customer.Address),
new XElement("City", customer.City),
new XElement("Region", customer.Region),
new XElement("PostalCode", customer.PostalCode),
new XElement("Country", customer.Country),
new XElement("Phone", customer.Phone),
new XElement("Fax", customer.Fax)
));
}
doc.Add(root);
doc.Save("Customers.xml");
return true;
}
catch (Exception)
{
return false;
}
}
#endregion
private static string GetValueOrDefault(XContainer el, string propertyName)
{
return el.Element(propertyName)?.Value ?? string.Empty;
}
}
To use this repository, we should change the ViewModel to:
private readonly ICustomerRepository _customerRepository;
private IEnumerable<Customer> _allCustomers;
private Customer _selectedCustomer;
public MainViewModel(ICustomerRepository customerRepository)
{
_customerRepository = customerRepository ??
throw new ArgumentNullException("customerRepository");
GetCustomers();
AddCommand = new RelayCommand(DoAdd);
RemoveCommand = new RelayCommand(DoRemove, () => SelectedCustomer != null);
SaveCommand = new RelayCommand(DoSave);
SearchCommand = new RelayCommand<string>(DoSearch);
}
private async void GetCustomers()
{
_allCustomers = await _customerRepository.GetCustomersAsync();
Customers = _allCustomers;
OnPropertyChanged("Customers");
}
We must call the async method in the ViewModel constructor and, in this case, we cannot await for the call, so we fire the call and, when the data is ready, it fires the INotifyPropertyChanged event and populates the data.
If we run the program, we see that it opens a new browser window with the program:
We've ported our WPF program to the web using Uno Platform. Nice, no ? We can leverage our XAML expertise and port it to the web. It's not a direct change but, nevertheless, it's way less trouble than a complete rewrite. Uno Platform did a great work to port UWP to WebAssembly! And you get some extra bonuses: you still have an UWP and a WPF project running with the same code. The original Uno template also allows you to run the code in Android or iOs (but I removed these from the code for this article).
The full code for this article is in https://github.com/bsonnino/MVVMUnoApp