Dot.Blog

Consulting DotNet C#, XAML, WinUI, WPF, MAUI, IA

MAUI Roue des Couleurs avec SkiaSharp

SkiaSharp offre des possibilités immenses qui peuvent transformer une App banale en une App attrayante et vendeuse ou de créer des contrôles au look inédit… En quelques lignes à peine… Comment ? c’est ce que je vous propose de voir…

Du visuel vraiment graphique

Il est évident que pour gérer des calculs ou organiser son code suivant une méthodologie de développement précise, SkiaSharp ne sert pas à grande chose, mais dès qu’il s’agit de visuel alors là ça change tout !

Des couleurs pour le prouver

Quoi de plus “visuel” que des couleurs ? Sans couleur le monde serait triste, pas sans intérêt mais moins “appétissant”. Il y a très peu de gens qui ne voient pas du tout les couleurs (achromatopsie) c'est ainsi quelque chose qui concerne tout le monde, même les daltoniens. Pour ceux qui ne voient pas du tout il existe d'autres moyens inclusifs de leur rendre une App accessible, mais pour aussi sérieux que soit le sujet, ce n'est pas celui du jour. Pour les autres, couleurs et graphismes ont du sens et il est essentiel pour se démarquer de proposer des Apps qui les séduisent visuellement.

Donc pour cet exemple-prétexte partons des couleurs et d'une roue.

Nous allons décliner des couleurs pour nous aider à les marier pour choisir des thèmes (c’est le prétexte, l’App devrait être plus travaillée pour être vraiment utile). La première idée serait de créer des “pastilles” de couleur avec leur valeur écrite dedans. Rondes elles se prêteront mal à l’écriture de texte à l’intérieur, rectangulaire elles ne seront que de tristes tuiles unies…

Pire : comment les agencer ? En lignes ? En Colonnes ? Forcément un peu des deux puisqu’il y en aura beaucoup.

Résultat : nous allons nous retrouver avec un tableau de couleurs. Un tableau avec des lignes, des colonnes, un scroll peut-être même. C’est à dire une UI d'il y a 30 ans aussi excitante que la proposition d’une tête de veau au petit déjeuner ... ou une baignade dans la Seine à Paris !

Une roue colorée

Ne serait-il pas plus visuel, plus agréable, plus sensuel même, si ces couleurs étaient représentée dans une roue colorée ? Si bien sûr.

J’entends déjà quelques grincheux dire que c’est impossible le XAML de MAUI n’est pas un “vrai” XAML car il ne possède pas de primitives graphiques vectorielles et tout ça…

Belle erreur ! Le XAML de MAUI est un vrai XAML. Il ne possède certes pas les primitives 2D et 3D du XAML de WPF, mais tous les XAML ne vont pas jusque là. Et puis le XAML de MAUI possède malgré tout des Shapes, des Paths, ce qui n'est pas si mal. Mais il y a une part de vérité dans cette remarque. MAUI, graphiquement, ce n'est pas WPF, c'est vrai. Mais comme il existe une foule d'extensions pour cet environnement, nous allons piocher dans ce vivier pour combler le manque !

SkiaSharp est l’extension magique qu’il faut connaître et maîtriser un peu même si on n’est pas graphiste. Car justement cela ajoute à MAUI ce qui lui manquait un peu : le dessin.

Pas seulement pour dessiner des petits mikeys ou des sapins de Noël, mais pour créer des contrôles visuels qui ont de la gueule ! Et là ça intéresse ou ça devrait intéresser TOUS les développeurs…

Regardons ce que ça donne…

Avant d’aller plus loin regardons un exemple vivant du résultat (c’est une capture GIF déjà gourmande alors ne soyez pas trop … regardant… quant à la compression des images et donc des couleurs qui peuvent parfois se mélanger un peu. C’est un artefact GIF, l’App est plus jolie en vrai !)

 

 

Explications

Ce qu’on voit ici c’est la MainPage de l’application qui n’a qu’une seule page d’ailleurs. Lorsqu’on lance l’App on est gratifié d’une jolie animation de la roue. Puis le soft devient fonctionnel et affiche ses divers contrôles.

