Upgrading Radial Gauge from UWP to WinUI 3

In this article we describe the process of migrating a user control from UWP to WinUI 3. The subject is the Radial Gauge that we wrote many years ago and is currently part of Windows Community Toolkit. For its UI the radial gauge uses XAML as well as the Composition API. To successfully migrate the control from UWP to WinUI3

  • we had to remove some UWP calls that don’t exist in WinUI 3,
  • we replaced some UWP calls by their WinUI 3 counterpart, and
  • we decided to give the control a more rounded Windows 11 look-and-feel.

Setting up the environment

When we started this journey, the most recent version of Windows App SDK was the experimental preview of v1.0, so the list of required NuGet packages looked like this:

We installed the new Visual Studio templates:

After the upgrade to the official Windows App SDK 1.0 release, the dependencies list conveniently shrunk to this:

We created a sample app wherein all relevant UWP Radial Gauge source files from Windows Community Toolkit were copy-pasted.

Here’s the taxonomy of the current Windows 8 -style Radial Gauge control:

There was no need to change these properties, just as there was no need to change the control’s XAML template:

<ControlTemplate TargetType="local:RadialGauge">
    <Viewbox>
        <Grid x:Name="PART_Container"
              Width="200"
              Height="200">
 
            <!--  Scale  -->
            <Path Name="PART_Scale" />

            <!--  Trail  -->
            <Path Name="PART_Trail" />

            <!--  Value and Unit  -->
            <StackPanel HorizontalAlignment="Center"
                        VerticalAlignment="Bottom">
                <TextBlock Name="PART_ValueText" />
            </StackPanel>
        </Grid>
    </Viewbox>
</ControlTemplate>

The scale and trail of the gauge are Path elements, and then there are text boxes for value and unit. The rest of the UI parts -needle, ticks, and scale ticks- are drawn by the Composition API.

Goodbye Windows.UI namespace

The first step in migrating a code base from UWP to WinUI would be changing the namespace from Windows.UI.* to Microsoft.UI.* in a zillion places. All compiled well after this change…

Goodbye UI Events

… but at runtime the Toolkit’s ThemeListener crashed the app. It’s a known issue and the reason is that UWP’s UISettings.ColorValuesChanged Event and AccessibilitySettings.HighContrastChanged Event are no longer supported in desktop apps. Unlike UWP apps, WinUI 3 desktop apps are not notified when the user changes the theme, the contrast color, or high contrast mode. This is not a showstopper – as long as you stick to ThemeResources in your XAML. Since theming support is a popular feature these days, we assume that the majority of recent UWP apps is already using theme resource dictionaries. Make sure you don’t forget one for HighContrast. You don’t really need a notification when the theme changes at runtime: all XAML and Composition elements that get their color from a ThemeResource will immediately and automatically change color.

Here’s how we configured the Radial Gauge instances on the Home page of the sample app:

<ResourceDictionary.ThemeDictionaries>
    <ResourceDictionary x:Key="HighContrast">
        <!-- This makes the background disappear in High Contrast mode -->
        <x:Double x:Key="BackgroundOpacity">0</x:Double>
        <SolidColorBrush x:Key="TenPercentContrastBrush"
             Color="{ThemeResource SystemColorWindowTextColor}" />
        <SolidColorBrush x:Key="SystemAccentColorBrush"
             Color="{StaticResource SystemAccentColor}" />
        <SolidColorBrush x:Key="AppNeedleBrush"
             Color="{ThemeResource SystemAccentColor}" />
    </ResourceDictionary>
    <ResourceDictionary x:Key="Dark">
        <x:Double x:Key="BackgroundOpacity">.075</x:Double>
        <SolidColorBrush x:Key="TenPercentContrastBrush"
             Color="White"
             Opacity=".1" />
        <Color x:Key="SystemAccentColor">CadetBlue</Color>
        <SolidColorBrush x:Key="SystemAccentColorBrush"
             Color="{StaticResource SystemAccentColor}"
             Opacity=".5" />
        <SolidColorBrush x:Key="AppNeedleBrush"
             Color="OrangeRed" />
    </ResourceDictionary>
    <ResourceDictionary x:Key="Light">
        <x:Double x:Key="BackgroundOpacity">.15</x:Double>
        <SolidColorBrush x:Key="TenPercentContrastBrush"
             Color="Black"
             Opacity=".1" />
        <Color x:Key="SystemAccentColor">CadetBlue</Color>
        <SolidColorBrush x:Key="SystemAccentColorBrush"
             Color="{StaticResource SystemAccentColor}"
             Opacity=".5" />
        <SolidColorBrush x:Key="AppNeedleBrush"
             Color="OrangeRed" />
    </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

