Xamarin.Forms

Créer un contrôle réutilisable 100% Xamarin.Forms, partie 2

par
publié le
Image par PIRO4D de Pixabay

Vous apprendrez notamment à créer des propriétés bindables (BindableProperty) et à utiliser les Converters

Si ce n'est pas déjà fait, je vous invite à d'abord consulter la première partie de cette article dans laquelle nous avons construit les bases d'une image circulaires avec quelques options (placeholder, bordure...).


Mais où va-t-on ?

Commençons par un petit rappel, voici une démonstration animée de l'image circulaire en situation.

Comme je suis un gars vraiment sympa, pour le même prix je vous ajoute une copie d'écran iOS. La base de code est 100% commune, un seul code pour générer deux applications natives. C'est la beauté de Xamarin.Forms.

 On y voit : 

  • Des images circulaires
  • Une bordure s'afficher si le contact est placé dans les favoris
  • Un indicateur de chargement tourbillonner brièvement sur les deux dernières images
  • Des images de substitution par défaut s'il n'existe pas d'image valide (image non renseignée, url invalide ou inaccessible...)

La mise-en-page est très simple, un titre, un sous-titre indiquant le nombre de favoris et une liste de contacts.

Par contre, hors de question de reprendre tel quel le XAML de l'image circulaire créée dans l'article précédent et de le coller comme ça, l'air de rien. Hé ! Oh ! On est des vrais professionnels n'est-ce pas ? Nous allons donc en faire un contrôle découplé du reste du code et réutilisable au besoin partout dans le projet.

Création du contrôle

Composons

Souvenez-vous, dans la première partie de l'article nous avions créé une image circulaire à l'aide de la propriété Clip et d'une EllipseGemotry. Puis nous l'avions agrémentée de quelques options en y superposant d'autres contrôles dans une Grid.

Nous avons donc une base avec une image circulaire simple : 

<Image Source="monimage.png" WidthRequest="128" HeightRequest="128">
    <Image.Clip>
        <EllipseGeometry RadiusX="64"
                         RadiusY="64"
                         Center="64,64" />
    </Image.Clip>
</Image>

Et une image circulaire avancée, composée à partir de la précédente :

<Grid>
    <Image Source="monPlaceholder.png" WidthRequest="128" HeightRequest="128">
        <Image.Clip>
            <EllipseGeometry RadiusX="64"
                         RadiusY="64"
                         Center="64,64" />
        </Image.Clip>
    </Image>

    <Image Source="monimage.png" WidthRequest="128" HeightRequest="128">
        <Image.Clip>
            <EllipseGeometry RadiusX="64"
                         RadiusY="64"
                         Center="64,64" />
        </Image.Clip>
    </Image>

    <Ellipse Margin="0"
             HorizontalOptions="Center"
             VerticalOptions="Center"
             Stroke="Yellow"
             StrokeThickness="2"
             HeightRequest="128"
             WidthRequest="128"
             />

     <ActivityIndicator IsRunning="{Binding Source={x:Reference monImage}, Path=IsLoading}"
                        VerticalOptions="Center"
                        HorizontalOptions="Center"
                        />
</Grid>

Nous avons le squelette de notre contrôle mais quelque chose me chiffonne. Ce n'est tout de même pas bien beau cette duplication de code. Et si, au lieu de copier-coller le code de l'image "clippée" avec l'ellipse, on en faisait un contrôle d'image circulaire tout simple mais réutilisable ?

Notre image étant inscrite dans un cercle, cela me gêne de définir HeightRequest et WidthRequest. Les deux propriétés auront toujours la même valeur, autant définir une nouvelle propriété ImageSize.

Quelque chose comme ça :

<Grid>
    <local:CircleImage x:Name="monPlaceholder"
                         Source="monPlaceholder.png"
                         ImageSize="128" />
                         
    <local:CircleImage x:Name="monImage"
                         Source="monImage.png"
                         ImageSize="128" />

    [...] Le reste du contrôle avec la bordure et l'activityIndicator
</Grid>

Nous allons donc commencer par créer un contrôle CircleImage qui hérite du contrôle Image de base, en y ajoutant une propriété ImageSize et un "clipping" circulaire.

