Custom control for MAUI using SkiaSharp

skiasharp

In this blog post, I will demonstrate how you can create your own custom control for MAUI using SkiaSharp and what you need to do in order to make it reusable.

The full source code of this component is on GitHub. Also, you can use the NuGet package PSC.Maui.Components.Doughnuts.

What is SkiaSharp?

SkiaSharp is a cross-platform 2D graphics API for .NET platforms based on Google’s Skia Graphics Library (skia.org). It provides a comprehensive 2D API that can be used across mobile, server and desktop models to render images.

SkiaSharp provides cross-platform bindings for:

  • .NET Standard 1.3
  • .NET Core
  • .NET 6
  • Tizen
  • Android
  • iOS
  • tvOS
  • macOS
  • Mac Catalyst
  • WinUI 3 (Windows App SDK / Uno Platform)
  • Windows Classic Desktop (Windows.Forms / WPF)
  • Web Assembly (WASM)
  • Uno Platform (iOS / macOS / Android / WebAssembly)

The API Documentation is available on the web to browse.

The Angle Arc

The angle arc approach to drawing arcs requires that you specify a rectangle that bounds an ellipse. The arc on the circumference of this ellipse is indicated by angles from the center of the ellipse that indicate the beginning of the arc and its length. Two different methods draw angle arcs. These are the AddArc method and the ArcTo method:

public void AddArc (SKRect oval, Single startAngle, Single sweepAngle)

public void ArcTo (SKRect oval, Single startAngle, Single sweepAngle, Boolean forceMoveTo)

These methods are identical to the Android AddArc and [ArcTo]xref:Android.Graphics.Path.ArcTo*) methods. The iOS AddArc method is similar but is restricted to arcs on the circumference of a circle rather than generalized to an ellipse.

Both methods begin with an SKRect value that defines both the location and size of an ellipse:

The oval that begins an angle arc

The arc is a part of the circumference of this ellipse.

startAngle and sweepAngle

The startAngle argument is a clockwise angle in degrees relative to a horizontal line drawn from the center of the ellipse to the right. The sweepAngle argument is relative to the startAngle. Here are startAngle and sweepAngle values of 60 degrees and 100 degrees, respectively:

The angles that define an angle arc

The arc begins at the start angle. Its length is governed by the sweep angle. The arc is shown here in red:

The highlighted angle arc

The curve added to the path with the AddArc or ArcTo method is simply that part of the ellipse’s circumference:

The angle arc by itself

The startAngle or sweepAngle arguments can be negative: The arc is clockwise for positive values of sweepAngle and counter-clockwise for negative values.

However, AddArc does not define a closed contour. If you call LineTo after AddArc, a line is drawn from the end of the arc to the point in the LineTo method, and the same is true of ArcTo.

AddArc automatically starts a new contour and is functionally equivalent to a call to ArcTo with a final argument of true:

path.ArcTo (oval, startAngle, sweepAngle, true);

That last argument is called forceMoveTo, and it effectively causes a MoveTo call at the beginning of the arc. That begins a new contour. That is not the case with a last argument of false:

path.ArcTo (oval, startAngle, sweepAngle, false);

This version of ArcTo draws a line from the current position to the beginning of the arc. This means that the arc can be somewhere in the middle of a larger contour.

The goal

Now, the idea is to create a component to create a wheel or doughnut (like in iOS) that will look like this:

All the colours of the Wheel must be customizable. Just to understand a bit more about SkiaSharp and how it works.

Wheel/Doughnut properties

Now, for the doughnut, we want to have some custom properties to customize the colours. Here are the properties:

  • InnerColor: this is the colour of the background of the Wheel
  • SweepAngle: the angle to be selected/highlighted starting from 0
  • WheelColor: the base colour of the Wheel/Doughnut
  • WheelSelectedColor: the colour of the selected/highlighted

Setup

Now, in order to start this simple project, we need a new  .NET MAUI Class Library project. This is where we will actually implement our custom control.

To create a component for Maui, add a new project and select .NET MAUI Class Library. After that give a name (in my case is PSC.Maui.Components.Doughnuts).

