Canalblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Publicité
Fanfois et ses papillons
2 février 2012

Aspirateur (10 - MVVM, une seconde vue)

Même si cela revient à modifier le principe de fonctionnement de notre aspirateur, nous allons mettre en place le code de la troisième commande du menu de la même façon que dans MVVM de la découverte à la maîtrise. Cela signifie que notre aspirateur disposera d'une liste de sites web et qu'il faudra en sélectionner un. Cela fait, il sera possible de l'aspirer. Au final, ce n'est pas forcément plus mal.

Dans le projet Aspirateur.ViewModels, nous commençons par enlever les commentaires que nous avons mis la dernière fois dans le code de MainViewModel. Nous devons ajouter deux "using", using Aspirateur.Model; et using Aspirateur.Model.Services; pour espérer compiler. Cela nous amène à référencer les projets MVVM.Framework.Model et Aspirateur.Model .
Nous pouvons maintenant ajouter à ce même projet, une classe WebSiteViewModel dont le code est directement déduit de celui du ViewModel LivreViewModel du livre :

using System;
using MVVM.Framework;
using MVVM.Framework.IOC;
using MVVM.Framework.Model.Services;
using Aspirateur.Model;
using Aspirateur.Model.Services;
using System.ComponentModel;
using System.Collections.ObjectModel;
 
namespace Aspirateur.ViewModels
{
    public class WebSiteViewModel : ViewModelBase
    {
        /// <summary>
        /// Etats possibles pour le ViewModel.
        /// </summary>
        public enum Etats
        {
            Parfait,
            ADesErreurs
        }
 
        #region Membres
        private Etats _etat = Etats.Parfait;
        private IDialogService _windowServices;
        private WebSiteModele _site = null;
        #endregion Membres
 
        #region Proprietes
        #region EtatCourant
        public Etats EtatCourant
        {
            get { return _etat; }
            set
            {
                if (_etat == value)
                {
                    return;
                }
                _etat = value;
                RaisePropertyChanged<Etats>(() => EtatCourant);
            }
        }
        #endregion EtatCourant
 
        #region Site
        /// <summary>
        /// Le site web exposé par le ViewModel.
        /// </summary>
        public WebSiteModele Site
        {
            get { return _site; }
            set
            {
                if (_site != value)
                {
                    _site = value;
                    RaisePropertyChanged<WebSiteModele>(() => Site);
                }
            }
        }
        #endregion Site
 
        #region Commandes
        #region OuvrirFicheSiteCommand
        public ProxyCommand<WebSiteViewModel> OuvrirFicheSiteCommand { get; private set; }
        #endregion OuvrirFicheSiteCommand
        #endregion Commandes
 
        #region Constructeurs / destructeur
        /// <summary>
        /// Pour blend.
        /// </summary>
        public WebSiteViewModel()
        {
        }
 
        /// <summary>
        /// Le constructeur normal.
        /// </summary>
        /// <param name="webSiteModele">Le site web géré.</param>
        /// <param name="synchroniseAvecLaSelection">Y a-t-il une liaison avec une liste ?</param>
        public WebSiteViewModel(WebSiteModele webSiteModele, bool synchroniseAvecLaSelection)
        {
            Site = webSiteModele;
            webSiteModele.PropertyChanged += WebSiteModele_PropertyChanged;
            InitialisationDuMessenger(synchroniseAvecLaSelection);
        }
        #endregion Constructeurs / destructeur
 
        #region Gestionnaires d'événements
        /// <summary>
        /// Synchronisation de l'état du ViewModel avec celui du Model.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void WebSiteModele_PropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (e.PropertyName == "ADesErreurs")
            {
                EtatCourant = Site.ADesErreurs ? Etats.ADesErreurs : Etats.Parfait;
            }
        }
        #endregion Gestionnaires d'événements
 
        #region Fonctions surchargée
        protected override void InitialisationDesServices()
        {
            _windowServices = ServiceLocator.Instance.Retrieve<IDialogService>();
        }
 