Pour faire simple : les switches pilotent l’affichage ou non des valeurs des couleurs en hexadécimal ainsi que le mode de fonctionnement des couleurs en HSL (par défaut) ou HSV. Quant au trois sliders, les deux premiers font tourner la roue intérieure et la roue extérieure, celle du milieu restant fixe, et le troisième permet d’augmenter le nombre de tranches de la roue pour un choix plus fin des couleurs. C'est en pivotant ces différentes roues qu'on met des couleurs en face les unes des autres ce qui permet de choisir une harmonie et de s'en servir ensuite dans une App.

Tout cela n’est bien entendu qu’une démo, un énorme prétexte pour parler à la fois d’UI et de SkiaSharp donc on ne va pas tout disséquer comme s’il s’agissait de l’App du siècle, même si l'idée est sympa.

D'ailleurs ça marche et c’est joli…

Imaginez la même chose avec un horrible tableau de couleurs ou une ListView de couleurs … Beurk !

Comment ?

Ok, donc comment faire pour obtenir un tel résultat ?

Première chose ajouter le package SkiaSharp à votre Solution… Je ne vous montre pas je suppose qu’ajouter un paquet Nuget ne mérite plus que je fasse des captures écran détaillées. Je vous donne juste le nom du paquet car ce n'est pas le même que sous Xamarin.Forms notamment :  SkiaSharp.Views.Maui.Controls

N'oubliez pas non plus d'activer SkiaSharp dans le MauiProgram :

Et ensuite ?

Il suffit de coder…

Bien entendu il va y avoir du code, pour certains ça semblera beaucoup, mais il y a de la présentation, des switches, des sliders, etc, tout cela prend de la place à coder en XAML tout autant que les actions pour faire marcher l'App côté C#. Que cela ne vous rebute pas, la partie concernant SkiSharp n'est pas la plus grosse !

Ici une page, une ContentPage normale avec son code behing sans bizarrerie. De toute façon c’est entièrement du visuel alors pourquoi chercher midi à quatorze heures…

XAML

Voici le code XAML de la MainPage :

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:SkiaSharp.Views.Maui.Controls;assembly=SkiaSharp.Views.Maui.Controls"
             x:Class="SkiaDemo1.MainPage"
             BackgroundColor="White">

    <Grid RowDefinitions="*,200">
        <controls:SKCanvasView x:Name="canvasView"  Grid.Row="0" Margin="10"
             PaintSurface="Handle_PaintSurface"
             BackgroundColor="Transparent">
            <controls:SKCanvasView.GestureRecognizers>
                <TapGestureRecognizer
                     Tapped="Handle_Tapped"/>
            </controls:SKCanvasView.GestureRecognizers>
        </controls:SKCanvasView>
        <Grid x:Name="toolBarAbsoluteLayout"
                        Grid.Row="1"
             BackgroundColor="#33B0AEAE">
            <StackLayout x:Name="toolbarStackLayout"
                 Margin="5"
                 Padding="2">
                <StackLayout Orientation="Vertical">
                    <StackLayout
                     Orientation="Horizontal" >
                        <Label x:Name="HslSwitchLabel"
                         Text="HSL (else HSV)?"
                         VerticalOptions="Center"/>
                        <Switch x:Name="SwHSL"
                         Toggled="SwHSL_Toggled"
                         VerticalOptions="Center" />
                    </StackLayout>
                    <StackLayout
                     Orientation="Horizontal" >
                        <Label x:Name="ShowTextLabel"
                            Text="Show Values?"
                            VerticalOptions="Center"/>
                        <Switch
                         Toggled="SwitchShowValues"
                         IsToggled="false"
                         VerticalOptions="Center"/>
                    </StackLayout>
                </StackLayout>
                <HorizontalStackLayout>
                    <Label Text="Inner range" WidthRequest="150" />
                    <Slider x:Name="innerRangeAngleSlider"
                            WidthRequest="220"
                     ValueChanged="InnerRangeAngleSlider_ValueChanged"
                     Minimum="0" MinimumTrackColor="White" MaximumTrackColor="Black"/>
                </HorizontalStackLayout>
                <HorizontalStackLayout>
                    <Label Text="outer range" WidthRequest="150" />
                    <Slider x:Name="outerRangeAngleSlider"
                     ValueChanged="OuterRangeAngleSlider_ValueChanged"
                     Minimum="0" MinimumTrackColor="White" MaximumTrackColor="Black"
                     WidthRequest="220"/>
                </HorizontalStackLayout>
                <HorizontalStackLayout>
                    <Label Text="Steps" WidthRequest="150" />
                    <Slider x:Name="stepsSlider"
                     Maximum="20"
                     Minimum="7"
                     ValueChanged="StepsSlider_ValueChanged"
                     MinimumTrackColor="White" MaximumTrackColor="Black"
                     WidthRequest="220"/>
                </HorizontalStackLayout>
            </StackLayout>
        </Grid>
    </Grid>