Clean up

Now, the basic project is created and we have a boilerplate for a new component. The implementation will be one for all platforms. So, open the folder Platforms and in each folder delete the file PlatformClass1.

Add SkiaSharp

Next, we need to add SkiaSharp to our class library project. For this, we add the following packages in the NuGet package manager:

  • SkiaSharp.Views.Maui.Controls (version 2.88.3 at the time of writing)
  • SkiaSharp.Views.Maui.Core (version 2.88.3 at the time of writing)

Once installed, we can use the SKCanvasView as a base class for our control. After that, our class should look like this:

using SkiaSharp;
using SkiaSharp.Views.Maui;
using SkiaSharp.Views.Maui.Controls;

namespace PSC.Maui.Components.Doughnuts
{
    // All the code in this file is included in all platforms.
    public class Doughnut : SKCanvasView
    {
    }
}

Handler Registration

What is a handler? All handler-based .NET MAUI controls support two handler lifecycle events:

  • HandlerChanging is raised when a new handler is about to be created for a cross-platform control, and when an existing handler is about to be removed from a cross-platform control. The HandlerChangingEventArgs object that accompanies this event has NewHandler and OldHandler properties, of type IElementHandler. When the NewHandler property isn’t null, the event indicates that a new handler is about to be created for a cross-platform control. When the OldHandler property isn’t null, the event indicates that the existing native control is about be removed from the cross-platform control, and therefore any native events should be unwired and other cleanup performed.
  • HandlerChanged is raised after the handler for a cross-platform control has been created. This event indicates that the native control that implements the cross-platform control is available, and all the property values set on the cross-platform control have been applied to the native control.

To know more about the handlers, see the official Microsoft documentation.

After what I said, we need to register a handler for our control. This is required because otherwise, MAUI doesn’t know how to render the control for each platform. Because we don’t need any platform-specific handlers since we inherit directly from SKCanvasView, we can use the existing SKCanvasViewHandler from SkiaSharp.

In order to register the handler for our control, we need to create a static class inside our PSC.MAUI.Components.Doughnuts project that I usually call Registration. In this class, we create an extension method called UseDoughnut() where we add the handler to the MauiAppBuilder:

public static class Registration
{
    public static MauiAppBuilder UseDoughnut(this MauiAppBuilder builder)
    {
        builder.ConfigureMauiHandlers(h =>
        {
            h.AddHandler<Doughnut, SKCanvasViewHandler>();
        });

        return builder;
    }
}

This can now be used in the main project’s MauiProgram class as follows:

using PSC.Maui.Components.Doughnuts;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseDoughnut() //add this line
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        return builder.Build();
    }
}

Adding the control to XAML

Although we haven’t implemented yet the component, I can already add the Doughnut to a XAML Page or View. So, we have to import the namespace from our class library and add the control to the layout (in the following code, the name is dn):

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="https://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="https://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:dn="clr-namespace:PSC.Maui.Components.Doughnuts;assembly=PSC.Maui.Components.Doughnuts"
             x:Class="ProgressBarSample.MainPage">

  <VerticalStackLayout
            Spacing="25"
            Padding="30,0"
            VerticalOptions="Center">

			<dn:Doughnut WidthRequest="300" HeightRequest="300" 
						 HorizontalOptions="FillAndExpand" 
						 VerticalOptions="FillAndExpand" 
						 SweepAngle="100" 
						 ShowNoData="False" />

  </VerticalStackLayout>
</ContentPage>

Probably, we receive an error with this code because the properties are not defined yet.

Implementing the Wheel/Doughnut

Now, we can start with the component itself. The first thing to do is define the BindableProperty.

What is a BindableProperty?

A BindableProperty is a special type of property that can be used in .NET MAUI apps to support data binding, styles, templates, and other features. It is defined by a class that inherits from BindableObject, and it can be accessed by other classes as an attached property. You can also use a source generator to automatically create BindableProperties from fields.

Implementing a property