Et comme affecter des valeurs en dur ça n'a pas beaucoup d'intérêt dans un vrai projet, nous rendrons la propriété ImageSize bindable de façon à pouvoir écrire quelque chose comme :

<local:CircleImage x:Name="monPlaceholder"
                     Source="{Binding MonImage}"
                     ImageSize="{Binding MyImageSize}" />

La propriété Source héritant du contrôle Image de base, elle est déjà bindable.

Si ceci vous parait obscur, relisez mon article sur l'architecture MVVM !

Créer un nouveau contrôle

Pour faire les choses proprement, créez un dossier CircleImage à la base de votre projet Xamarin.Forms et ajoutez-y un ContentView nommé CircleImage. Il n'y a pas de mal à être pragmatique sur le nommage. 😁

Copie d'écran pour illustrer l'ajout d'un ContentView dans Xamarin.Forms
Ajoutez un ContentView nommé CircleImage

Par défaut, votre ContentView est de type... ContentView. Ça va de soi mais je le précise pour les deux qui dorment au fond vers le radiateur.

Nous pourrions ajouter notre Image comme contenu du ContentView, mais pourquoi alourdir le code et l'affichage natif en imbriquant des contrôles alors que nous avons uniquement besoin d'une image ? Nous allons donc remplacer le type ContentView par le type Image.

Attention ! Pensez bien à le faire à la fois dans le XAML...

<!-- Remplacer -->
<ContentView x:Class="CircleImageDemo.CircleImage.CircleImage"
<-- Par -->
<Image x:Class="CircleImageDemo.CircleImage.CircleImage"

...et dans le code-behind C# !

// Remplacer
public partial class CircleImage : ContentView { }
// Par
public partial class CircleImage : Image { }

Nous avons presque tout ce qu'il nous faut, il s'agit juste de créer le découpage circulaire dans le XAML et d'ajouter la propriété bindable ImageSize. Commençons par ceci.

Exposer des propriétés bindables depuis l'extérieur

Je suis en train de créer un contrôle visuel et je souhaite exposer une propriété qui soit bindable depuis et/ou vers l'extérieur. Il s'agit principalement de définir une propriété qui encapsule une BindableProperty au lieu d'un champs privé comme cela se fait habituellement.

Je vous vois froncer les sourcils, alors un exemple : 

#region ImageSize Bindable property
// Bindable property
public static readonly BindableProperty ImageSizeProperty =
  BindableProperty.Create(
     propertyName: nameof(ImageSize),
     declaringType: typeof(CircleImage),
     returnType: typeof(double),
     defaultValue: 0.0,
     defaultBindingMode: BindingMode.OneWay,
     propertyChanged: (bindable, oldValue, newValue) =>
     { });

// Gets or sets value of this BindableProperty
public double ImageSize
{
    get => (double)GetValue(ImageSizeProperty);
    set => SetValue(ImageSizeProperty, value);
}
#endregion

Nous avons la propriété ImageSize qui sera exposée et sur laquelle on viendra "binder" dans le XAML.

La gestion du binding proprement dit est laissée à la BindableProperty ImageSizeProperty instanciée par la méthode statique  BindableProperty.Create avec les arguments suivants : 

  • propertyName : nom de la propriété exposée, celle qui recevra les valeurs via Binding
  • declaringType : type de la classe portant la propriété, ici CircleImage
  • returnType : type retourné par la propriété
  • defaultValue : valeur par défaut
  • defaultBindingMode : définit le sens du binding par défaut
  • coerceValue : permet de réévaluer la valeur d'une BindableProperty quand la valeur d'une autre BindableProperty change. Pas évident de l'expliquer en une seule phrase, je vous recommande de lire la documentation !
  • propertyChanged : callback appelé quand la valeur du binding vient de changer
  • propertyChanging : idem quand la valeur est en train de changer
  • defaultValueCreator : permet de définir une valeur par défaut à partir d'une Func 

Seuls les trois premiers arguments sont obligatoires.

Tout ceci se place bien entendu dans le code-behind de la View.

Nous avons désormais une propriété ImageSize mais comment allons-nous l'utiliser en interne dans notre CircleImage ?