Here’s that page in Light, Dark, and HighContrast theme:

As it turned out that all the Toolkit’s ThemeListener related code in the Radial Gauge is actually obsolete in a WinUI 3 desktop context, we removed that code.

Goodbye CoreWindow

Radial Gauge supports keyboard input. The Value property can be changed via the arrow keys, with the CTRL key to manage the change interval. To detect the virtual key press Radial Gauge was using CoreWindow.GetKeyState():

var ctrl = Window.Current.CoreWindow.GetKeyState(VirtualKey.Control);
if (ctrl.HasFlag(CoreVirtualKeyStates.Down))
{
    step = LargeChange;
}

Since UWP’s CoreWindow is not available in WinUI 3 desktop apps, we had to look for another way to detect key presses. We found it in the appropriately named KeyboardInput class. Here’s the new code:

if (KeyboardInput.GetKeyStateForCurrentThread(VirtualKey.Control) == CoreVirtualKeyStates.Down)
{
    step = LargeChange;
};

Goodbye KeyboardInput

Just a few days after that last modification, we upgraded the project to Windows App SDK v1.0 only to observe that the KeyboardInput class was not there anymore. We abandoned the keyboard events and went for Keyboard Accelerators. In hindsight that would always have been the proper approach. We created an extension method to facilitate the declaration:

public static void AddKeyboardAccelerator(this UIElement element,
  VirtualKeyModifiers keyModifiers,
  VirtualKey key,
  TypedEventHandler<KeyboardAccelerator, KeyboardAcceleratorInvokedEventArgs> handler)
{
    var accelerator =
      new KeyboardAccelerator()
      {
          Modifiers = keyModifiers,
          Key = key
      };
    accelerator.Invoked += handler;
    element.KeyboardAccelerators.Add(accelerator);
}

Then we replaced the keypress event handler by eight keyboard accelerators (one for each arrow key, with and without CTRL). Here are two of those:

this.AddKeyboardAccelerator(
    VirtualKeyModifiers.Control,
    VirtualKey.Left,
    (ka, kaea) =>
    {
        Value = Math.Max(Minimum, Value - LargeChange);
        kaea.Handled = true;
    });
this.AddKeyboardAccelerator(
    VirtualKeyModifiers.None,
    VirtualKey.Left,
    (ka, kaea) =>
    {
        Value = Math.Max(Minimum, Value - SmallChange);
        kaea.Handled = true;
    });

Goodbye DesignMode

One of the major differences between UWP and Win32 Desktop is the application model. Unsurprisingly the Windows.ApplicationModel namespace did not survive into Windows App SDK v1.0. As a result, the DesignMode to detect whether the control is hosted by a Visual Designer is not available anymore. It is used in the Toolkit’s DesignTimeHelper class. There seem to be no plans for a WinUI 3 Desktop XAML Designer, and with features like XAML Live Preview and XAML Hot Reload there’s no urgent need for one. Again, we happily removed another part of the Radial Gauge code.

Goodbye Windows 8 style

The original looks of the Radial Gauge were a product of the Windows 8 design style: sharp (“rounded corners, shadows, and gradients are bad for battery life”) and plump (“touch first: designed for fingers”). We decided it was time for a make-over to bring the UI closer to Windows 11 design. We made another copy of the control (“RadialGauge2”) where we

  • decreased the default scale width,
  • rounded the Scale and Trail shapes by changing their PenLineCaps in the XAML template,
  • rounded the Tick, ScaleTick and Needle elements, and
  • provided support for Opacity in these elements.

