Dot.Blog

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

UWP/UAP : Du bon usage de RelativePanel et AdaptiveTrigger

Le RelativePanel et l’AdaptiveTrigger sont des nouveautés du XAML de UWP. Grâce à eux on positionne les éléments les uns par rapport aux autres et on réagit aux différentes résolutions. Comment s’en servir concrètement dans le cadre de l’Adaptive Design ?

Adaptive Design

Je ne m’étendrais pas des heures sur le sujet, regardez dans les billets de ces derniers mois c’est un sujet que j’ai déjà traité. Pour faire court l’adative design est une façon de concevoir les UI de telle sorte à ce qu’elles s’adaptent dynamiquement aux changements de tailles et résolutions des machines.

Ce qui était purement théorique est devenu réalité avec UWP qui offre le moyen de faire un seul code, mais aussi une seule UI, pour tous les form factors.

Il est ainsi devenu essentiel de rendre les UI réactives pour qu’elles s’adaptent instantanément tantôt à l’écran d’un PC, tantôt à celui d’un smartphone ou d’une tablette, voire d’un IoT, une XBox ou les Hololens…

Pour arriver à un tel tour de force on utilise toute la puissance du vectoriel de XAML mais aussi certaines nouveautés comme le RelativePanel.

Le RelativePanel

Si le Canvas est déconseillé depuis longtemps car trop lié aux pixels réels, la Grid a été sous Silverlight et WPF le contrôle de base par excellence pour créer des mises en page. Avec UWP la Grid reste toujours aussi utile mais le RelativePanel pourrait bien lui voler la vedette en tant qu’outil de base.

En effet les adaptions d’un StackPanel, d’un WrapPanel ou d’une Grid pour intelligentes qu’elles soient sont assez limitées en termes de mise en page. La Grid n’est finalement qu’une sorte de <table> HTML.

S’il s’agit de s’adapter à des affichage plus ou moins grand sur une seule famille de machines, comme les PC par exemple, la Grid fait des merveilles. Les lignes, les colonnes peuvent s’adapter en taille et offrir plus ou moins de place à l’information ce qui est souvent suffisant.

Mais s’il s’agit de s’adapter du PC au smartphone en passant par des tablettes ou une XBox, l’exercice de style est trop difficile en jouant uniquement sur la Grid ou les autres conteneurs. La raison en est simple : passer d’un écran de plus de 20 pouces à un autre de 4 pouces ne se fait pas seulement en rendant les colonnes et les lignes d’une Grid plus ou moins grandes… Il faut pouvoir totalement changer la disposition des contrôles, voire en cacher ou montrer certains. Le tout pour conserver une bonne lisibilité de la page en toute circonstance.

C’est là qu’entre en scène le RelativePanel qui à la base n’a rien de réactif : il permet seulement de positionner les contrôles enfants les uns par rapport aux autres de façon relative. Par exemple en indiquant que tel TextBlock sera à droite de telle TextBox… Ou en dessous, au dessus, etc.

Le RelativePanel permet un placement relatif mais pas réactif. Alors pourquoi joue-t-il un rôle si important dans cette nouvelle approche du design ?

Simplement parce qu’en changeant juste les relations entre les contrôles on modifie rapidement la mise en page. Je peux faire passer un libellé au dessus d’une zone de saisie sur un écran pas très large alors que je veux qu’il soit à sa gauche sur un écran large. On comprend bien que le RelativePanel par son mode de fonctionnement permet de changer facilement l’ordre visuel des composants bien mieux qu’une Grid qui ne peut que rendre des colonnes plus étroites ou plus larges (idem pour les lignes).

La Grid n’est pas hors jeu dans le reactive design, elle peut largement y tenir un rôle essentiel mais le RelativePanel se montre très bien adapté à ces changements de configurations visuelles. En associant le RelativePanel à d’autres conteneurs comme Grid ou StackPanel il devient possible de créer des UI très souples et facilement adaptables à de nombreux types d’écrans.

Toutefois puisque le RelativePanel n’offre qu’un algorithme de plus pour le placement de ses enfants (comme Grid ou WrapPanel) comment peut-on le rendre “réactif” ?