        protected override void InitialisationDesCommandes()
        {
            OuvrirFicheSiteCommand = new ProxyCommand<WebSiteViewModel>(_ => OuvrirFicheSite(), this);
        }
        #endregion Fonctions surchargée
 
        #region Helper
        /// <summary>
        /// Initialisation des communications via le Messenger.
        /// </summary>
        /// <param name="synchroniseAvecLaSelection">Y a-t-il une liaison avec une liste ?</param>
        private void InitialisationDuMessenger(bool synchroniseAvecLaSelection)
        {
            if (synchroniseAvecLaSelection)
            {
                // Obtention du messenger via le localisateur de services.
                var messenger = ServiceLocator.Instance.Retrieve<IMessenger>();
 
                // Enregistrement sur l'événement SITE_SELECTIONNE_CHANGE.
                messenger.Register<WebSiteModele>(Evenements.SITE_SELECTIONNE_CHANGE, (siteEvt) =>
                {
                    // Le WebSiteModele courant a changé, on le répercute sur le ViewModel.
                    Site = siteEvt;
                });
            }
        }
 
        /// <summary>
        /// Fonction associée à la commande d'ouverture du descriptif du site web.
        /// </summary>
        private void OuvrirFicheSite()
        {
            IWebSitesService siteService = ServiceLocator.Instance.Retrieve<IWebSitesService>();
            Site.BeginEdit();
            _windowServices.OuvrirFenetreSauvegardeOuAnnulation(
                // Titre de la fenêtre.
                String.Format("Edition du site : {0}", Site.BaseURL.ToString()),
                // Taille de la fenêtre.
                400, 500,
                // Elément à afficher
                this,
                // Méthode appelée en cas de sauvegarde, on sauvegarde effectivement.
                (_) =>
                {
                    Site.EndEdit();
                    bool ok = siteService.MettreAJour(Site);
                    siteService.AppliquerLesChangements();
                    return ok;
                },
                // Méthode appelée en cas d'annulation des modification.
                (_) =>
                {
                    Site.CancelEdit();
                    return true;
                }
            );
        }
        #endregion Helper
    }
}

Ce code nécessite d'ajouter une nouvelle classe Evenements au projet Aspirateur.ViewModels. Son code est le suivant :

using System;
 
namespace Aspirateur.ViewModels
{
    /// <summary>
    /// Classe contenant la déclarations des événements de l'application
    /// </summary>
    public static class Evenements
    {
        /// <summary>
        /// Evénements correspondant à un changement de sélection du domaine courant.
        /// </summary>
        public const String DOMAINE_SELECTIONNE_CHANGE = "DOMAINE_SELECTIONNE_CHANGE";
 
        /// <summary>
        /// Evénements correspondant à un changement de sélection du site web courant.
        /// </summary>
        public const String SITE_SELECTIONNE_CHANGE = "SITE_SELECTIONNE_CHANGE";
    }
}

Si nous lançons notre application et que nous cliquons sur le menu "Edition" / "Ajouter un nouveau site...", une fenêtre apparait. Si elle possède deux boutons, elle n'affiche pas quelque chose que l'on puisse considérer comme utile :

Aspirateur 10 01

C'est relativement normal. Comme nous n'avons pas dit comment afficher les données, .NET fait appel à son traitement par défaut. Depuis le début de .NET, il s'agit de la méthode ToString() de l'élément devant être affiché.
Ce qu'il nous faut c'est une vue ou un template d'affichage de nos données. Dans les deux cas cela relève de la couche View.