Il y a plusieurs façons de voir les choses. Dans des cas simples qui touchent directement l'aspect visuel, on peut le faire directement dans le XAML. Parfois, c'est plus pertinent d'agir via les callbacks propertyChanged ou propertyChanging de la BindableProperty qui offrent plus de souplesse et de liberté que XAML.

Je vais vous présenter les deux mais, bien entendu, dans le projet GitHub je n'ai pu en laisser qu'un !

Lier les propriétés bindables au XAML du contrôle

Partons de notre CircleImage de base. 

<?xml version="1.0" encoding="UTF-8" ?>
<Image x:Class="CircleImageDemo.CircleImage.CircleImage"
       xmlns="http://xamarin.com/schemas/2014/forms"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       HeightRequest="128"
       WidthRequest="128">
    <Image.Clip>
        <EllipseGeometry RadiusX="64"
                         RadiusY="64"
                         Center="64,64"
                         />
    </Image.Clip>
</Image>

L'enjeu est de réussir à lier la propriété ImageSize aux propriétés HeightRequest et WidthRequest de l'image, ainsi qu'aux RadiusXRadiusY et Center de l'ellipse dont les valeurs sont la moitié de ImageSize.

Pour cela, nous allons utiliser un Binding. Cela peut paraître un peu confus, il faut suivre un peu : on va binder en interne une valeur qui provient d'un Binding externe. Une fois qu'on a compris ça, on a tout compris.

Sauf que là, comme ça, ça ne fonctionnera pas. Il est nécessaire de préciser à notre View que son contexte de binding est... elle-même !

Une méthode simple est de donner un nom à notre contrôle, "this" par exemple pour mimer le mot clé "this" du C# et de donner "this" comme source du contexte de binding :

<Image x:Name="this" BindingContext="{x:Reference this}" ...>

On peut désormais binder ImageSize aux propriétés de l'Image :

<?xml version="1.0" encoding="UTF-8" ?>
<Image x:Name="this"
       [...]
       BindingContext="{x:Reference this}"
       HeightRequest="{Binding ImageSize}"
       WidthRequest="{Binding ImageSize}"
       >
    [...]
</Image>

Ça y est, nous avons une image carrée de côté ImageSize

Vous la voulez circulaire ? Fastoche, il suffirait de binder (ImageSize / 2) sur les propriétés de l'ellipse... si seulement c'était possible !

Il nous faut trouver un moyen de modifier la valeur du binding à la volée... Ça tombe bien, Xamarin.Forms nous propose un outil pour cela : le Converter.

Je n'entre pas dans les détails car j'ai un article en cours de rédaction sur le sujet, sachez simplement que le principe est simple : il s'agit d'une classe implémentant l'interface IValueConverter, dont la principale méthode reçoit une valeur en entrée et en retourne une autre en sortie. Exactement ce qu'il nous faut. Nous allons créer un DividerConverter !

Pour rendre le converter plus versatile, celui-ci expose une propriété Divider définissant la quantité par laquelle nous souhaitons diviser. Nous aurions pu simplement diviser par deux, en dur dans le code, mais ça limiterait la réutilisation du converter.

La méthode Convert() divise la valeur reçue de l'extérieur, la méthode ConvertBack() fait l'inverse dans le cas d'un binding à double sens (si on modifie la valeur au niveau de l'interface utilisateur, elle sera répercutée dans le code, mais bien entendue avec l'opération arithmétique inverse !)

public class DoubleDividerConverter : IValueConverter
{      
    public int Divider { get; set; } = 1;

    // Divise la valeur reçue par Divider
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is double doubleToDivide)
        {
            return doubleToDivide / Divider;
        }

        throw new ArgumentException($"{nameof(value)} should be of type double");
    }

    // Multiplie la valeur renvoyée par Divider
    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is double doubleToMultiply)
        {
            return doubleToMultiply * Divider;
        }

        throw new ArgumentException($"{nameof(value)} should be of type double");
    }
}

Reste à utiliser le Converter dans le XAML :

  • Le déclarer comme ressource statique dans la View
  • L'ajouter aux Bindings
  • Préciser à l'EllipseGeometry son contexte de Binding.
