grid view complèxe

WinRT : Pousser la GridView à ses limites !

grid view complèxeGridView, le contrôle magique qui est si caractéristique et si omniprésent dans Windows 8. Elle est rapide, avec de belles animations et des comportements de zoom, groupage et scroll tout faits. Mais quel support offre-t-elle si nous voulons modifier son comportement ? Si nous ne voulons pas juste activer ou désactiver une option, mais réellement modifier certains aspects principaux du contrôle ?

Dans cet article, je vous présenterai certaines techniques avancées qui ne couvrent pas la totalité de ce que nous pouvons faire avec la GridView, mais qui peuvent inspirer des solutions facilement réutilisables pour d’autres contrôles ou même dans d’autres Frameworks qui utilisent XAML (Silverlight, Windows Phone, WPF). Notez qu’une familiarisation avec XAML est prérequise pour la compréhension de l’article.

Tuiles de taille variable, mais alignées

Commençons par quelque chose qui peut vous choquer : la GridView n’a pas tous les comportements de la page d’accueil de Windows 8. Ici nous allons voir l’une de ses limitations : l’impossibilité d’avoir des tuiles qui s’alignent correctement à la grille, car chacune possède une taille différente (imaginez par exemple la page d’accueil de Windows 8 : des tuiles petits et grandes).

La solution de base est présentée dans un blog par Diederik Krols : avec très peu de code, il permet de “binder” à une collection de Things, et chaque Thing précise sa taille. Il y a certains points à retenir sur sa solution :

  • La magie principale est faite en modifiant le Panel de la GridView, en utilisant un VariableSizedWrapGrid. Le principe de ce WrapGrid est que chaque élément a des dimensions en multiples de VariableSizedWrapGrid.ItemHeight et VariableSizedWrapGrid.ItemWidth. Un élément tout petit peut avoir alors des dimensions (1,1) qui signifient (ItemWidth, ItemHeight) et un autre peut avoir (4,3) qui signifient (4*ItemWidth, 3*ItemHeight). Comme les éléments sont en multiples des montants fixes, ils s’alignent harmonieusement sur la grille.
  • Ceci marche par défaut si vos éléments sont ajoutés dans Items, mais pour faire marcher ItemsSource, il faut hériter la GridView et surcharger la méthode PrepareContainerForItemOverride, pour copier les valeurs ItemHeight/ItemWidth de votre donnée vers le GridViewItem qui le contient.
  • Pour avoir un résultat agréable, il est important pour chaque élément de prendre tout l’espace qui lui est accordé – il doit s’étirer jusqu’au bout des dimensions qu’il précise. Si vos DataTemplates n’ont pas ce comportement, essayez le suivant :
<GridView.ItemContainerStyle>
     <Style TargetType="GridViewItem">
          <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
          <Setter Property="VerticalContentAlignment" Value="Stretch"/>
      </Style>
</GridView.ItemContainerStyle>

C’est le deuxième point qui nous pose des problèmes. Si les tailles de vos Tuiles ne changent pas, c’est suffisant. Mais qu’est-ce qu’il se passe si vous donnez à l’utilisateur la possibilité de modifier la taille d’une Tuile ? PrepareContainerForItemOverride n’est appelé qu’au chargement de la GridView, et il ne sera pas mis à jour suite aux modifications de vos données.

C’est presque évident. Il faut définir un Binding. Vu que nous ne pouvons pas le faire de façon déclarative (pour des raisons que Krols explique), il faut le déclarer en c# :


protected override void PrepareContainerForItemOverride(DependencyObject element, object item)
{
var fe = (FrameworkElement)element;
fe.SetBinding(VariableSizedWrapGrid.ColumnSpanProperty, new Binding { Path = new PropertyPath("Width") });
fe.SetBinding(VariableSizedWrapGrid.RowSpanProperty, new Binding { Path = new PropertyPath("Height") });

base.PrepareContainerForItemOverride(element, item);
}

Techniques utilisées :

  • Modifier le Panel par défaut d’un Control qui présente une collection (technique aussi utilisable pour la ListView et la ListBox). Attention : chaque Control sait gérer seulement un sous-ensemble des Panels disponibles.
  • Hériter un contrôle de base pour modifier juste une méthode. Technique qui est rarement utilisée en XAML, parce que dans la plupart des cas c’est suffisant de modifier un Template de façon déclarative.
  • Déclarer un Binding en code itératif.

Charger Dynamiquement un Template de Tuile

La taille de la Tuile détermine au chargement le DataTemplate

Vous vous êtes peut-être déjà fait la réflexion suivante : si nous changeons la taille d’une Tuile, nous pouvons afficher plus ou moins d’information. Il faudra alors modifier le DataTemplate. Pas de souci, avec un DataTemplateSelector nous pouvons sélectionner le template correspondant de façon déclarative.

