Simple WinUI + Uno Calculator

A week or so ago David from the XF/Maui team kicked of a thread on twitter challenging devs to add a simple addition to a simple calculator. I particularly liked the transition animation that Robin added, so figured I’d translate this to a WinUI+Uno platform application. I’m going to try to keep as close to … Continue reading “Simple WinUI + Uno Calculator”

A week or so ago David from the XF/Maui team kicked of a thread on twitter challenging devs to add a simple addition to a simple calculator. I particularly liked the transition animation that Robin added, so figured I’d translate this to a WinUI+Uno platform application.

I’m going to try to keep as close to the original source code as possible. I did make some additional changes to leverage some of the features of WinUI, such as XAML animations and theme resources.

Dotnet New

Let’s kick off with creating a new WinUI+Uno project – I’ll use the dotnet template.

The unoapp-winui template generates head projects for each of the supported Uno platforms. Rather than using UWP as the basis for the cross platform APIs, this template uses WindowsUI as the basis. What this means is that the Windows.Desktop project is just a .net5 application with WinUI as the UI framework.

The next step is to add the five different theme files and the SimpleCalculator class – I did this by cloning the SimpleCalculator repo (I actually grabbed Robin’s fork), opening the solution in file explorer and simply dragging the files across into the Shared project in Visual Studio.

After adding these file, we had to update the namespace from SimpleCalculator to SimpleWinUICalculator.

Themes

Each of the five themes defines PrimaryColor and SecondaryColor resources that are Color instances.

<?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary
    xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    x:Class="SimpleCalculator.Common.Styles.ClayTheme">
    <Color x:Key="PrimaryColor">#7D1424</Color>
    <Color x:Key="SecondaryColor">#FFFCE6</Color>
</ResourceDictionary>

Unlike Xamarin.Forms, which uses a Color to specify BackgroundColor and TextColor, WinUI uses a Brush to define Background and Foreground colours. WinUI also supports both Dark and Light themes, allowing developers to specify colours and other resources for each theme.

As part of including the five themes we’re going to exchange Color for SolidColorBrush resources. We’re also going to define Default, Dark and Light theme dictionaries. For example, the ClayTheme now looks like

<ResourceDictionary x:Class="SimpleWinUICalculator.Common.Styles.ClayTheme"
                    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                    mc:Ignorable="d">
    <ResourceDictionary.ThemeDictionaries>
        <ResourceDictionary x:Key="Default">
            <SolidColorBrush x:Key="PrimaryColor">#7D1424</SolidColorBrush>
            <SolidColorBrush x:Key="SecondaryColor">#FFFCE6</SolidColorBrush>
        </ResourceDictionary>
        <ResourceDictionary x:Key="Dark">
            <SolidColorBrush x:Key="PrimaryColor">#7D1424</SolidColorBrush>
            <SolidColorBrush x:Key="SecondaryColor">#FFFCE6</SolidColorBrush>
        </ResourceDictionary>
        <ResourceDictionary x:Key="Light">
            <SolidColorBrush x:Key="PrimaryColor">#FFFCE6</SolidColorBrush>
            <SolidColorBrush x:Key="SecondaryColor">#7D1424</SolidColorBrush>
        </ResourceDictionary>
    </ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>

In this case we’ve simply exchanged the PrimaryColor and SecondaryColor RGB values in order to define Dark and Light themes – in most cases this isn’t sufficient and you’ll need to tweak the colors to make sure there is sufficient contrast and that everything is readable in both themes.