<?xml version="1.0" encoding="UTF-8" ?>
<Image x:Name="this"
         [...]
       >
    <Image.Resources>
        <ResourceDictionary>
            <local:DoubleDividerConverter 
                    x:Key="DoubleDividerConverter" 
                    Divider="2" />
        </ResourceDictionary>
    </Image.Resources>

    <Image.Clip>
        <EllipseGeometry BindingContext="{x:Reference this}"
                         RadiusX="{Binding ImageSize, Converter={StaticResource DoubleDividerConverter}}"
                         RadiusY="{Binding ImageSize, Converter={StaticResource DoubleDividerConverter}}"
                         Center="??? Center attend un type Point, je vous laisse réfléchir quelques instants"
                         />
    </Image.Clip>
</Image>

C'est bon pour la forme de l'ellipse, nous venons d'en faire un cercle de rayon (Imagesize / 2).

Reste à la positionner via la propriété Center. Celle-ci attend une valeur de type Point. Nous allons là encore utiliser un converter. Il recevra la taille de l'image en entrée et retournera un point dont les coordonnées sont au centre de l'image.

Vous trouverez tout le code dans le dépôt GitHub

Gérer la propriété bindable dans le code behind

Si la beauté du XAML vous laisse de marbre, il est possible d'effectuer la même chose directement dans le code-behind de la View.

Il suffit de placer notre code dans l'événement propertyChanged de la BindableProperty : nous y définirons le rayon du cercle, la taille de l'image et nous appliquerons l'ellipse à l'image après avoir créé celle-ci.

Et c'est tout !

public static readonly BindableProperty ImageSizeProperty =
  BindableProperty.Create(
     propertyName: nameof(ImageSize),
     declaringType: typeof(CircleImage),
     returnType: typeof(double),
     defaultValue: 0.0,
     defaultBindingMode: BindingMode.OneWay,
     propertyChanged: (bindable, oldValue, newValue) =>
     {
         if (bindable is CircleImage circleImage && newValue is double imageSize)
         {
             double radius = imageSize / 2;
             EllipseGeometry ellipseGeometry = new EllipseGeometry()
             {
                 Center = new Point(radius, radius),
                 RadiusX = radius,
                 RadiusY = radius
             };

             circleImage.HeightRequest = imageSize;
             circleImage.WidthRequest = imageSize;
             circleImage.Clip = ellipseGeometry;
         }
     });

Vous allez me dire, ça valait bien la peine de s'embêter en XAML avec des converters ! Ce n'est pas complètement faux. Mais pas totalement vrai.

La qualité d'un code ne se limite pas à sa simplicité mais aussi à sa clarté. Ici, un développeur ne connaissant pas le code devra chercher à l'aveuglette dans le code-behind pour comprendre comment l'image est dimensionnée et devient circulaire. Tant que ça reste simple, ça passe. Dans un code plus touffu, cela peut vite devenir un véritable casse-tête !

Il y a également un risque accru de monstre spaghetti et de crotte de nez dans le code. Un développeur débutant ou peu consciencieux, aura vite fait de mélanger tout et n'importe quoi dans propertyChanged. Au minimum, il aurait fallu que j'extrais le code dans une méthode à part. Et pour être parfaitement propre, séparer le dimensionnement de l'image du découpage circulaire dans deux méthodes distinctes. Et tant qu'on y est, placer la conversion entre la taille de l'image et l'ellipse dans une classe à part pour que ce soit réutilisable. Une sorte de Converter qui cacherait son nom, en quelque sorte... Bref, le code-behind est plus simple surtout parce qu'on peut librement y coder comme un sale.

L'avantage de la méthode "XAML", c'est que 100% du code concernant l'aspect visuel est au même endroit, directement là où on irait le chercher spontanément. Tout y est explicite, on sait d'emblée où chercher l'information. Et ça, en débogage, ça n'a pas de prix !

Appliquons le même principe pour créer un contrôle avancé

Nous avons désormais une image circulaire simple et réutilisable. Nous allons de même créer une image circulaire encapsulant la précédente pour lui ajouter quelques options.

