On parle Windows, C#, Apple, Android, Js, …

WPF, ListView d’images, MVVM et Drag and Drop… – Partie 1

Introduction

Pour les besoins d’un logiciel orienté « Photos », j’ai eu besoin des fonctionnalités essentielles suivantes :

  • Affichage des images sous forme de vignettes dans une liste et dans une arborescence de type TreeView
  • Cet affichage doit permettre de gérer un nombre imposant de photos (>5000 !) sans générer de débordement mémoire ou de ralentissement
  • L’affichage et la génération des vignettes doit pouvoir se faire en mode asynchrone (en lisant les informations EXIF si elles existent,…)
  • La multi-sélection doit permettre de sélectionner plusieurs éléments.
  • Les éléments sélectionnés doivent pouvoir être déplacés ou copiés dans la liste (ou l’arbre) par Drag And Drop

Et le tout en DotNet WPF respectant le pattern MVVM !!!

MVVM Pattern

Je ne ferai pas un gros article sur la nécessité de développer en WPF selon une architecture MVVM (Séparation des fonctions, réutilisation, rôle des objets mieux ciblés, accès déclaratifs…). Voici quelques liens au cas où vous ne seriez toujours pas convaincus…

http://www.orbifold.net/default/?p=550
http://japf.developpez.com/tutoriels/dotnet/mvvm-pour-des-applications-wpf-bien-architecturees-et-testables/
http://msdn.microsoft.com/fr-fr/magazine/dd419663.aspx
http://www.c2i.fr/Article/Detail/a3809f7b-196a-4d8c-bb48-164f591920bb

MVVM et ListView

Cette première partie présente l’implémentation de la liste d’images avec le modèle MVVM.

Exemple d’application

Voici le descriptif fonctionnel de l’exemple que nous allons mettre en place : Un petit croquis vaut mieux qu’un long discours.

Draft MVVM application

L’application est extrêmement simpliste. La saisie d’un répertoire se fait en haut, le click sur le bouton affiche la liste des images et leur nombre total.

Découpage de l’application

L’application est découpée en 4 parties :

  • Model : l’objet “métier” image
  • ViewModel : les objets nécessaires à la manipulation des objets “Model” images par la couche “View” (Détail de l’image et liste des images)
  • View : L’affichage XAML-WPF de la liste des images
  • Controler : Les actions nécessaires : Remplissage de la liste par exemple

Avec un diagramme, cela ressemble à ceci :

MVVM Pattern

L’organisation des classes dans la solution se traduit comme ceci

MVVM solution explorer

 

Détail des couches MVVM

La couche Model

Cette couche n’est pas spécifique à la technologie WPF.
J’ai ajouté une interface IImageFile pour simplifier les évolutions.
Voici l’interface de manipulation de l’objet Image.

public interface IImageFile
{
    string FileName { get; }
    bool IsAvailable { get; }
}

FileName contient le chemin complet du fichier Image
IsAvailable retourne vrai si le fichier existe

Et voici son implémentation

public class ImageFile: IImageFile
{
   public string FileName { get; set; }
   public bool IsAvailable
   {
       get 
       {
           return File.Exists(FileName);
       }
   }
}

 

 

La couche ViewModel

Passons maintenant à la couche ViewModel qui va exposer les informations nécessaires à la vue.

  • La classe FileViewModel va contenir l’objet ViewModel pour un fichier image
  • La classe ImageFileCollectionViewModel va contenir la liste des objets FileViewModel

Pour se faire les classes implémentent l’interface INotifiedProperty.
Cette interface nécessite l’implémentation d’un événement de type PropertyChangedEventHandler qui remontra

la classe FileViewModel

La propriété ShortName permettra d’afficher uniquement le nom du fichier, l’objet métier complet sera stocké dans un attribut privé _ImageFile.

public class FileViewModel: INotifyPropertyChanged
{
   protected IImageFile _imageFile;
   public string ShortName
   {
       get { return Path.GetFileName(_imageFile.FileName); }
   }

   public FileViewModel(IImageFile imageFile)
   {
       this._imageFile = imageFile;
   }

   public event PropertyChangedEventHandler PropertyChanged;
   protected void OnPropertyChanged(string propertyName)
   {
       if (PropertyChanged != null)
       {
           PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
       }
   }
}

La classe ImageFileCollectionViewModel

La liste des fichiers images sera exposée à travers une autre classe ImageFileCollectionViewModel.
Pour obtenir la liste des images dans la vue nous utiliserons donc la propriété AllImages de type ObservableCollection de FileViewModel.

DataItemCount permettra d’obtenir le nombre total d’images dans la liste. DataItemCount  appelle le fameux OnPropertyChanged qui permettra à chaque modification du nombre d’éléments (de la couche Model)  de notifier à la vue XAML de se rafraichir automatiquement.

L’ajout d’éléments dans la liste se fera avec la méthode AddNewPhotoItem.