</ContentPage>

Comme vous le voyez c’est un code classique, il fait juste appel parfois à des contrôles provenant de SkiaSharp comme la surface de dessin.

Sinon c’est du “vrai” XAML comme on l’aime.

C#

le code behind de MainPage est assez simple il consiste en une séquence du dessin de la roue et en quelques actions pour répondre aux changements des swtiches ou des sliders… vraiment du code simple.

using SkiaSharp;
using SkiaSharp.Views.Maui;
using static System.Math;

namespace SkiaDemo1
{
    public partial class MainPage : ContentPage
    {
        private int stepsOuterRange = 360;
        private float angleOuterRange;
        private float angleInnerRange;
        private bool drawText;
        private bool valueEnabled = true;
        private bool hslEnabled = true; // else HSV


        public MainPage()
        {
            InitializeComponent();

            SwHSL.IsToggled = hslEnabled;

            if (!DesignMode.IsDesignModeEnabled)
            {
                canvasView.Opacity =
                    toolBarAbsoluteLayout.Opacity =
                        innerRangeAngleSlider.Opacity =
                            outerRangeAngleSlider.Opacity =
                                stepsSlider.Opacity = 0;
            }
        }


        protected override void OnAppearing()
        {
            base.OnAppearing();


            var appearingAnim = new Animation(steps =>
            {
                stepsOuterRange = (int)steps;
                canvasView.InvalidateSurface();
            }, stepsOuterRange, stepsSlider.Minimum, Easing.SinOut);


            drawText = false;
            if (!DesignMode.IsDesignModeEnabled)
                Task.Run(async () =>
                {
                    await Task.Delay(1000);
                    await canvasView.FadeTo(1, 1000, Easing.SinInOut);
                    appearingAnim.Commit(this, "Appearing", length: 5000, finished: async (_, __) =>
                    {
                        const uint duration = 200;
                        var easing = Easing.CubicOut;
                        await toolBarAbsoluteLayout.FadeTo(1, duration, easing);
                        await stepsSlider.FadeTo(1, duration, easing);
                        await outerRangeAngleSlider.FadeTo(1, duration, easing);
                        await innerRangeAngleSlider.FadeTo(1, duration, easing);


                        canvasView.InvalidateSurface();


                        outerRangeAngleSlider.Maximum = innerRangeAngleSlider.Maximum = stepsSlider.Value;
                    });
                });
        }


        private void Handle_Tapped(object sender, System.EventArgs e)
        {
            valueEnabled = !valueEnabled;
            canvasView.InvalidateSurface();
        }



        private void InnerRangeAngleSlider_ValueChanged(object sender, ValueChangedEventArgs args)
        {
            updateAngleInnerRange(args.NewValue);
            canvasView.InvalidateSurface();
        }


        private void updateAngleInnerRange(double value)
        {
            angleInnerRange = (float)(Floor(value) * (360 / innerRangeAngleSlider.Maximum));
        }


        private void OuterRangeAngleSlider_ValueChanged(object sender, ValueChangedEventArgs args)
        {
            updateAngleOuterRange(args.NewValue);
            canvasView.InvalidateSurface();
        }


        private void updateAngleOuterRange(double value)
        {
            angleOuterRange = (float)(Floor(value) * (360 / outerRangeAngleSlider.Maximum));
        }


        private void StepsSlider_ValueChanged(object sender, ValueChangedEventArgs e)
        {
            stepsOuterRange = (int)e.NewValue;


            var prevOuterValue = outerRangeAngleSlider.Value;
            var prevOuterMaximum = outerRangeAngleSlider.Maximum;
            outerRangeAngleSlider.Maximum = stepsOuterRange;
            outerRangeAngleSlider.Value = (stepsOuterRange * prevOuterValue) / prevOuterMaximum;
            updateAngleOuterRange(outerRangeAngleSlider.Value);


            var prevInnerValue = innerRangeAngleSlider.Value;
            var prevInnerMaximum = innerRangeAngleSlider.Maximum;
            innerRangeAngleSlider.Maximum = stepsOuterRange;
            innerRangeAngleSlider.Value = (stepsOuterRange * prevInnerValue) / prevInnerMaximum;
            updateAngleInnerRange(innerRangeAngleSlider.Value);


            canvasView.InvalidateSurface();
        }