Also note that the namespaces have been updated to remove the default Xamarin.Forms namespace (i.e. http://xamarin.com/schemas/2014/forms) and add the WinUI namespace (i.e. http://schemas.microsoft.com/winfx/2006/xaml/presentation). We’ve also remove the <?xml element from the beginning of the file.

The App.xaml file then needs to be updated to reference one of the theme resource files. Note that we’re adding the theme resource into the MergedDictionaries collection to ensure the resources are correctly merged with the other application resources.

<Application x:Class="SimpleWinUICalculator.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="using:SimpleWinUICalculator">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Common/Styles/ClayTheme.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

XAML Changes

Now that we’ve added the themes, it’s time to add the XAML to the MainPage. To do this I’m simply going to copy the entire XAML between the ContentPage tags from the MainPage of the Xamarin.Forms application, replacing the contents of the Page tag on the MainPage of the WinUI app.

Of course, simply copying the XAML isn’t going to work since there are a large number of subtle differences in the XAML syntax. Here’s a quick summary of the fixes that we applied:

  • Change ContentPage.Resources to Page.Resources and remove the inner ResourceDictionary element as this isn’t required
  • Change all uses of DynamicResource to ThemeResources – we’ll come back to this point later when we discuss how we’re going to dynamically adjust the colours used through the app.
  • Remove ContentPage.Padding and replace it with ios specific padding on the Page element. This is done by importing the ios namespace and then defining the ios:Padding attribute. Note that we’ve also added ios to the list of Ignorable namespaces to ensure the XAML compiles for all platforms.
<Page x:Class="SimpleWinUICalculator.MainPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:local="using:SimpleWinUICalculator"
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
      xmlns:ios="http://uno.ui/ios"
      ios:Padding="0,20,0,0"
      mc:Ignorable="d ios">
  • Change HorizontalOptions attribute to HorizontalAlignment and the corresponding values from Start/End to Left/Right
  • Change VerticalOptions attribute to VerticalAlignment and the corresponding values from Start/End to Top/Bottom
  • Change TextColor attribute to Foreground
  • Change BackgroundColor attribute to Background
  • Change LineBreakMode to TextWrapping
  • Change VerticalTextAlignment to TextAlignment – this didn’t work across all Uno platforms, so I ended up removing the TextAlignment attribute and setting the VerticalAlignment attribute to Bottom instead.
  • Change HorizontalTextAlignment attribute value End to Right (again due to incompatibilities with some of the Uno platforms)
  • Change FontAttributes attribute to FontWeight
  • Change BoxView element to Border
  • Change HeightRequest attribtue to Height
  • Change Text attribute on Button to Content
  • Change Clicked event on Button to Click
  • On the Ellipse element, remove the Scale attribute and add both RenderTransform and a Storyboard.
<Ellipse x:Name="ellipse"
            Fill="{ThemeResource PrimaryColor}"
            HorizontalAlignment="Left"
            VerticalAlignment="Top"
            StrokeThickness="0">
    <Ellipse.RenderTransform>
        <CompositeTransform x:Name="ellipseTransform" />
    </Ellipse.RenderTransform>
    <Ellipse.Resources>
        <Storyboard x:Name="themeTransitionStoryboard">
            <DoubleAnimation Storyboard.TargetName="ellipseTransform"
                                Storyboard.TargetProperty="(CompositeTransform.ScaleX)"
                                From="0.0"
                                To="1.0"
                                Duration="0:0:1" />
            <DoubleAnimation Storyboard.TargetName="ellipseTransform"
                                Storyboard.TargetProperty="(CompositeTransform.ScaleY)"
                                From="0.0"
                                To="1.0"
                                Duration="0:0:1" />
        </Storyboard>
    </Ellipse.Resources>
</Ellipse>
  • Add VerticalAlignment, HorizontalAlignment and Background attributes to the theme switcher button.
<Button x:Name="ThemeSwitcher"
        Click="ThemeSwitcher_Clicked"
        VerticalAlignment="Stretch"
        HorizontalAlignment="Stretch"
        Background="Transparent"
        Grid.ColumnSpan="4" />
  • Expand row and column definitions – whilst WinUI now supports the compact notation for row and column definitions, the other Uno platforms don’t yet.
  • Replace divide (Γ·) with &#x00f7; and multiplication (Γ—) with &#x00d7;

The final XAML:

<Page x:Class="SimpleWinUICalculator.MainPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:local="using:SimpleWinUICalculator"
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
      xmlns:ios="http://uno.ui/ios"
      ios:Padding="0,20,0,0"
      mc:Ignorable="d ios">


    <Page.Resources>
        <Style TargetType="TextBlock">
            <Setter Property="Foreground"
                    Value="{ThemeResource SecondaryColor}" />
        </Style>

        <Style TargetType="Button">
            <Setter Property="Background"
                    Value="Transparent" />
            <Setter Property="Foreground"
                    Value="{ThemeResource SecondaryColor}" />
            <Setter Property="FontSize"
                    Value="36" />
        </Style>
    </Page.Resources>

    <Grid>
        <Ellipse x:Name="ellipse"
                 Fill="{ThemeResource PrimaryColor}"
                 HorizontalAlignment="Left"
                 VerticalAlignment="Top"
                 StrokeThickness="0">
            <Ellipse.RenderTransform>
                <CompositeTransform x:Name="ellipseTransform" />
            </Ellipse.RenderTransform>
            <Ellipse.Resources>
                <Storyboard x:Name="themeTransitionStoryboard">
                    <DoubleAnimation Storyboard.TargetName="ellipseTransform"
                                     Storyboard.TargetProperty="(CompositeTransform.ScaleX)"
                                     From="0.0"
                                     To="1.0"
                                     Duration="0:0:1" />
                    <DoubleAnimation Storyboard.TargetName="ellipseTransform"
                                     Storyboard.TargetProperty="(CompositeTransform.ScaleY)"
                                     From="0.0"
                                     To="1.0"
                                     Duration="0:0:1" />
                </Storyboard>
            </Ellipse.Resources>
        </Ellipse>
        <Grid x:Name="gridMain"
              Padding="16"
              RowSpacing="0"
              ColumnSpacing="0">
            <Grid.RowDefinitions>
                <RowDefinition Height="2*" />
                <RowDefinition />
                <RowDefinition />
                <RowDefinition />
                <RowDefinition />
                <RowDefinition />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition />
                <ColumnDefinition />
                <ColumnDefinition />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>
            <TextBlock x:Name="CurrentCalculation"
                       FontSize="22"
                       Foreground="{ThemeResource SecondaryColor}"
                       TextWrapping="NoWrap"
                       Grid.ColumnSpan="4"
                       TextAlignment="Center"
                       HorizontalTextAlignment="Right"
                       Text="2 x 4"
                       Grid.Row="0" />

            <TextBlock x:Name="resultText"
                       FontSize="64"
                       FontWeight="Bold"
                       Text="0"
                       HorizontalTextAlignment="Right"
                       VerticalAlignment="Bottom"
                       TextWrapping="NoWrap"
                       Grid.ColumnSpan="4" />

            <Border Background="{ThemeResource SecondaryColor}"
                    Height="1"
                     Grid.Row="0"
                     VerticalAlignment="Bottom"
                     Grid.ColumnSpan="4" />

            <Button Content="C"
                    Grid.Row="1"
                    Grid.Column="0"
                    Click="OnClear" />
            <Button Content="+/-"
                    Grid.Row="1"
                    Grid.Column="1"
                    Click="OnNegative" />
            <Button Content="%"
                    Grid.Row="1"
                    Grid.Column="2"
                    Click="OnPercentage" />

            <Button Content="7"
                    Grid.Row="2"
                    Grid.Column="0"
                    Click="OnSelectNumber" />
            <Button Content="8"
                    Grid.Row="2"
                    Grid.Column="1"
                    Click="OnSelectNumber" />
            <Button Content="9"
                    Grid.Row="2"
                    Grid.Column="2"
                    Click="OnSelectNumber" />

            <Button Content="4"
                    Grid.Row="3"
                    Grid.Column="0"
                    Click="OnSelectNumber" />
            <Button Content="5"
                    Grid.Row="3"
                    Grid.Column="1"
                    Click="OnSelectNumber" />
            <Button Content="6"
                    Grid.Row="3"
                    Grid.Column="2"
                    Click="OnSelectNumber" />

            <Button Content="1"
                    Grid.Row="4"
                    Grid.Column="0"
                    Click="OnSelectNumber" />
            <Button Content="2"
                    Grid.Row="4"
                    Grid.Column="1"
                    Click="OnSelectNumber" />
            <Button Content="3"
                    Grid.Row="4"
                    Grid.Column="2"
                    Click="OnSelectNumber" />

            <Button Content="00"
                    Grid.Row="5"
                    Grid.Column="0"
                    Click="OnSelectNumber" />
            <Button Content="0"
                    Grid.Row="5"
                    Grid.Column="1"
                    Click="OnSelectNumber" />
            <Button Content="."
                    Grid.Row="5"
                    Grid.Column="2"
                    Click="OnSelectNumber" />

            <Button Content="&#x00f7;"
                    Grid.Row="1"
                    Grid.Column="3"
                    Click="OnSelectOperator" />
            <Button Content="&#x00d7;"
                    Grid.Row="2"
                    Grid.Column="3"
                    Click="OnSelectOperator" />
            <Button Content="-"
                    Grid.Row="3"
                    Grid.Column="3"
                    Click="OnSelectOperator" />
            <Button Content="+"
                    Grid.Row="4"
                    Grid.Column="3"
                    Click="OnSelectOperator" />

            <Button Content="="
                    Grid.Row="5"
                    Grid.Column="3"
                    Click="OnCalculate" />

            <Button x:Name="ThemeSwitcher"
                    Click="ThemeSwitcher_Clicked"
                    VerticalAlignment="Stretch"
                    HorizontalAlignment="Stretch"
                    Background="Transparent"
                    Grid.ColumnSpan="4" />
        </Grid>
    </Grid>
</Page>

Codebehind Changes

Now to add the corresponding logic to MainPage.xaml.cs. Again we’ll copy the contents of the MainPage class from the Xamarin.Forms project into the WinUI project. Here’s a summary of the changes made to get the calculate to operate.

  • Change references to the Text property on a Button to the Content property. Since the code is expecting a string, the Content property also needs to be cast to a string
  • Change the EventArgs parameter to event handlers to RoutedEventArgs
  • Add an event handler for the SizeChanged event on the page. In the event handler we’re going to resize the Ellipse and make sure it has an appropriate Margin and the transform is centered correctly. The Margin is important otherwise the Ellipse will be cropped by the edge of the parent container (in this case a Grid)
public MainPage()
{
    InitializeComponent();
    OnClear(this, null);
    SizeChanged += MainPage_SizeChanged;
}
private void MainPage_SizeChanged(object sender, SizeChangedEventArgs e)
{
    ellipse.Margin = new Thickness { Left = -ActualWidth * 2, Top = -ActualHeight * 2, Right = -ActualWidth * 2, Bottom = -ActualHeight * 2 };
    ellipse.Height = ActualHeight * 4;
    ellipse.Width = ActualWidth * 4;
    var transform = ellipse.RenderTransform as CompositeTransform;
    transform.CenterX = ActualWidth * 2;
    transform.CenterY = ActualHeight * 2;
}
  • Update the ThemeSwitcher_Clicked method to switch the theme resources, trigger and update and then animate the change using the Storyboard defined on the Ellipse (in XAML). Note that this makes use of a RunAsync extension method which I covered in my previous post.
async void ThemeSwitcher_Clicked(System.Object sender, RoutedEventArgs e)
{
    themeIndex += 1;
    if (themeIndex >= themes.Length)
    {
        themeIndex = 0;
    }

    var transform = ellipse.RenderTransform as CompositeTransform;
    transform.ScaleX = 0;
    transform.ScaleY = 0;

    // Switch current themee
    var newResources = themes[themeIndex];
    App.Current.Resources.MergedDictionaries.Clear();
    App.Current.Resources.MergedDictionaries.Add(newResources);


    // Hack: force themes to be reapplied by switching between Dark and Light themes on root frame
    if (this.Frame.RequestedTheme == ElementTheme.Dark)
    {
        this.Frame.RequestedTheme = ElementTheme.Light;
        this.Frame.RequestedTheme = ElementTheme.Dark;
    }
    else
    {
        this.Frame.RequestedTheme = ElementTheme.Dark;
        this.Frame.RequestedTheme = ElementTheme.Light;
    }
            
    // Run the transition animation
    await themeTransitionStoryboard.RunAsync();

    Background = ellipse.Fill;
}

The full MainPage.cs source code is then

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Animation;
using SimpleWinUICalculator.Common.Styles;
using System;
using System.Threading.Tasks;

namespace SimpleWinUICalculator
{
    /// <summary>
    /// An empty page that can be used on its own or navigated to within a Frame.
    /// </summary>
    public sealed partial class MainPage : Page
    {
        int currentState = 1;
        string mathOperator;
        double firstNumber, secondNumber;

        public MainPage()
        {
            InitializeComponent();
            OnClear(this, null);

            SizeChanged += MainPage_SizeChanged;
        }
        private void MainPage_SizeChanged(object sender, SizeChangedEventArgs e)
        {
            ellipse.Margin = new Thickness { Left = -ActualWidth * 2, Top = -ActualHeight * 2, Right = -ActualWidth * 2, Bottom = -ActualHeight * 2 };
            ellipse.Height = ActualHeight * 4;
            ellipse.Width = ActualWidth * 4;
            var transform = ellipse.RenderTransform as CompositeTransform;
            transform.CenterX = ActualWidth * 2;
            transform.CenterY = ActualHeight * 2;
        }

        void OnSelectNumber(object sender,RoutedEventArgs e)
        {
            Button button = (Button)sender;
            string pressed = button.Content as string;

            if (this.resultText.Text == "0" || currentState < 0)
            {
                this.resultText.Text = "";
                if (currentState < 0)
                    currentState *= -1;
            }

            if (pressed == ".")
                pressed = ".00";// not optimistic

            this.resultText.Text += pressed;

            double number;
            if (double.TryParse(this.resultText.Text, out number))
            {
                this.resultText.Text = number.ToString("N0");
                if (currentState == 1)
                {
                    firstNumber = number;
                }
                else
                {
                    secondNumber = number;
                }
            }
        }

        void OnSelectOperator(object sender, RoutedEventArgs e)
        {
            currentState = -2;
            Button button = (Button)sender;
            string pressed = button.Content as string;
            mathOperator = pressed;
        }

        void OnClear(object sender,RoutedEventArgs e)
        {
            firstNumber = 0;
            secondNumber = 0;
            currentState = 1;
            this.resultText.Text = "0";
        }

        void OnCalculate(object sender,RoutedEventArgs e)
        {
            if (currentState == 2)
            {
                double result = Calculator.Calculate(firstNumber, secondNumber, mathOperator);

                this.CurrentCalculation.Text = $"{firstNumber} {mathOperator} {secondNumber}";

                this.resultText.Text = result.ToTrimmedString();
                firstNumber = result;
                currentState = -1;


            }
        }



        void OnNegative(object sender,RoutedEventArgs e)
        {
            if (currentState == 1)
            {
                secondNumber = -1;
                mathOperator = "Γ—";
                currentState = 2;
                OnCalculate(this, null);
            }
        }

        void OnPercentage(object sender,RoutedEventArgs e)
        {
            if (currentState == 1)
            {
                secondNumber = 0.01;
                mathOperator = "Γ—";
                currentState = 2;
                OnCalculate(this, null);
            }

        }

        int themeIndex = 0;

        ResourceDictionary[] themes = new ResourceDictionary[]
        {
            new ClayTheme(),
            new DesertTheme(),
            new LavaTheme(),
            new SunTheme(),
            new OceanTheme()
        };

        async void ThemeSwitcher_Clicked(System.Object sender, RoutedEventArgs e)
        {
            themeIndex += 1;
            if (themeIndex >= themes.Length)
            {
                themeIndex = 0;
            }

            var transform = ellipse.RenderTransform as CompositeTransform;
            transform.ScaleX = 0;
            transform.ScaleY = 0;

            // Switch current themee
            var newResources = themes[themeIndex];
            App.Current.Resources.MergedDictionaries.Clear();
            App.Current.Resources.MergedDictionaries.Add(newResources);


            // Hack: force themes to be reapplied by switching between Dark and Light themes on root frame
            if (this.Frame.RequestedTheme == ElementTheme.Dark)
            {
                this.Frame.RequestedTheme = ElementTheme.Light;
                this.Frame.RequestedTheme = ElementTheme.Dark;
            }
            else
            {
                this.Frame.RequestedTheme = ElementTheme.Dark;
                this.Frame.RequestedTheme = ElementTheme.Light;
            }
            
            // Run the transition animation
            await themeTransitionStoryboard.RunAsync();

            Background = ellipse.Fill;
        }
    }

    public static class StoryboardHelper
    {
        public static Task RunAsync(this Storyboard storyboard)
        {
            var tcs = new TaskCompletionSource<object>();
            EventHandler<object> completion = null;
            completion = (sender, args) =>
            {
                storyboard.Completed -= completion;
                tcs.SetResult(null);
            };
            storyboard.Completed += completion;
            storyboard.Begin();
            return tcs.Task;
        }
    }
}

Summary

Whilst it looks like we’ve had to make a lot of changes, they’re actually all pretty small and easy to make – you can simply go down the XAML and CS files and fix build warnings for the most part.

One thing that was a bit of a challenge was switching the themes. As you can see from the code in the ThemeSwitcher_Clicked method, we were able to easily exchange the theme resources by replacing the entry in the MergedDictionaries collection. However, for the changes to take effect we have to toggle the RequestedTheme value on the root frame of the application. This is a total hack but luckily happens so quickly that the user doesn’t see a flicker, just the theme update.

Here’s the WinUI calculator running on Desktop and Web (via WASM).

WinUI Desktop
Web – via WASM using Uno Platform

Source code is available on GitHub

2 thoughts on “Simple WinUI + Uno Calculator”

  1. What is?

    > Expand row and column definitions – whilst WinUI now supports the compact notation for row and column definitions, the other Uno platforms don’t yet.

    I haven’t found anything about it.

    1. You mean the compact notation for row and column definitions – this was added to WinUI3 but didn’t seem to work on other uno platforms when I tried it. I’m sure the team will fix this shortly πŸ™‚

Leave a Reply

Your email address will not be published. Required fields are marked *