Using ImageSharp in your WinUI – Uno Cross Platform App

This post originated from a tweet demonstrating how easily you can use the ImageSharp library in a Blazor server side app to generate images. In this post I’ll show how we can reuse the same code plus WinUI+Uno to generate images for iOS, Android, Windows (Desktop+UWP) and Web (WASM)

I figured it’d be easy enough to replicate this in a WinUI+Uno cross platform application. I’d like to say that this was trivial but there were definitely a few stuck points along the way that I’ll walk through.

New Project

To get started we’ll use the WinUI dotnet template that’s available for Uno. The Uno dotnet templates can be installed using the following command:

dotnet new -i Uno.ProjectTemplates.Dotnet

Note: I’ve recently installed the .NET 6 Preview 1, which means the default SDK is 6.0.100-preview.1. You can check this by running “dotnet –info” from a command prompt.

Why this is important is that dotnet templates are installed per sdk, which means when I came to create the new solution I couldn’t see any of the Uno templates, despite having previously installed them. You can list the available dotnet template by running “dotnet new”.

Luckily, there’s a very easy way to switch SDKs using a global.json file. Rather than trying to remember, or looking up what the contents should be, dotnet has a command for generating a global.json file:

dotnet new globaljson --sdk-version <version number of sdk>

You can list the available dotnet SDKs by running “dotnet –list-sdks”.

Now that we’ve got our global.json file in place, we can list the available template using the command “dotnet new”. In this case we’re going to use the unoapp-winui template.

At the time of writing the unoapp-winui template still references preview3 of WinUI. So, before going any further, we need to upgrade this to preview4 (current version at time of writing). To do this, I recommend following the instruction provided on the GitHub repository. This involves

  • Upgrading the WinUI package (in both UWP and Desktop projects) by running “install-package Microsoft.WinUI -Version 3.0.0-preview4.210210.4” in the Package Manager Console.
  • Removing the PublishTrimmed attribute in each of the xml files in the Properties\PublishProfiles folder of the Desktop project
  • Remove the SDKReference elements from the Windows Application Packaging Project file.

At this point I also go through and upgrade any other NuGet packages (except the Microsoft.Extensions.* packages as the latest versions of these aren’t supported by Uno yet).

Add Package ImageSharp

In order to generate the images for the weather service (see tweet above) we’re going to us the SixLabors.ImageSharp library. We’ll go ahead and add the reference to the NuGet package to each of the projects, except the Windows Application Packaging project.

Weather Service

Next, we need to implement our mock weather service that will consist of WeatherForecast and WeatherForecaseService classes.

namespace UnoImageSharpWeather
{
    public class WeatherForecast
    {
        public DateTime DateTime { get; set; }
        public int TemperatureC { get; set; }
        public string Summary { get; set; }
        public byte[] Image { get; set; }
    }
    public class WeatherForecastService
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool","Mild","Warm","Balmy","Hot","Sweltering","Scorching"
        };

        public Task<WeatherForecast[]> GetForecastAsync(DateTime startDate)
        {
            var rng = new Random();
            var buffer = new byte[4];
            using var image = new Image<Rgba32>(75, 75);
            for (int y = 0; y < image.Height; y++)
            {
                var row = image.GetPixelRowSpan(y);
                for (int x = 0; x < row.Length; x++)
                {
                    rng.NextBytes(buffer);
                    row[x] = new Rgba32(buffer[0], buffer[1], buffer[2], buffer[3]);
                }
            }

            byte[] retImageBytes;
            using (var memoryStream = new MemoryStream())
            {
                image.SaveAsPng(memoryStream);
                retImageBytes = memoryStream.ToArray();
            }

            return Task.FromResult(Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                DateTime = startDate.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)],
                Image = retImageBytes 
            }).ToArray());
        }
    }
}

Page Layout

For the layout of the weather forecasts we’re just going to use a ListView with a StackPanel showing a TextBlock with the Summary of the weather and an Image which will show the generated image.