Rounding the shapes was not a trivial job, since we were relying on SpriteVisual instances – unrounded rectangles filled with a non-transparent CompositionColorBrush. Here’s the old code for the ticks:

SpriteVisual tick;
for (double i = radialGauge.Minimum; i <= radialGauge.Maximum; i += radialGauge.TickSpacing)
{
    tick = radialGauge._compositor.CreateSpriteVisual();
    tick.Size = new Vector2((float)radialGauge.TickWidth, (float)radialGauge.TickLength);
    tick.Brush = radialGauge._compositor.CreateColorBrush(radialGauge.TickBrush.Color);
    tick.Offset = new Vector3(100 - ((float)radialGauge.TickWidth / 2), 0.0f, 0);
    tick.CenterPoint = new Vector3((float)radialGauge.TickWidth / 2, 100.0f, 0);
    tick.RotationAngleInDegrees = (float)radialGauge.ValueToAngle(i);
    radialGauge._root.Children.InsertAtTop(tick);
}

The new Radial Gauge uses a CompositionRoundedRectangleGeometry to fill a CompositionSpriteShape for each tick, and then groups all the ticks into a single ShapeVisual. Transparent brushes are not possible in this part of the API (well, at least not in a simple way). As a work-around we applied the opacity of the source brush to the Opacity of the Visual. Here’s the -much longer- new code:

var ticks = radialGauge._compositor.CreateShapeVisual();
ticks.Size = new Vector2((float)radialGauge.Height, (float)radialGauge.Width);
ticks.BorderMode = CompositionBorderMode.Soft;
ticks.Opacity = (float)radialGauge.TickBrush.Opacity;
 
var roundedTickRectangle = radialGauge._compositor.CreateRoundedRectangleGeometry();
roundedTickRectangle.Size = new Vector2((float)radialGauge.TickWidth, (float)radialGauge.TickLength);
roundedTickRectangle.CornerRadius = new Vector2((float)radialGauge.TickWidth / 2, (float)radialGauge.TickWidth / 2);
 
var tssFillBrush = radialGauge._compositor.CreateColorBrush(radialGauge.TickBrush.Color);
var tssOffset = new Vector2(100 - ((float)radialGauge.TickWidth / 2), 0.0f);
var tssCenterPoint = new Vector2((float)radialGauge.TickWidth / 2, 100.0f);
 
for (double i = radialGauge.Minimum; i <= radialGauge.Maximum; i += radialGauge.TickSpacing)
{
    var tickSpriteShape = radialGauge._compositor.CreateSpriteShape(roundedTickRectangle);
    tickSpriteShape.FillBrush = tssFillBrush;
    tickSpriteShape.Offset = tssOffset;
    tickSpriteShape.CenterPoint = tssCenterPoint;
    tickSpriteShape.RotationAngleInDegrees = (float)radialGauge.ValueToAngle(i);
 
    ticks.Shapes.Add(tickSpriteShape);
}
 
radialGauge._root.Children.InsertAtTop(ticks);

The Home page of our sample app compares the new look to the original one:

The Gallery page displays two sets of controls: the top row has some gauge configurations using the old style, the second row has the same gauges but is using the new style:

We believe that the second row looks better in any configuration.

Here’s how it looks like with the official v1.0.0. of Windows App SDK, bringing the Windows 11 style into the equation:

Here’s how the upgraded Radial Gauge looks like in a candidate production app that is in the middle of its migration to WinUI 3:

For the sake of completion: that other control in the screenshot is a Radial Range Indicator.

The Verdict

Migrating controls and apps from UWP to WinUI 3 involves more than a namespace change, and we’re OK with that. Some API’s have disappeared, but none of these was crucial – not to this user control and not to the apps we’re currently migrating.

We are happy with the result: Radial Gauge now has better looks and less source code to maintain.

The sample app lives here on GitHub.

Enjoy!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s