Créer un contrôle réutilisable 100% Xamarin.Forms, partie 2
par Sylvainpublié leVous 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. 😁
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 RadiusX
, RadiusY
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 :
- Déclarer le namespace auquel appartient votre contrôle dans votre
ContentPage
- 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.
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 :
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. ⬇