La réactivité réelle, celle qui déclenche “des choses” lorsque la taille de l’écran varie (ou la taille de la fenêtre de l’application), est à trouver dans un autre mécanisme, celui des AdaptiveTrigger…

AdaptiveTrigger

Pour relever le défi de l’adaptive design il ne suffit pas de disposer d’un RelativePanel comme on vient de le voir. Il faut aussi disposer d’un quelque chose de plus, un moyen d’être averti que la taille de la fenêtre est en dessous ou au dessus d’une certaine valeur pour ensuite aller modifier les relations entre contrôles dans un RelativePanel (ou d’autres conteneurs).

Le RelativePanel n’est qu’un moyen de plus de rendre le travail de design sous XAML ultra souple. Mais bien entendu on a le droit de modifier n’importe quel élément pour s’adapter à un autre form factor. Il n’y a pas même obligation d’utiliser un RelativePanel, c’est juste que cela a été conçu pour et que c’est bien pratique de créer ses UI avec ce nouveau conteneur.

Donc quels que soient les conteneurs ou contrôles utilisés, c’est grâce à l’AdaptiveTrigger qu’on va pouvoir réagir au contexte visuel et pouvoir modifier les valeurs des autres contrôles (visibilité, taille, position, … toutes les propriétés peuvent changer).

L’AdaptiveTrigger est un nouveau type de déclencheur utilisable par le VisualStateManager de XAML. Comme le RelativePanel il n’invente pas un concept, déjà existant dans XAML, mais il propose un nouvel algorithme pour élargir nos possibilités de mise en page.

Grâce à l’AdaptiveTrigger on peut tout simplement créer des états visuels dans le VisualStateManager qui dépendent de la taille écran (largeur ou hauteur). Lorsque la condition de taille est détectée par le VSM l’état visuel associé est activé automatiquement sans besoin d’aucun code C# pour cela.

On peut ainsi concevoir avec le même code XAML plusieurs configurations d’écran en un seul fichier. Grâce à Blend qui sait manipuler le VSM on passe d’un état à l’autre par un simple clic sans avoir besoin de réellement redimensionner à chaque fois son espace de travail.

C’est l’occasion de la dire et de le redire, Blend est l’outil pour travailler les UI, depuis les débuts de XAML et encore plus aujourd’hui. Même si le designer XAML de VS s’est beaucoup amélioré avec le temps, Blend lui est supérieur en tout point et sait manipuler des concepts absent du designer de VS, comme les états visuels ou les animations par exemple.

Un exemple visuel

Le mieux pour bien comprendre comment tout cela fonctionne est de regarder le petit gif animé ci-dessous (une capture écran de la démo dont on verra le code plus loin) :

DemoRelativePanel

Ce que l’on voit

On voit une fenêtre d’application UWP lancée en mode Local Machine, donc sur un PC de développement.

La fenêtre est de couleur Cyan et présente trois champs :

  • Le libellé “Votre Prénom :” (TextBlock)
  • La zone de saisie (TextBox)
  • Un libellé en bas à gauche qui indique la largeur actuelle de la fenêtre (affichage purement technique, seuls les deux autres contrôles font réellement partie de la démo).

Lorsque la fenêtre possède une largeur suffisante (au dessus de 800 pixels effectifs, inclus), le libellé se trouve devant la zone de saisie.

Lorsque la fenêtre devient plus petite, en dessous de 800 epx mais au dessus de 600 epx outre le changement de couleur du fond qui devient gris pour bien marquer visuellement la rupture dans la démo, la zone de saisie passe en-dessous du libellé.

Si on continue à réduire la fenêtre, et en dessous de 600 epx, le fond devient blanc là aussi pour marquer visuellement le passage de la frontière mais surtout le libellé disparait et à l’intérieur de la zone de saisie apparait un texte fantôme indiquant qu’il faut saisir son prénom.

Si les changements de couleur du fond ne sont là que pour vous aider à voir les franchissements de frontières, la façon dont se positionnent les contrôles et dont leurs propriétés changent sont directement liées aux mécanismes d’adaptive design présentés ici.