Nous ajoutons pour cela un nouveau projet, de type "Bibliothèque de contrôles utilisateurs WPF" (si vous n'avez pas défini le template lors du précédent billet, vous devez ajouter un projet de type "Application WPF" à la solution et le modifier), que nous nommons Aspirateur.Views. En plus des modifications usuelles du fichier AssemblyInfo.cs, il convient de définir dans ce fichier :

using System.Windows.Markup;
 
[assembly: XmlnsDefinition("http://aspirateur-mvvm.fr/Views", "Aspirateur.Views")]
[assembly: XmlnsDefinition("http://aspirateur-mvvm.fr/Views", "Aspirateur.Views.Behaviors")]

Cela fait, nous référençons les projets MVVM.Framework, Aspirateur.Model et Aspirateur.ViewModels.

Pour référencer simplement le projet Aspirateur.ViewModels dans le xaml à venir de nos vues, nous ajoutons dans son AssemblyInfo.cs (donc celui du projet Aspirateur.ViewModels) :

using System.Windows.Markup;
 
[assembly: XmlnsDefinition("http://aspirateur-mvvm.fr/ViewModels", "Aspirateur.ViewModels")]

Cela nécessite de référencer System.Xaml dans Aspirateur.ViewModels.

Pour avoir une organisation claire de nos fichiers, nous créons dans le projet Aspirateur.Views un répertoire "DataTemplates". Nous y rangerons le fichier contenant les templates de nos données.
Avant de nous occuper du template dont nous avons besoin, nous allons récupérer dans le code du livre les fichiers de ressources. Comme cela a été dit dans le précédent billet, l'aspect de notre aspirateur sera identique à celui de l'application d'exemple de MVVM de la découverte à la maîtrise.
A cet effet, nous créons, toujours dans le projet Aspirateur.Views, un répertoire "Resources" et nous y recopions les fichiers correspondant du livre.
Si on regarde rapidement ce que sont ces fichiers, on constate que :

  • Brushes.xaml défini une brosse dont nous n'aurons pas besoin dans ce billet (même renommée en WebSiteBrush) ;
  • Converters.xaml défini trois convertisseurs. EnumToDescriptionConverter doit vous rappeler quelquechose. En fait il s'agit fonctionnellement de la même chose que notre EnumDescriptionConverter, en plus complet. Nous avons besoin d'un convertisseur pour afficher l'extension choisie pour nos fichiers locaux, nous allons l'utiliser cette version. Pour cela nous devons créer un nouveau répertoire "Converters" dans notre projet Aspirateur.Views et y copier le fichier "EnumToDescriptionConverter.cs" issu du livre (ne pas oublier les manipulations classiques lors d'une copie). Les deux autres convertisseurs définis sont sans intérêt pour nous, nous enlevons leur définition ;
  • Fonts.xaml est "vide" ;
  • Styles.xaml définit plusieurs styles. Nous n'avons besoin que du dernier qui permet de rendre visible les erreurs de saisie de l'utilisateur.

Pour que ces fichiers soient pris en compte, nous référençons le projet Aspirateur.Views dans le projet Aspirateur.Main. Cela fait, nous modifions le fichier App.xaml du projet Aspirateur.Main (seule la partie modifiée est montrée) :

<ResourceDictionary.MergedDictionaries>
    <ResourceDictionary
        Source="pack://application:,,,/JetPackWPFTheme;component/JetPackWPFTheme.xaml" />
    <ResourceDictionary
        Source="pack://application:,,,/Aspirateur.Views;component/Resources/Brushes.xaml" />
    <ResourceDictionary
        Source="pack://application:,,,/Aspirateur.Views;component/Resources/Converters.xaml" />
    <ResourceDictionary
        Source="pack://application:,,,/Aspirateur.Views;component/Resources/Styles.xaml" />
    <ResourceDictionary
        Source="pack://application:,,,/Aspirateur.Views;component/Resources/Fonts.xaml" />
    <ResourceDictionary
        Source="pack://application:,,,/Aspirateur.Views;component/DataTemplates/DataTemplates.xaml" />
</ResourceDictionary.MergedDictionaries>

Comme nous venons de le référencer, il est temps de créer le fichier DataTemplates.xaml dans le répertoire DataTemplates. En nous inspirant du code du livre, nous y définissons le code suivant :

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:sys="clr-namespace:System;assembly=mscorlib"
                    xmlns:model="clr-namespace:Aspirateur.Model;assembly=Aspirateur.Model"
                    xmlns:viewModels="http://aspirateur-mvvm.fr/ViewModels"
                    xmlns:bhv="clr-namespace:Aspirateur.Views"
                    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                    mc:Ignorable="d">
    <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary
            Source="pack://application:,,,/JetPackWPFTheme;component/JetPackWPFTheme.xaml" />
    </ResourceDictionary.MergedDictionaries>
 
    <ObjectDataProvider x:Key="ExtensionEnumValeurs" MethodName="GetValues" ObjectType="{x:Type sys:Enum}">
        <ObjectDataProvider.MethodParameters>
            <x:Type TypeName="model:Extension" />
        </ObjectDataProvider.MethodParameters>
    </ObjectDataProvider>
 
    <DataTemplate DataType="{x:Type viewModels:WebSiteViewModel}">
        <Grid x:Name="grid1" Height="Auto" Width="Auto" Margin="5"
              bhv:EtatVisuel.EtatVisuel="{Binding EtatCourant}">
            <VisualStateManager.VisualStateGroups>
                <VisualStateGroup x:Name="CommonStates">
                    <VisualStateGroup.Transitions>
                        <VisualTransition GeneratedDuration="0:0:1" To="ADesErreurs">
                            <Storyboard AutoReverse="False">
                                <ThicknessAnimationUsingKeyFrames
                                    Storyboard.TargetProperty="(FrameworkElement.Margin)"
                                    Storyboard.TargetName="grid1">
                                    <EasingThicknessKeyFrame KeyTime="0:0:0.4" Value="14,5,5,5">
                                        <EasingThicknessKeyFrame.EasingFunction>
                                            <BounceEase EasingMode="EaseIn" Bounciness="-5" />
                                        </EasingThicknessKeyFrame.EasingFunction>
                                    </EasingThicknessKeyFrame>
                                    <EasingThicknessKeyFrame KeyTime="0:0:0.6" Value="5" />
                                </ThicknessAnimationUsingKeyFrames>
                            </Storyboard>
                        </VisualTransition>
                        <VisualTransition GeneratedDuration="0:0:1" />
                    </VisualStateGroup.Transitions>
                    <VisualState x:Name="ADesErreurs" />
                    <VisualState x:Name="Parfait" />
                </VisualStateGroup>
            </VisualStateManager.VisualStateGroups>
 
            <Grid x:Name="grid" d:LayoutOverrides="Width" VerticalAlignment="Top"
                  Background="{x:Null}" Margin="0,0,5,0" DataContext="{Binding Mode=OneWay}">
                <Grid.RowDefinitions>
                    <RowDefinition Height="*" />
                    <RowDefinition Height="*" />
                    <RowDefinition Height="*" />
                    <RowDefinition Height="*" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
                <TextBlock Text="URL de départ : " Style="{DynamicResource Heading5}"
                         Margin="0,5,0,0" />
                <TextBox x:Name="textBoxBaseURL" IsReadOnly="True"
                         Text="{Binding Site.BaseURL, ValidatesOnDataErrors=true}"
                         Grid.ColumnSpan="1" Grid.Column="1" HorizontalAlignment="Stretch"
                         Margin="0,5,0,0" />
                <TextBlock Text="Répertoire racine : " Grid.Row="1" Grid.RowSpan="3"
                           Style="{DynamicResource Heading5}" Margin="0,5,0,0" />
                <TextBox x:Name="textBoxBaseDirectory" IsReadOnly="True"
                         Text="{Binding Site.BaseDirectory, ValidatesOnDataErrors=true}"
                         Grid.ColumnSpan="1" Grid.Column="1" HorizontalAlignment="Stretch"
                         Grid.Row="1" Grid.RowSpan="1" Margin="0,5,0,0" />
                <TextBlock Text="Nom par défaut : " Grid.Row="2" Grid.RowSpan="3"
                           Style="{DynamicResource Heading5}" Margin="0,5,0,0" />
                <TextBox x:Name="textBoxDefaultName" IsReadOnly="True"
                         Text="{Binding Site.DefaultName, ValidatesOnDataErrors=true}"
                         Grid.ColumnSpan="1" Grid.Column="1" HorizontalAlignment="Stretch"
                         Grid.Row="2" Grid.RowSpan="1" Margin="0,5,0,0" />
                <TextBlock Text="Extension par défaut : " Grid.Row="3"
                           Style="{DynamicResource Heading5}" Margin="0,5,0,0" />
                <Grid Grid.ColumnSpan="1" Grid.Column="1" Grid.Row="3">
                    <Grid.Resources>
                        <DataTemplate x:Key="itemTemplateCourant">
                            <TextBlock Margin="3,3" Text="{Binding ., Converter={StaticResource EnumToDescriptionConverter}}" />
                        </DataTemplate>
                    </Grid.Resources>
                    <ComboBox x:Name="comboBoxExtension" Margin="0,3" HorizontalAlignment="Stretch"
                              ItemsSource="{Binding Source={StaticResource ExtensionEnumValeurs}}"
                              SelectedValue="{Binding Site.DefaultExtension, ValidatesOnDataErrors=true}"
                              ItemTemplate="{DynamicResource itemTemplateCourant}" />
                </Grid>
            </Grid>
        </Grid>
        <DataTemplate.Triggers>
            <DataTrigger Binding="{Binding Site.EstEnEdition}" Value="True">
                <!--<Setter TargetName="grid" Property="Background" Value="PaleGreen" />-->
                <Setter TargetName="textBoxBaseURL" Property="IsReadOnly" Value="False" />
                <Setter TargetName="textBoxBaseDirectory" Property="IsReadOnly" Value="False" />
                <Setter TargetName="textBoxDefaultName" Property="IsReadOnly" Value="False" />
                <Setter TargetName="comboBoxExtension" Property="IsReadOnly" Value="False" />
            </DataTrigger>
        </DataTemplate.Triggers>
    </DataTemplate>
</ResourceDictionary>

Ce code utilise le "behavior" EtatVisuel que nous devons donc ajouter à notre projet. Nous créons un répertoire Behaviors et y recopions le fichier EtatVisuel.csdu livre.

Avant de tester notre programme, nous devons modifier le App.xaml.cs du projet Aspirateur.Mainpour enregistrer les deux nouveaux services utilisés par le code mis en place dans ce billet :

using Aspirateur.Model.Services;
using Aspirateur.ServicesXML;
 
IOCAlias.ServiceLocator.Instance.Register<IWebSitesService>(new WebSitesService());
IOCAlias.ServiceLocator.Instance.Register<IMessenger>(new Messenger());

Ces deux lignes nous obligent à référencer les projets Aspirateur.Model, Aspirateur.ServicesXML et MVVM.Framework.Model.

Si nous lançons notre aspirateur, nous pouvons ajouter un nouveau site web. La gestion des erreurs de saisie est même opérationnelle, comme le montre la capture suivante :

Aspirateur 10 02

Nous ne sommes pas loin du but, mais il y a tout de même des points qui méritaient d'être traités :

  1. si l'utilisateur sort d'une des zones d'édition sans avoir tapé quoique ce soit, il ne se passe rien alors que les champs sont obligatoires ;
  2. le bouton "Sauvegarder" n'est pas désactivé s'il y a une erreur sur un champ ;
  3. nous pouvons ajouter n fois la même chose sans que notre programme ne s'en rende compte.

Pour résoudre le second point, il faut lier la propriété "IsEnabled" du bouton "Sauvegarder" au membre "ADesErreurs" de l'instance courante de WebSiteModele, comme nous l'avons fait dans notre projet d'aspirateur non MVVM.
Evidement on ne peut pas le faire comme cela vient d'être dit. Le bouton appartient à une fenêtre qui ne connaîtra jamais aucun objet du Model. Par contre cette fenêtre est destinée à être utilisée avec des ViewModel (même si rien ne l'impose dans sa définition actuelle).
Nous pourrions donc décider d'ajouter à la classe ViewModelBase un membre dédié à cet effet.
Dans cette même optique, on peut se demander pourquoi le code du livre n'a pas intégré EtatCourant (qui en gros correspond à ce dont nous avons besoin) dans ViewModelBase.
La réponse la plus probable (je n'ai pas demandé aux auteurs), c'est que compte tenu de l'organisation du code ce n'est pas possible. En plus ça ne sert pas dans tous les cas. Typiquement c'est inutile dans le cas des listes.
Pour ceux qui ne voient pas le rapport avec l'organisation du code, essayez de définir une nouvelle classe, EditViewModelBase qui gère un lien avec ModelBase (pour savoir si l'élément est valide ou non). A un moment ou un autre, ObservedBase va vous gêner.
Ne souhaitant pas nous lancer dans une réorganisation du code, nous allons simplement faire en sorte que ça fonctionne dans notre cas particulier.

Nous modifions donc WebSiteViewModel (seul le code impacté est présenté) :

#region EtatCourant
public Etats EtatCourant
{
    get { return _etat; }
    set
    {
        if (_etat == value)
        {
            return;
        }
        _etat = value;
        RaisePropertyChanged<Etats>(() => EtatCourant);
        RaisePropertyChanged<bool>(() => IsEnabled);
    }
}
#endregion EtatCourant
 
#region IsEnabled
public bool IsEnabled
{
    get { return _etat == Etats.Parfait; }
}
#endregion IsEnabled

Nous pouvons maintenant binder le bouton de SaveOrCancelWindow :

<Button x:Name="_saveButton" Click="_saveButton_Click" IsDefault="True"
        ToolTip="Sauvegarder" Margin="5"
        IsEnabled="{Binding Path=IsEnabled}">

Certains feront remarquer que nous aurions pu binder sur EtatCourant, en utilisant notre convertisseur EnumToBooleanConverter. C'est vrai, mais ce ne serait ni plus simple (c'est le moins que l'on puisse dire), ni plus performant.

Nous avons maintenant une gestion cohérente du bouton de sauvegarde et des zones de saisie :

Aspirateur 10 03

Si votre aspirateur explose lorsque vous tentez de faire la même chose que la copie d'écran précédente, c'est à cause d'un bug qui traine dans le code. Il faut modifier le code de ExtractVirtualDirectory dans la classe WebSiteModele :

private Uri ExtractVirtualDirectory(Uri url)
{
    try
    {
        string absPath = url.AbsolutePath;
        if (absPath.EndsWith("/"))
        {
            return url;
        }
        else
        {
            return new Uri(url, absPath.Substring(0, absPath.LastIndexOf('/') + 1));
        }
    }
    catch
    {
        // Commpte tenu du fait que les vérifications sur les membres sont faites à postériori
        // url peut contenir n'importe quoi.
        return null;
    }
}

Pour ce billet nous n'allons pas tenter d'améliorer les deux autres problèmes soulevés car leur résolution ne se fait pas simplement.
Le troisième est lié au fait qu'aucune validation n'est faite avant d'insérer en base et que l'insertion fonctionnera toujours car un nouveau GUID est créé à chaque fois. Le GUID étant la clé primaire... Une façon simple de résoudre le problème au niveau du fichier XML consiste à chercher si l'URL du site est déjà connue. Se pose alors la question, que fait-on si la réponse est, "le site existe déjà" ? On entre alors dans des problèmes qui ont tendance à être sans fin. Conclusion, nous ne ferons donc rien pour l'instant.

Si vous souhaitez récupérer le code à jour, c'est ici

Dans le prochain billet nous afficherons la liste des sites web et permettrons à nouveau l'aspiration d'un site, le site courant

Publicité
Publicité
Commentaires
Fanfois et ses papillons
Publicité
Archives
Publicité