Pour commencer, nous allons créer comme précédemment un ContentView que nous nommerons AdvancedCircleImage et qui héritera de Grid puisque c'est le Layout de base de notre contrôle.

Je vous laisse faire, c'est exactement comme tout à l'heure, avec une Grid à la place de l'Image.

A l'intérieur de notre Grid, nous aurons donc : 

  • Deux itérations de notre CircleImage, l'une pour l'image, l'autre pour l'image de substitution
  • Une Ellipse pour la bordure
  • Un ActivityIndicator pour l'indicateur de chargement

Dans le code-behind, nous définirons quelques BindablePropertie :

  • MainImageSource : source de l'image principale
  • PlaceholderImageSource : source du placeholder
  • ImageSize : taille de l'image
  • IsBorderVisible : la bordure est-elle visible ?
  • BorderColor : couleur de la bordure
  • BorderThickness : épaisseur de la bordure
  • IsLoaderEnabled : l'indicateur de chargement est-il actif (si faux on ne le verra jamais, si vrai on le verra quand l'image est en train de charger)

Nous allons appliquer la même recette que précédemment, voici une version simplifiée du XAML de laquelle j'ai retiré ce qui n'est pas nécessaire à la compréhension :

<Grid x:Name="this">
    <Grid.Resources>
        <DoubleDividerConverter x:Key="DoubleDividerConverter" Divider="2" />
        <ImageSizeToCenterConverter x:Key="ImageSizeToCenterConverter" />
        <ColorToBrushConverter x:Key="ColorToBrushConverter" />
    </Grid.Resources>

    <local:CircleImage BindingContext="{x:Reference this}"
                       ImageSize="{Binding ImageSize}"
                       Source="{Binding PlaceholderImageSource}"/>

    <local:CircleImage x:Name="Image"
					 BindingContext="{x:Reference this}"
                     ImageSize="{Binding ImageSize}"
                     Source="{Binding MainImageSource}" />

    <Ellipse BindingContext="{x:Reference this}"
             Stroke="{Binding BorderColor, Converter={StaticResource ColorToBrushConverter}}"
             StrokeThickness="{Binding BorderThickness}"
             HeightRequest="{Binding ImageSize}"
             WidthRequest="{Binding ImageSize}"
             IsVisible="{Binding IsBorderVisible}"/>

    <ActivityIndicator IsRunning="{Binding Source={x:Reference Image}, Path=IsLoading}"
                       BindingContext="{x:Reference this}"
                       IsVisible="{Binding IsLoaderEnabled}"
                       Color="{Binding BorderColor}"/>
</Grid>

Vous noterez juste l'apparition d'un nouveau Converter : notre propriété BorderColor transmet un type Color alors que la propriété Stroke de l'Ellipse attend une Brush.

Pour le reste, ce n'est que du Binding vers les BindableProperty et des astuces que nous avons déjà vu dans la première partie de l'article.

Nous avons désormais une image circulaire avancée parfaitement réutilisable dans notre projet ! Chouette !

Nous en avons même deux : CircleImage et AdvancedCircleImage !

Utiliser le contrôle dans le code

En réalité, vous savez déjà comment utiliser votre contrôle personnalisé puisque vous l'avez déjà fait en incorporant CircleImage dans le XAML de AdvancedCircleImage

Il suffit de :

  1. Déclarer le namespace auquel appartient votre contrôle dans votre ContentPage
  2. Utiliser le contrôle dans le XAML

Voici un exemple très simplifié :

<ContentPage x:Class="CircleImageDemo.MainPage"
            xmlns:ci="clr-namespace:CircleImageDemo.CircleImage"
            [...]>            
    <ci:AdvancedCircleImage ImageSize="64"
                            MainImageSource="{Binding PhotoUrl}"
                            PlaceholderImageSource="{Binding PlaceholderImage}"
                            BorderColor="Yellow"
                            BorderThickness="4"
                            IsBorderVisible="{Binding IsBookmarked}"
                            IsLoaderEnabled="True" />
</ContentPage>