        void Handle_PaintSurface(object sender, SKPaintSurfaceEventArgs e)
        {
            e.Surface.Canvas.Clear(hslEnabled ? SKColors.White : SKColors.Black);
            drawRange(e.Surface.Canvas, e.Info.Width, e.Info.Height, angleOuterRange, stepsOuterRange,
                      (w, h) => Min(w, h) / 2 - (float)toolbarStackLayout.Margin.Bottom, 50, drawText);
            drawRange(e.Surface.Canvas, e.Info.Width, e.Info.Height, 0, stepsOuterRange,
                      (w, h) => Min(w, h) / 2 / 3 * 2, valueEnabled ? 67 : 50, drawText);
            drawRange(e.Surface.Canvas, e.Info.Width, e.Info.Height, angleInnerRange, stepsOuterRange,
                      (w, h) => Min(w, h) / 2 / 3, valueEnabled ? 84 : 50, drawText);
        }


        private void drawRange(SKCanvas canvas, int width, int height, float degreesOffset, int steps,
                        Func<int, int, float> calcStepSide, float value, bool drawColorValuesAsText = false)
        {
            var degreesStep = 360f / steps;
            var halfWidth = width / 2f;
            var halfHeight = height / 2f;
            var stepSide = calcStepSide(width, height);
            var scale = width / (float)canvasView.Width;
            var textBrush = new SKPaint
            {
                IsAntialias = true,
                Style = SKPaintStyle.Fill,
                Color = SKColors.White,
                TextSize = 10 * scale
            };
            var arcRect = new SKRect(halfWidth - stepSide, halfHeight - stepSide,
                                     halfWidth + stepSide, halfHeight + stepSide);


            for (var i = 0; i < steps; i++)
            {
                var degrees = i * degreesStep;
                var degreesWithOffset = degrees + degreesOffset;
                var brush = new SKPaint
                {
                    IsAntialias = true,
                    Style = SKPaintStyle.Fill,
                    Color = hslEnabled ?
                        SKColor.FromHsl(degrees, 100, value, 255) :
                        SKColor.FromHsv(degrees, 100, value, 255)
                };


                canvas.RotateDegrees(degreesWithOffset, halfWidth, halfHeight);
                using (var path = new SKPath())
                {
                    path.MoveTo(halfWidth, halfHeight);
                    path.ArcTo(arcRect, 270, degreesStep, false);
                    path.Close();
                    canvas.DrawPath(path, brush);
                }


                if (drawColorValuesAsText)
                {
                    var textX = halfWidth;
                    var textY = halfHeight - 3 * (stepSide / 4);
                    canvas.DrawText(brush.Color.ToString(), textX, textY, textBrush);
                }


                canvas.RotateDegrees(-degreesWithOffset, halfWidth, halfHeight);
            }
        }


        private void SwitchShowValues(object sender, ToggledEventArgs e)
        {
            drawText = e.Value;
            canvasView.InvalidateSurface();
        }

        private void SwHSL_Toggled(object sender, ToggledEventArgs e)
        {
            hslEnabled = e.Value;
            canvasView.InvalidateSurface();
            HslSwitchLabel.TextColor = ShowTextLabel.TextColor = hslEnabled ? Colors.Black : Colors.Chocolate;
        }
    }

Et c’est tout !

Oui c’est tout. Pas de trucs exotiques à ajouter, d’initialisation bizarres, pas de modifications dans l’App.Xaml, pas de ressources spéciales à gérer, rien.

On vient juste d’ajouter les primitives 2D qui manquaient un peu au XAML de MAUI . En ajoutant un paquet Nuget et en codant de la façon de la plus classique qu’il soit…

Conclusion

Que dire... Si cela ne vous a pas convaincu de la nécessité d’ajouter un peu de vie à vos Apps par le biais de la 2D de SkiaSharp alors je ne peux rien pour vous…

Mais je suis certain que les lecteurs de Dot.Blog seront sensibles à mes arguments visuels et verront tout le potentiel qui se cache derrière ce simple exemple sans prétention.

Le mieux étant de tester vous-mêmes. Et puis de …

Stay Tuned !

Faites des heureux, PARTAGEZ l'article !