Ce qui se passe

Pour que notre fenêtre réagisse de la sorte il a fallu plusieurs choses :

  1. avoir mis en place des états visuels pilotés par des AdaptiveTrigger
  2. avoir choisi les “frontières” aussi bien leur nombre que les valeurs qui les définissent
  3. avoir utilisé un RelativePanel pour positionner les contrôles de façon relative
  4. avoir modifié les placements relatifs du RelativePanel dans chaque état visuel déclenché par les AdaptiveTrigger

C’est ainsi qu’on arrive à nos fins, par un savant mélange des possibilités de ces deux nouveautés de XAML sous UWP, l’AdaptiveTrigger et le RelativePanel…

Le Code

Le code XAML de la mise en page qu’on peut admirer dans la capture GIF animée est le suivant :

<Page
    x:Class="App4.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:App4"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d" >

    <Grid x:Name="LayoutRoot" Background="Cyan" >
        <Grid.Resources>
            <local:ActualSizePropertyProxy Element="{Binding ElementName=LayoutRoot}" x:Name="widthProxy" />
        </Grid.Resources>
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="MiseEnPageGroup">
                <VisualState x:Name="LargeState">
                    <VisualState.StateTriggers>
                        <AdaptiveTrigger MinWindowWidth="800" />
                    </VisualState.StateTriggers>
                    <VisualState.Setters>
                        <Setter Target="LayoutRoot.Background" Value="Cyan" />
                        <Setter Target="textBox.(RelativePanel.RightOf)" Value="textBlock" />
                        <Setter Target="textBox.PlaceholderText" Value="" />
                    </VisualState.Setters>
                </VisualState>

                <VisualState x:Name="MoyenState">
                    <VisualState.StateTriggers>
                        <AdaptiveTrigger MinWindowWidth="600" />
                    </VisualState.StateTriggers>
                    <VisualState.Setters>
                        <Setter Target="LayoutRoot.Background" Value="Silver" />
                        <Setter Target="textBox.(RelativePanel.Below)" Value="textBlock" />
                        <Setter Target="textBox.PlaceholderText" Value="" />
                    </VisualState.Setters>
                </VisualState>

                
                <VisualState x:Name="PetitState">
                    <VisualState.StateTriggers>
                        <AdaptiveTrigger MinWindowWidth="0" />
                    </VisualState.StateTriggers>
                    <VisualState.Setters>
                        <Setter Target="LayoutRoot.Background" Value="White" />
                        <Setter Target="textBlock.Visibility" Value="Collapsed" />
                        <Setter Target="textBox.PlaceholderText" Value="Prénom..." />
                    </VisualState.Setters>
                </VisualState>
                
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
        <RelativePanel Padding="15">
            <TextBlock x:Name="textBlock" Text="Votre prénom :" Margin="0,5,10,0"  />
            <TextBox x:Name="textBox" TextWrapping="NoWrap" Text="" Width="150"  />
            <TextBlock x:Name="textBlock2" Text="{Binding ElementName=widthProxy,Path=ActualWidthValue}" RelativePanel.AlignBottomWithPanel="True" Style="{ThemeResource TitleTextBlockStyle}" />
        </RelativePanel>

    </Grid>
</Page>

 

Ce code est très simple et je vous laisse le lire tranquillement maintenant que vous en connaissez la finalité et même le visuel en mouvement.

Reste un petit détail pour les curieux qui n’a rien à voir avec le sujet du jour mais qui mérite quelques mots :

Le TextBlock qui affiche la largeur du LayoutRoot le fait bien entendu par l’effet d’un Binding. Malheureusement et c’était déjà le cas sous Silverlight, un Binding sur ActualWidth ou ActualHeight ne marche pas, c’est “by design”, aucune notification de changement de propriété n’est envoyée. On se demande bien pourquoi un tel manque et surtout pourquoi cela n’a jamais été corrigé. Mystère !

Mais peu importe, un simple Element Binding sur l’ActualWidth du LayoutRoot (une Grid) ne marche pas.