Tout n’est pas si simple

Malheureusement, le DataTemplateSelector.SelectTemplate n’ est appelé qu’au premier affichage. Si la taille de la tuile est modifiée après, le Template n’est pas mis à jour.

Et c’est même pire lorsque la taille de la tuile doit dépendre du DataTemplate (et non l’inverse). Prenons un cas concret (que j’ai rencontré en développant l’application Character Compendium) :

  1. Une tuile peut représenter un pouvoir, objet magique, rituel ou autre.
  2. En mode « concise » la tuile représente seulement les informations nécessaires – un résumé textuel, un titre etc. Par contre ces informations ne consomment pas toujours la même place. Les tailles disponibles pour le mode « concise » sont les (1,1) et (1,2 –  carte à double hauteur) et (1,3).
  3. Quand l’utilisateur sélectionne une tuile, le DataTemplate change en fonction du type de la donnée. La tuile change de taille pour avoir assez de place pour son contenu (dans certaines limites, mais on peut facilement aller jusqu’à (3,6)).

Analysons un peu ces étapes : la donnée (représentée ici par le ViewModel Card) doit fournir à la GridView ses dimensions (Width, Height) – mais ces dimensions ne sont pas connues du ViewModel, parce qu’elles dépendent d’un DataTemplate complexe, qui est sous la responsabilité de la View. C’est un cercle vicieux que nous ne pouvons pas résoudre de façon élégante. Il faut absolument, quand nous déterminons les Width et Height, avoir accès aux contrôles qui vont afficher la donnée. Nous pouvons essayer de faire certaines étapes avec un DataTemplateSelector (et/ou avec DataTriggers en WPF), mais au final c’est plus simple de tout faire dans le code behind :

1. Il faut, de façon heuristique, initialiser les dimensions de chaque Card. Cette étape est très importante pour des raisons de performance (comme nous allons voir dans l’étape 4). Pour ce cas en particulier, c’est suffisant pour le mode « concis » (ou ce n’est pas grave si nous ne voyons pas toute l’information).

Dans l’objet Card :

internal void InitializeSpans()
{
     Width = 1;
     var length = Orderable.CollapsedText.Length + Orderable.CollapsedText.ToCharArray().Count(c=>c == '\n') * 20;
     Height = (byte)( length < 70? 1 : (length < 220 ? 2 : 3));
}

Une fonction similaire Expand () calcule la taille d’une carte développée.

Vous n’avez pas à regarder les détails de ce calcul : il sera différent pour votre cas. Ici je me suis basé sur la longueur totale du texte et le nombre de sauts de ligne.

2. Il faut gérer le changement de Template en fonction de vos évènements de l’interface. Dans ce cas, c’est à la sélection d’une tuile :

private void OrderablesView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var grid = (Selector)sender;
    foreach (var item in e.AddedItems)
    {
         var card = (Card)item;
         card.Expand();
         SetTemplate(grid, card, GetExpandedTemplate(card), adjustCardSizeToFit: true);
     }

     foreach (var item in e.RemovedItems)
    {
         var card = (Card)item;
         card.InitializeSpans();
         SetTemplate(grid, card, CollapsedTemplate);
     }
 }

Pour chaque objet ajouté ou supprimé de la sélection, nous estimons ses dimensions de façon heuristique et nous déterminons le DataTemplate à utiliser pour sa présentation.

3. Maintenant il faut ajuster les dimensions de la carte pour lui permettre d’avoir assez de place pour être visualisée entière. Le principe est de commencer avec les dimensions trouvées de façon heuristique (rapide) et vérifier si elle a assez de place. Sinon, il faut augmenter les dimensions d’une étape.

private void SetTemplate(Selector grid, Card card, DataTemplate template, bool adjustCardSizeToFit = false)
{
    var container = (FrameworkElement)grid.ItemContainerGenerator.ContainerFromItem(card);
    var contentPresenter = container.GetFirstDescendantOfType<ContentPresenter>();
    contentPresenter.ContentTemplate = template;

    if (adjustCardSizeToFit)
    {
        byte maxRowSpan = (byte)((grid.ActualHeight - 12) / ViewModel.GridItemHeight);
        int maxColumnSpan = 3;
        while ((card.ColumnSpan < maxColumnSpan || card.RowSpan < maxRowSpan)
                && !Fits(container, card))
        {
             if (card.Height < maxRowSpan)
                card.Height++;
             else if (card.Width < maxColumnSpan)
             {
                card.Width++;
                card.Height = (byte)(maxRowSpan / card.Width);
              }
         }
    }
}

4. Pour voir si la carte a assez de place pour son contenu, nous utilisons la méthode Measure. Entre autre, elle va mettre à jour le DesiredSize d’un élément visuel (la taille actuelle sera définie par rapport aux contraintes imposées par les éléments qui l’entourent).