The first thing we want to be sure is: if we change a property, the wheel has to be redraw. For this purpose, we can invalidate the canvas on where we draw our wheel. This component is inherited from the SKCanvasView. So, we have a command to invalidate everything and start to draw again. This function is InvalidateSurface and I call it in a generic function OnAnyPropertyChanged

private static void OnAnyPropertyChanged(BindableObject bindable, 
                              object oldValue, object newValue)
{
    ((Doughnut)bindable).InvalidateSurface();
}

Every time a property changes its value, I want to call this function. Now,let me create the InnerColor property.

public Color InnerColor
{
    get => (Color)GetValue(InnerColorProperty);
    set => SetValue(InnerColorProperty, value);
}

This is a quite normal property with get and set. If you notice, it calles something called InnerColorProperty. This is the real bindable property that we can call from the UI. Here the implementation:

public static readonly BindableProperty InnerColorProperty = BindableProperty.Create(
                                            nameof(InnerColor), 
                                            typeof(Color),
                                            typeof(Doughnut), 
                                            Color.FromArgb("#ffffffff"), 
                                            propertyChanged: OnAnyPropertyChanged);

I’m doing the some implementation for the other properties.

Draw the circle

Now, the most exciting part. Skia gives us the canvas where we can draw. Based on the abode documentation related to the Arc, I can use this info to draw in the center of the canvas a circle or part of it and create the illusion that the Wheel or Doughnut – as use like to call it – it is empty.

private void DrawCircle(SKImageInfo info, SKCanvas canvas, Color color, 
                        float Radius, float startAngle, float sweepAngle)
{
    var center = new SKPoint(info.Width / 2F, info.Height / 2F);

    using (var path = new SKPath())
    using (var fillPaint = new SKPaint())
    {
        fillPaint.Style = SKPaintStyle.Fill;
        fillPaint.Color = color.ToSKColor();

        var radius = Math.Min(info.Width / 2, info.Height / 2) * Radius;
        var rect = new SKRect(center.X - radius, center.Y - radius,
                                center.X + radius, center.Y + radius);

        path.MoveTo(center);
        path.ArcTo(rect, startAngle,
                    Math.Abs(sweepAngle - 360F) < EPSILON ? 359.99F : sweepAngle, false);
        path.Close();

        canvas.DrawPath(path, fillPaint);
    }
}

Draw all circles and the magic begins

The function above draws only one circle in the middle of the canvas. Let see the code first.

protected override void OnPaintSurface(SKPaintSurfaceEventArgs e)
{
    base.OnPaintSurface(e);

    canvas = e.Surface.Canvas;
    canvas.Clear(); // clears the canvas for every frame
    info = e.Info;
    drawRect = new SKRect(0, 0, info.Width, info.Height);

    // where the pie starts
    var startAngle = -90F;

    var center = new SKPoint(info.Width / 2F, info.Height / 2F);

    float bigWheelRadius = 0.96F;
    float bigAngle = 360F;
    float interWheelRadius = 0.96F;
    float miniWheelRadius = 0.84F;

    // draw the big doughnut
    DrawCircle(info, canvas, WheelSelectedColor, 1, startAngle, bigAngle);

    if (ShowNoData)
    {
        DrawCircle(info, canvas, WheelColor, 1, -93, 6);
        DrawCircle(info, canvas, InnerColor, miniWheelRadius, startAngle, bigAngle);
    }
    else
    {
        DrawCircle(info, canvas, WheelColor, 1, startAngle, SweepAngle);
        DrawCircle(info, canvas, InnerColor, miniWheelRadius, startAngle, bigAngle);
    }
}

Here I have the OnPaintSurface that is called when the component starts of a property changed. This override tha function from SkiaSharp. Now, from the SKPaintSurfaceEventArgs e I have to read the Canvas where I can draw the wheel. For this reason, I use

canvas = e.Surface.Canvas;

Now, I have the canvas. I want to clean it with

canvas.Clear();

Next step is to know how big is the canvas using the info = e.Info;. Then, I start to draw the circle to create the idea of wheel\doughnut.

Wrap up

In conclusion, this is how to create a custom control for MAUI using SkiaSharp the you can re-use in your applications.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.