public class ImageFileCollectionViewModel: INotifyPropertyChanged
{
   private ObservableCollection<FileViewModel> _allImages;
   private int dataItemsCount;

   public ObservableCollection<FileViewModel> AllImages
   {
       get { return _allImages; }
   }

   public int DataItemsCount
   {
       get
       {
           return dataItemsCount;
       }
       private set
       {
           dataItemsCount = value;
           OnPropertyChanged("DataItemsCount");
       }
   }

   public ImageFileCollectionViewModel()
   {
       this._allImages = new ObservableCollection<FileViewModel>();
       this.DataItemsCount = 0;
   }

   public void AddNewPhotoItem(IImageFile imageFile)
   {
       FileViewModel newImageFileViewModel = new FileViewModel(imageFile);
       this._allImages.Add(newImageFileViewModel);
       this.DataItemsCount++;
   }
          
   public event PropertyChangedEventHandler PropertyChanged;

   private void OnPropertyChanged(string propertyName)
   {
       if (PropertyChanged != null)
       {
           PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
       }
   }
}

La couche View

L’affichage de la liste se fait ensuite avec une vue XAML classique. Pour améliorer la réutilisation et faciliter la maintenance j’ai créé un UserControl « ImageFileListView » qui contient uniquement la liste des images.

Voici l’implémentation de ce UserControl. Notez que le Binding se fait sur les propriétés des objets ViewModel

<UserControl x:Class="wpfListViewSample01.View.ImageFileListView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:wpfListViewSample01.View">
    
    <ListView SelectionMode="Extended"  x:Name="ListViewImage"
              ItemsSource="{Binding Path=AllImages}"  Margin="0,20,0,0">
        <ListView.ItemTemplate>
            <DataTemplate>
                <StackPanel Orientation="Horizontal">
                    <TextBlock Text="{Binding Path=ShortName}" />
                 </StackPanel >
            </DataTemplate  >
        </ListView.ItemTemplate>   
    </ListView >
 </UserControl >

Ce UserControl est appelé de la façon suivante dans la fenêtre principale Window.xaml.
Notez que “label2” est bindé sur la propriété DataItemsCount de l’objet ViewModel

 

<Window x:Class="wpfListViewSample01.View.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:m="clr-namespace:wpfListViewSample01.View"
    Title="Window1" Height="400" Width="571">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="515*" />
            <ColumnDefinition Width="34*" />
        </Grid.ColumnDefinitions>
        <Label Height="25" HorizontalAlignment="Left" Margin="12,17,0,0" 
        Name="label1" VerticalAlignment="Top" Width="108">Images Directory</Label >
        <TextBox Height="25" Margin="126,17,12,0" Name="textBoxImageDirectory"
        VerticalAlignment="Top" Grid.ColumnSpan="2" />
        <Button Height="29" HorizontalAlignment="Left" Margin="12,56,0,0" 
        Name="BtnOk" VerticalAlignment="Top" Width="106" Click="BtnOk_Click">    
        Display!</Button  >
        <m:ImageFileListView x:Name="listView1" Margin="12,99,12,22" 
        ClipToBounds="False" Grid.ColumnSpan="2" />
        <Label Height="24" HorizontalAlignment="Right" Margin="0,57,72,0" 
        Name="label2" VerticalAlignment="Top" Width="170" 
        Content="{Binding Path=DataItemsCount}"></Label>
  </Grid   >
</Window >

 

Les liens entre Model, ViewModel et View

Nous venons de voir que la couche View et ViewModel sont directement reliées par le Binding des fichiers XAML.
Au moment du click sur le bouton, l’alimentation de la liste doit se faire avec le répertoire saisi.
Sur le click du bouton, j’ai codé quelques actions.

  1. Création de l’objet ViewModel ImageFileCollectionViewModel qui va contenir l’ensemble des images.
  2. Alimentation de la liste des fichiers images présents dans le répertoire textBoxImageDirectory.Text.
  3. Initialisation des View avec la propriété DataContext de chaque composant visuel.

Voici une écriture possible.

private void BtnOk_Click(object sender, RoutedEventArgs e)
{
  ImageFileCollectionViewModel ImagesViewModel 
    = new ImageFileCollectionViewModel();
  ImageFileControler.CompleteViewList(ImagesViewModel, textBoxImageDirectory.Text);
  listView1.DataContext = ImagesViewModel;
  label2.DataContext = ImagesViewModel;
};

ImageFileControler.CompleteViewList recherche les fichiers *.jp* du répertoire passé en paramètre et les aoute un par un dans la liste de type ImageFileCollectionViewModel.

 

public static void CompleteViewList(
  ImageFileCollectionViewModel imageFileCollecion, string directory)
{
  string[] files = Directory.GetFiles(directory, "*.jp*");
  foreach (var f in files)
  {
      var im = new ImageFile();
      im.FileName = f;
      imageFileCollecion.AddNewPhotoItem(im);
  }
}

 