Dans le projet de démonstration, j'utilise l'AdvancedCircleImage dans une liste (en fait un BindableLayout mais ce n'est pas le sujet).

Le projet de démonstration

Je vous redonne le lien vers le dépôt GitHub du projet : https://github.com/SylvainMoingeon/CircleImageDemo ainsi que la petite animation :

Celle-ci simule une page de contacts qui existe avant tout pour illustrer le contrôle personnalisé que nous venons de créer.

L'AdvancedCircleImage est utilisée pour afficher les photos de contacts. Si l'image n'est pas renseignée ou indisponible (url invalide par exemple), le contrôle affiche une image de substitution par défaut à la place.

Au clic sur une fiche contact, celui-ci bascule de l'état "mis en favori" à l'état "pas mis en favori", ce qui affiche ou non la bordure.

L'indicateur de chargement est fugace mais visible dans les deux dernières fiches puisqu'une image est définie dans chacune d'elle mais inaccessible (erreur 404 et url non joignable).

Jetez-y un œil !

C'est tout en ce qui concerne l'image circulaire, mais il y a deux ou trois petites choses dans le code qui pourraient vous intéresser si vous n'êtes pas familier avec XAML et MVVM. Et si vous êtes arrivé jusqu'à la fin de cet article, c'est sans doute le cas !

StringFormat

Pour illustrer l'usage de MVVM, j'ai ajouté un Label qui indique le nombre de contacts en favori. Celui-ci utilise StringFormat pour afficher une phrase complète intégrant la valeur obtenue via le Binding. StringFormat est un outil très puissant et malheureusement souvent négligé par les développeurs. Il vous évitera pas mal de mauvaises bidouilles et de crottes dans le code.

Code / XAML spécifique à la plateforme

Il y a quelques cas à la marge mais suffisamment fréquents où malheureusement un code 100% commun n'est plus suffisant. Cela tient surtout à des différences fonctionnelles entre les plateformes. 

Par exemple, l'encoche et le bouton virtuel apparus sur iOS à partir de l'iPhone X (il me semble). Lorsque vous définissez votre mise-en-page, vous risquez fort d'obtenir quelque chose comme ceci :

Il existe quelques fonctionnalités spécifiques aux plateformes directement accessibles dans le code commun, notamment pour iOS et Android. Ici, nous allons nous intéresser au SafeArea d'iOS.

Dans le XAML :

<ContentPage 
xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
ios:Page.UseSafeArea="True"
[...]>
    [...]
</ContentPage>

Ce qui corrigera le problème en protégeant les zones de l'encoche et du bouton virtuel pour éviter ces affreux chevauchements :

Fouillez dans la documentation, il y en a d'autres qui vous éviterons prises de tête et tentatives de corriger ces choses là vous même en codant dans les projets natifs.

DesignTimeVisible

Si vous utilisez le Previewer Xamarin.Forms, vous constaterez que vos contrôles personnalisés n'apparaissent pas dedans, ce qui peut être gênant lorsque vous êtes en train de dessiner vos interfaces.

Copie d'écran présentant le previewer Xamarin.Forms, le contrôle personnalisé n'est pas affiché
L'image circulaire n'est pas affichée pas dans le previewer, quel malheur !

La plupart du temps, ça ne tient qu'à une ligne de code ! Un simple attribut à ajouter à votre classe : [DesignTimeVisible(true)]

Comme son nom l'indique de façon assez explicite, cet attribut indique si la classe doit être traitée ou non par le previewer.

[XamlCompilation(XamlCompilationOptions.Compile)]
[DesignTimeVisible(true)]
public partial class AdvancedCircleImage : Grid
{
[...]
}

Pensez bien à l'ajouter à chacun de vos contrôles personnalisés ! Ici au CircleImage et à l'AdvancedCircleImage. Voici le résultat, une fois l'attribut ajouté à chacune des classes : 

Illustration du previewer avec l'attribut DesignTimeVisible
L'image circulaire est bien affichée grâce à l'attribut DesignTimeVisible(true)

Conclusion

Pour allez plus loin, il faudrait totalement sortir le contrôle personnalisé du projet et pourquoi pas en faire un package nugget. Peut-être une idée pour un prochain article !

Si vous avez des questions, des astuces ou même des remontrances concernant le sujet, je vous invite à laisser des commentaires. Juste là dessous. ⬇