Cette méthode est très lourde : elle nécessite la construction de tout le visuel (en faisant des appels de très bas niveau). Pour un DataTemplate d’une complexité moyenne (une dizaine d’éléments proprement alignés et visualisés) je l’ai vu dépasser les 200ms sur une Surface RT. Il faut alors minimiser le nombre de fois où elle est appelée – d’où l’intérêt d’avoir une bonne première estimation des dimensions. C’est vrai qu’il y a un cache interne pour les sous-éléments, donc les appels additionnels ne coûtent pas beaucoup. Une amélioration importante serait de mettre en cache les valeurs calculées (pas présentée dans l’exemple de code ci-dessous).

private bool Fits(FrameworkElement container, Card card)
{
    var availableSize = CalculateAvailableSize(card.Width, card.Height);
    var border = container.GetFirstDescendantOfType<Border>();
    border.Measure(availableSize);

    return border.DesiredSize.Height + 20 <= availableSize.Height;
    /*20 accounts for margins between cards*/
}

private Size CalculateAvailableSize(byte columnSpan, byte rowSpan)
{
      return new Size(columnSpan * ViewModel.GridItemWidth, rowSpan * ViewModel.GridItemHeight);
}

Dans mon cas il y avait toujours un Border dans les DataTemplates – dans votre cas vous allez probablement utiliser un autre élément.

Techniques Utilisées :

  • Sortir des contraintes d’MVVM quand nous avons besoin des informations trop liées  à la vue.
  • Appeler manuellement Measure sur un élément. Cette méthode, avec ArrangeLayout, est très importante quand nous développons nos propres contrôles.
  • Faire une approximation itérative pour trouver la bonne taille d’un élément, en commençant avec une première estimation rapide à calculer.
  • Mettre en cache les éléments qui sont lourds à calculer.

Epilogue

Ceux cas sont seulement des exemples qui permettent de présenter certaines techniques généralisables et quelques détails du fonctionnement interne de XAML. Les contraintes et les possibilités de la GridView ne s’arrêtent pas ici. Imaginez, par exemple, un Semantic Zoom ou la vue zoomée présente des objets par rapport aux filtres choisis dans la vue Zoomed Out (comme d’habitude) mais la version Zoomed In présentant les filtres groupés. Le SemanticZoom attend que les groupes soient au niveau de la vue zoomée !

Si vous avez conçu une interface qui n’est pas couverte par la GridView telle qu’elle est, n’hésitez pas de poster une question au-dessous de l’article, envoyez un email à nikolaskp@hotmail.com ou demandez un des Experts XAML de Soat. Nous pourrons souvent retrouver les 10 lignes de code qui ont résolu le problème quand nous l’avons rencontré !

Nombre de vue : 244

COMMENTAIRES 4 commentaires

  1. Rototo dit :

    Bonjour,

    Comment pouvons nous en WinRT modifier la position du scroll de sa gridview ? Car quand je clic sur un item, j’ai envie que quand je reviens à ma vue (qui au passage est recréé, cad mon constructeur est re-appelé) j’aurai aimé revenir à la position. J’ai la position mais impossible de modifier la position de la scrollview affiché

  2. Nicolas Kyriazopoulos-Panagiotopoulos dit :

    Bonjour Rototo,
    Je ne connaissais pas la réponse (mais ça m’intéressait de l’apprendre parce que je risquais d’avoir le même problème).
    J’ai trouvé la solution ici: http://social.msdn.microsoft.com/Forums/en-US/winappswithcsharp/thread/98d38dea-cd64-4d79-a49c-396b6f3c00f0
    Si votre vue utilise toujours le même DataContext, ça suffit d’utiliser NavigationCacheMode=”Enabled” pour éviter la reconstruction de l’objet.
    Autrement il faut appeler Window.Current.Dispatcher.RunAsync(() => viewer.ScrollToVerticalOffset(VOTRE_VALEUR), CoreDispatcherPriority.Low);

    ou viewer est le ScrollViewer de la GridView. Pour le trouver il suffira d’utilisait une de la multitude de méthodes sur le net pour trouver le premier descendant de la GridView, ayant le type ScrollViewer.

  3. Rototo dit :

    Merci Nico, Cependant j’apporte un élément de réponse : il faut exécuter cette méthode lorsque la liste est chargée (il faut donc ajouter un système de ce style : this.myGridView.Loaded += itemGridView_Loaded;
    ) et dans la méthode itemGridView_Loaded, mettre le fameux ScrollToverticalOffset 😉

  4. Nicolas Kyriazopoulos-Panagiotopoulos dit :

    Makes sense! 🙂

AJOUTER UN COMMENTAIRE