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 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 arc begins at the start angle. Its length is governed by the sweep angle. The arc is shown here in red:
The curve added to the path with the AddArc
or ArcTo
method is simply that part of the ellipse’s circumference:
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. TheHandlerChangingEventArgs
object that accompanies this event hasNewHandler
andOldHandler
properties, of typeIElementHandler
. When theNewHandler
property isn’tnull
, the event indicates that a new handler is about to be created for a cross-platform control. When theOldHandler
property isn’tnull
, 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.