Exécution de l’application

L’application est loin d’être terminée mais voici une première exécution.

WPF MVVM Application example

Les tests unitaires

Et les tests alors?
Normalement les tests unitaires sont fait en même temps, voir même avant l’écriture des différentes implémentations.

J’ai préféré poser le décor avant d’aborder cette partie qui a bien sur été réalisée avant l’implémentation des différentes méthodes .
Le découpage MVVM va nous faciliter la tâche en terme de tests unitaires.

Trois tests unitaires ont été ajoutés sur la partie ViewModel. Le but est de tester les interactions entre l’affichage et les objets métiers du ImageFileCollectionViewModel.

  • DataItemsCountTest : Test de ImageFileCollectionViewModel.DataItemsCount
  • AllImagesTest : Test de ImageFileCollectionViewModel.AllImages
  • AddNewPhotoItemTest : Test de ImageFileCollectionViewModel.AddNewPhotoItem

Les tests unitaires sont créés avec l’assistant Visual Studio 2008 (Depuis ImageFileCollectionViewModel click droit, Créer des Tests unitaires… )

DataItemsCountTest ()

Il s’agit de tester le Nombre d’éléments d’objets métiers ImageFile ajoutés dans la liste.

  1. Création de l’objet ImageCollectionViewModel
  2. Test si la propriété DataItemsCount est bien = 0
  3. Ajout de 2 fichiers images Fictifs
  4. Test si la propriété DataItemsCount est bien = 2

Voici le code.

 

[TestMethod()]
public void DataItemsCountTest()
{
  ImageFileCollectionViewModel target = new ImageFileCollectionViewModel();
  Assert.AreEqual(target.DataItemsCount, 0);
  target.AddNewPhotoItem(new ImageFile() { FileName = "c:test1.jpg" });
  target.AddNewPhotoItem(new ImageFile() { FileName = "c:test2.jpg" });
  Assert.AreEqual(target.DataItemsCount, 2); 
}

 

AllImagesTest()

Il s’agit de tester les éléments présents dans AllImages. La technique est la même. Attention ici AllImages représente le détail des ViewModel et non pas la liste des objets métiers.

    1. Création de l’objet ImageCollectionViewModel
    2. Test si la propriété AllImages.Count est bien = 0
    3. Ajout de 1 élément
    4. Test si la propriété AllImages.Count est bien = 1
    5. Test si le premier élément retourne bien l’affichage attendu
[TestMethod()]
public void AllImagesTest()
{
  ImageFileCollectionViewModel target = new ImageFileCollectionViewModel();
  Assert.AreEqual(target.AllImages.Count, 0);
  target.AddNewPhotoItem(new ImageFile() { FileName = @"ImagesTest1.jpg" });
  Assert.AreEqual(target.AllImages.Count, 1);
  Assert.AreEqual(target.AllImages[0].ShortName, "Test1.jpg");
  
}

AddNewPhotoItemTest()

Le meilleur pour la fin. L’objectif est de tester l’ajout d’un élément. Le but n’est pas de tester cet ajout à travers AllImages mais de tester directement l’attribut privé _allImages.
Il faut donc un accès à l’attribut privé “_allImages”. La première étape à réaliser est de se placer sur l’attribut _allImages et de faire click droit puis“Créer un accesseur privé’”.

Ensuite vous pourrez utiliser un code de ce type :

  1. Création de l’objet temp de type ImageCollectionViewModel
  2. Créer un PrivateObject à partir de l’objet temp précédent
  3. Création de l’objet target à tester à partir de l’objet Accessor ImageCollectionViewModel_Accessor qui prend en paramètre l’objet privé .
  4. Ajout d’1 élément
  5. Test en utilisant _allImages.Count = 1
  6. test du type retourné par le premier élément de _allImages

 

[TestMethod()]
public void AddNewPhotoItemTest()
{
  ImageFileCollectionViewModel temp = new ImageFileCollectionViewModel(); 
  PrivateObject param0 = new PrivateObject(temp);
  ImageFileCollectionViewModel_Accessor target = new ImageFileCollectionViewModel_Accessor(param0);
  target.AddNewPhotoItem(new ImageFile() { FileName = @"c:test1.jpg" });
  Assert.AreEqual(target._allImages.Count, 1);
  Assert.AreEqual(target._allImages[0].GetType().FullName,
  "wpfListViewSample01.ViewModel.FileViewModel");
}

 

Exécution des tests

Windows Test

Magique! tout marche à merveille.

Conclusion

Nous venons de voir une implémentation possible en WPF avec le pattern MVVM d’une application affichant une liste d’images.

Nous verrons dans la prochaine étape l’affichage des vignettes, les contraintes de performances, les problèmes de gèle d’interface et la virtualisation des données.

Voici le projet complet à télécharger

download32x32 WPFListView01.zip

 

Laissez un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.