<Page
    x:Class="UnoImageSharpWeather.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:UnoImageSharpWeather"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">

    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <Grid.Resources>
            <local:ImageLoadingConverter x:Key="ImageLoadingConverter" />
        </Grid.Resources>
        <ListView x:Name="WeatherList">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <StackPanel>
                        <TextBlock Text="{Binding Summary}" />
                        <Image DataContext="{Binding Converter={StaticResource ImageLoadingConverter}}"
                               Source="{Binding Image}"
                               Height="75"
                               Width="75" />
                    </StackPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </Grid>
</Page>

In the codebehind for the page we’re just going to create and instance of the WeatherForecastService and call the GetForecastAsync method. Note that in this case we’re keeping things simple and simply setting the ItemsSource on the ListView. In a real project we’d be creating a view model for the page and then databinding to this property.

protected override async void OnNavigatedTo(NavigationEventArgs e)
{
    base.OnNavigatedTo(e);

    var weather = await (new WeatherForecastService()).GetForecastAsync(DateTime.Now);
    WeatherList.ItemsSource = weather;
}

Image Loading

If you look back at the XAML you’ll notice that we’re using an ImageLoadingConverter on the DataContext for the Image element. This is a work around for the limitations in XAML to have a converter that does asynchronous actions. In this case we need to convert from a byte array, containing the image data, to a BitmapImage. This needs to be done asynchronously using the SetSourceAsync method (there is a SetSource method but this causes the application to hang).

public class ImageLoadingConverter : IValueConverter
{
    public class ImageLoader : System.ComponentModel.INotifyPropertyChanged
    {
        private byte[] bytes;
        public BitmapImage Image { get; set; }
        public ImageLoader(byte[] imageBytes)
        {
            bytes = imageBytes;
        }

        public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;

        public async Task Build()
        {
            var img = new BitmapImage();
            var ms = new MemoryStream(bytes);
            await img.SetSourceAsync(ms.AsRandomAccessStream());
            Image = img;
            PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Image)));
        }
    }

    public object Convert(object value, Type targetType, object parameter, string language)
    {
        if (!(value is WeatherForecast forecast))
            return null;

        var bytes = forecast.Image;


        var loader = new ImageLoader(bytes);
        loader.Build();
        return loader;
    }

    public object ConvertBack(object value, Type targetType, object parameter, string language)
    {
        throw new NotImplementedException();
    }
}

The way that the ImageLoadingConverter works is that it returns an instance of the ImageLoader class that has an asynchronous method, Build, which will convert the byte array to a BitmapImage. The important point is that at the end of the Build method, it raises the PropertyChanged event, causing the binding engine to re-query the Image property (this property would have initially been null).

LangVersion

As you build and run each of the target platforms, you may come across the following error:

error CS8370: Feature 'using declarations' is not available in C# 7.3. Please use language version 8.0 or greater.

This is because the code (taken from the referenced tweet) makes use of the single line using statement. To fix this, you just need to add the LangVersion element to the csproj file.

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    ...
    <LangVersion>9.0</LangVersion>
  </PropertyGroup>
  ...

WinUI for UWP Doesn’t Work

When you run the UWP project you’ll see that the images never show.

Unfortunately this is a limitation of WinUI for UWP where the INotifyPropertyChanged interface has been reimplemented in a different namespace. Until .NET 5 (or 6) is supported in a UWP project, this issue won’t go away. One workaround is to have the ImageLoader class implement the INotifyPropertyChanged interface from both the System.ComponentModel (the correct namespace) and the Microsoft.UI.Xaml.Data (WinUI implementation) namespaces. This seems like a bit of a waste of time given the unlikely future of WinUI on UWP (please see roadmap for WinUI that doesn’t have a ship date for this feature!).

Summary

You should be able to build and run this app on the various platforms supported by Uno (I haven’t tried MacOS or the Skia based heads – let me know if you do and if there are any issues).

Android
Web (WASM)

Note: For some reason every time I went to run the app on the iOS simulator, my MacBook had decided it was a good time to install an update. This resulted in an error being reported in the remote simulator UI “A fatal error has occurred while trying to start the server“. The work around is to reboot the Mac, as there’s probably some update that is pending.