Pour y arriver j’ai utilise une astuce que j’avoue avoir trouver le Web il ya plusieurs années de cela. L’astuce consiste à créer un “proxy” pour les propriétés qui ne bénéficient pas de INPC. Le code de la classe est le suivant :

public class ActualSizePropertyProxy : FrameworkElement, INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public FrameworkElement Element
        {
            get { return (FrameworkElement)GetValue(ElementProperty); }
            set { SetValue(ElementProperty, value); }
        }

        public double ActualHeightValue => Element?.ActualHeight ?? 0;

        public double ActualWidthValue => Element?.ActualWidth ?? 0;

        public static readonly DependencyProperty ElementProperty =
            DependencyProperty.Register("Element", typeof(FrameworkElement), typeof(ActualSizePropertyProxy),
                                        new PropertyMetadata(null, onElementPropertyChanged));

        private static void onElementPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            ((ActualSizePropertyProxy)d).onElementChanged(e);
        }

        private void onElementChanged(DependencyPropertyChangedEventArgs e)
        {
            var oldElement = (FrameworkElement)e.OldValue;
            var newElement = (FrameworkElement)e.NewValue;

            newElement.SizeChanged += elementSizeChanged;
            if (oldElement != null)
            {
                oldElement.SizeChanged -= elementSizeChanged;
            }
            notifyPropChange();
        }

        private void elementSizeChanged(object sender, SizeChangedEventArgs e)
        {
            notifyPropChange();
        }

        private void notifyPropChange()
        {
            if (PropertyChanged == null) return;
            PropertyChanged(this, new PropertyChangedEventArgs("ActualWidthValue"));
            PropertyChanged(this, new PropertyChangedEventArgs("ActualHeightValue"));
        }
    }

Il n’y a rien d’autre à faire que de déclarer ce code quelque part (dans la démo je l’ai mis dans le code-behind mais ce n’est pas sa place).

Ensuite dans le code XAML il faut attacher le proxy à l’élément dont on veut surveiller la taille, ici le LayoutRoot :

<Grid x:Name="LayoutRoot" Background="Cyan" >
        <Grid.Resources>
            <local:ActualSizePropertyProxy Element="{Binding ElementName=LayoutRoot}" x:Name="widthProxy" />
        </Grid.Resources>

On créée la ressource qu’on binde à la Grid et on lui donne un nom (widthProxy ici) pour s’en servir plus loin.

Et c’est dans le TextBlock qui affiche la largeur du LayoutRoot qu’on retrouve un binding au proxy :

 <TextBlock x:Name="textBlock2" 
   Text="{Binding ElementName=widthProxy,Path=ActualWidthValue}"
    RelativePanel.AlignBottomWithPanel="True" 
    Style="{ThemeResource TitleTextBlockStyle}" />

Et le tour est joué !

Dernier problème : en mode Debug sous UWP comme sous WinRT ou Windows Phone le système affiche des compteurs de frames qui ne sont pas toujours très utiles et qui surtout peuvent empêcher de voir ce qu’il y a en dessous (ce qui est le cas dans la démo de cet article, pile au mauvais endroit).

Donc dernière astuce : comment se débarrasse-t-on de cet affichage en mode debug ? En le demandant poliment, mais reste à savoir quel satané namespace et quelles classes et propriétés sont à bricoler… L’astuce est là d’ailleurs. Tout bêtement en ajoutant dans le constructeur de la page :

Application.Current.DebugSettings.EnableFrameRateCounter = false;

Conclusion

Suivre les principes de l’Adaptive Design réclame en pratique de disposer des bons outils… XAML nous les offre sous la forme d’un nouveau trigger pour le VisualStateManager et d’un nouveau contrôle conteneur ouvrant la porte d’une mise en page par positionnements relatifs des éléments particulièrement bien adaptée à cette gymnastique.

Au passage on règle deux petits problèmes (le binding sur la largeur de la Grid et l’affichage du compteur de frame) ce qui permet d’encore plus rentabiliser la lecture de cet article !

Bon Adaptive Design

Stay Tuned !

Faites des heureux, partagez l'article !
blog comments powered by Disqus