Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Xamarin.Forms : Couleurs, UI et SkiaSharp

SkiaSharp offre des possibilités immenses qui peuvent transformer une App banale en une App attrayante et vendeuse… En quelques lignes… Comment ? c’est ce que je vous propose de voir…

Partir de quelque chose de visuel

Il est évident que pour gérer des calculs ou organiser son code suivant une méthodologie 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 très peu de gens qui ne voient pas du tout les couleurs (achromatopsie) ce qui veut dire que même les daltoniens de toute sorte savent de quoi je parle !

Donc pour cet exemple / prétexte partons des couleurs.

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 aussi excitante que la proposition d’une tête de veau au petit déjeuner …

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 Xamarin.Forms n’est pas un “vrai” XAML car il ne possède pas de primitives graphiques et tout ça…

Belle erreur ! Le XAML des Xamarin.Forms 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 des Xamarin.Forms peut-être enrichi facilement et à moindre frais puisque cela est gratuit…

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 à Xamarin.Forms 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 !)


ColorsSkia


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 ou pas. 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.

Tout cela n’est 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.

N’empêche ça marche et c’est joli…

Imaginez la même chose avec un horrible tableau de couleur 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.

Et ensuite ?

Il suffit de coder… 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://xamarin.com/schemas/2014/forms"
     xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
     xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
     x:Class="Colors.MainPage"
     BackgroundColor="White">
     <AbsoluteLayout>
         <skia:SKCanvasView x:Name="canvasView"
             PaintSurface="Handle_PaintSurface"
             AbsoluteLayout.LayoutFlags="All"
             AbsoluteLayout.LayoutBounds="0,0,0.8,0.8"
             BackgroundColor="Transparent">
             <skia:SKCanvasView.GestureRecognizers>
                 <TapGestureRecognizer
                     Tapped="Handle_Tapped"/>
             </skia:SKCanvasView.GestureRecognizers>
         </skia:SKCanvasView>
         <AbsoluteLayout x:Name="toolBarAbsoluteLayout"
             AbsoluteLayout.LayoutFlags="PositionProportional,WidthProportional"
             AbsoluteLayout.LayoutBounds="0.5,1,1,AutoSize"
             BackgroundColor="#33000000">
             <StackLayout x:Name="toolbarStackLayout"
                 AbsoluteLayout.LayoutFlags="PositionProportional,WidthProportional"
                 AbsoluteLayout.LayoutBounds="0.5,1,0.5,AutoSize"
                 Margin="8"
                 Padding="8">
                 <StackLayout Orientation="Vertical" Scale="0.8">
                     <StackLayout
                     Orientation="Horizontal" WidthRequest="400">
                         <Label x:Name="HslSwitchLabel"
                         Text="HSL (else HSV)?"
                         VerticalOptions="Center"/>
                         <Switch
                         Toggled="Switch_OnToggled"
                         IsToggled="false"
                         VerticalOptions="Center"/>
                     </StackLayout>
                     <StackLayout
                     Orientation="Horizontal" WidthRequest="200">
                         <Label x:Name="ShowTextLabel"
                            Text="Show Values?"
                            VerticalOptions="Center"/>
                         <Switch
                         Toggled="SwitchShowValues"
                         IsToggled="false"
                         VerticalOptions="Center"/>
                     </StackLayout>
                 </StackLayout>
                 <Slider x:Name="innerRandeAngleSlider"
                     ValueChanged="InnerRandeAngleSlider_ValueChanged"
                     Minimum="0"/>
                 <Slider x:Name="outterRandeAngleSlider"
                     ValueChanged="OutterRandeAngleSlider_ValueChanged"
                     Minimum="0"/>
                 <Slider x:Name="stepsSlider"
                     Maximum="20"
                     Minimum="7"
                     ValueChanged="StepsSlider_ValueChanged"/>
             </StackLayout>
         </AbsoluteLayout>
     </AbsoluteLayout>
</ContentPage>

Comme vous le voyez c’est un code classique, il fait juste appel parfois à des contrôles provenant de SkiaSharp et notamment du package supplémentaire destinée à Xamarin.Forms qui offre des contrôles particuliers 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 System;
using System.ComponentModel;
using System.Threading.Tasks;
using SkiaSharp;
using Xamarin.Forms;

namespace Colors
{
     public partial class MainPage : ContentPage
     {
         private int stepsOutterRange = 360;
         private float angleOutterRange;
         private float angleInnerRange;
         private bool drawText;
         private bool valueEnabled = true;
         private bool hslEnabled = true; // else HSV

        public MainPage()
         {
             InitializeComponent();


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

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

            var appearingAnim = new Animation(steps =>
             {
                 stepsOutterRange = (int)steps;
                 canvasView.InvalidateSurface();
             }, stepsOutterRange, 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 outterRandeAngleSlider.FadeTo(1, duration, easing);
                         await innerRandeAngleSlider.FadeTo(1, duration, easing);

                        canvasView.InvalidateSurface();

                        outterRandeAngleSlider.Maximum = innerRandeAngleSlider.Maximum = stepsSlider.Value;
                     });
                 });
         }

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


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

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

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

        private void updateAngleOutterRange(double value)
         {
             angleOutterRange = (float)(Math.Floor(value) * (360 / outterRandeAngleSlider.Maximum));
         }

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

            var prevOutterValue = outterRandeAngleSlider.Value;
             var prevOutterMaximum = outterRandeAngleSlider.Maximum;
             outterRandeAngleSlider.Maximum = stepsOutterRange;
             outterRandeAngleSlider.Value = (stepsOutterRange * prevOutterValue) / prevOutterMaximum;
             updateAngleOutterRange(outterRandeAngleSlider.Value);

            var prevInnerValue = innerRandeAngleSlider.Value;
             var prevInnerMaximum = innerRandeAngleSlider.Maximum;
             innerRandeAngleSlider.Maximum = stepsOutterRange;
             innerRandeAngleSlider.Value = (stepsOutterRange * prevInnerValue) / prevInnerMaximum;
             updateAngleInnerRange(innerRandeAngleSlider.Value);

            canvasView.InvalidateSurface();
         }

        void Handle_PaintSurface(object sender, SkiaSharp.Views.Forms.SKPaintSurfaceEventArgs e)
         {
             e.Surface.Canvas.Clear(hslEnabled ? SKColors.White : SKColors.Black);
             drawRange(e.Surface.Canvas, e.Info.Width, e.Info.Height, angleOutterRange, stepsOutterRange,
                       (w, h) => Math.Min(w, h) / 2 - (float)toolbarStackLayout.Margin.Bottom, 50, drawText);
             drawRange(e.Surface.Canvas, e.Info.Width, e.Info.Height, 0, stepsOutterRange,
                       (w, h) => Math.Min(w, h) / 2 / 3 * 2, valueEnabled ? 67 : 50, drawText);
             drawRange(e.Surface.Canvas, e.Info.Width, e.Info.Height, angleInnerRange, stepsOutterRange,
                       (w, h) => Math.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 Switch_OnToggled(object sender, ToggledEventArgs e)
         {
             hslEnabled = !hslEnabled;
             canvasView.InvalidateSurface();
             HslSwitchLabel.TextColor = ShowTextLabel.TextColor = hslEnabled ? Color.Black : Color.White;
         }


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

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 des Xamarin.Forms. 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 !

blog comments powered by Disqus