Après avoir créé notre ItemsControl, et lui avoir donné des fonctionnalité de Data Binding / templating supplémentaires, nous allons maintenant travailler sur l'expérience utilisateur. Cette partie est relativement indépendante des 2 premières, mais leur lecture me parait tout de même judicieuse afin de bien comprendre ce que nous manipulons
Vous pouvez télécharger la solution de base ici.
Le but de cette partie est de donner la possibilité de zoomer sur le contenu, sans zoomer sur les headers (en utilisant la molette de la souris), mais en conservant l'alignement entre Headers et Contents. On va aussi faire en sorte de pouvoir se déplacer à l'intérieur du contenu, simplement en cliquant sur la surface et en déplaçant la souris (c'est ce que j'appellerai dans la suite de l'article la "touch navigation" - on parle d'expérience utilisateur, donc on se doit donc d'utiliser des termes "hype"). Dernière contraintes, les headers devront être visibles en permanence... Il va donc falloir appliquer des Layout Transforms et Render Transforms à un certain nombre d'éléments enfants de notre contrôle : travailler sur l'expérience utilisateur, c'est cool, mais on va voir que ce n'est pas si simple.
Zoom
La première chose que nous allons implémenter ici est le zoom.
Comme les headers ne doivent pas être zoomé, mais qu'il doivent rester alignés avec leur contenu, nous n'allons pas pouvoir effectué ce zoom dans le RenderTransform du panel (sinon les headers seront zoomés), ni dans celui de chaque ContentControl des contenus (sinon les headers ne seront pas automatiquement redimenssionnés et les contenus vont se chevaucher). Mais les UIElements de WPF possèdent un autre type de transforms bien utiles : les LayoutTransforms.
La différence entre RenderTransform et LayoutTransform se situe dans l'impact sur le layout de l'élément transformé :
- Dans le cas du RenderTransform, les modifications de tailles dues au transform n'affectent que le visuel : la taille logique de l'élément ne change pas, et n'influe donc pas sur le Layout
- Dans le cas du LayoutTransform, ces modifications impactent la taille logique de l'élément, et donc son layout (et celui de ses parents).
La solution à notre problème est donc de définir un LayoutTransform de type ScaleTransform sur chaque ContentControl de contenu que l'on fera évoluer en fonction des actions sur la molette de la souris.
Pour cela, comme nous l'avons fait jusque ici, nous définirons une AttachedProperty taggée avec l'option FrameworkPropertyMetadataOptions.Inherits, modifierons le template par défaut, et gèrerons les évènements de la molette de la souris.
AttachedProperty + modification du constructeur + gestion de la molette:
public static ScaleTransform GetContentLayoutTransform(DependencyObject obj)
{
return (ScaleTransform)obj.GetValue(ContentLayoutTransformProperty);
}
public static void SetContentLayoutTransform(DependencyObject obj,
ScaleTransform value)
{
obj.SetValue(ContentLayoutTransformProperty, value);
}
// Instance accessor to the ContentLayoutTransform attached property
protected ScaleTransform ContentLayoutTransform
{
get
{
return GetContentLayoutTransform(this);
}
set
{
SetContentLayoutTransform(this, value);
}
}
public static readonly DependencyProperty ContentLayoutTransformProperty =
DependencyProperty.RegisterAttached("ContentLayoutTransform",
typeof(ScaleTransform), typeof(FixedHeaderList),
new FrameworkPropertyMetadata(null,
FrameworkPropertyMetadataOptions.Inherits
));
/// <summary>
/// Content-Zoom factor
/// </summary>
public double Zoom
{
get { return (double)GetValue(ZoomProperty); }
set { SetValue(ZoomProperty, value); }
}
public static readonly DependencyProperty ZoomProperty =
DependencyProperty.Register("Zoom", typeof(double), typeof(FixedHeaderList),
new UIPropertyMetadata(1.0, OnZoomChanged));
private static void OnZoomChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
var ctl = obj as FixedHeaderList;
if(ctl != null)
ctl.OnZoomChanged((double) args.OldValue,
(double) args.NewValue);
}
/// <summary>
/// Handles zoom changes and update the ContentLayoutTransform
/// </summary>
/// <param name="oldValue"></param>
/// <param name="newValue"></param>
protected virtual void OnZoomChanged(double oldValue,
double newValue)
{
// make sure the lowest value is 0.1
if (newValue < 0.1)
{
ContentLayoutTransform.ScaleX = 0.1;
ContentLayoutTransform.ScaleY = 0.1;
Zoom = 0.1;
}
else
{
ContentLayoutTransform.ScaleX = newValue;
ContentLayoutTransform.ScaleY = newValue;
}
}
static FixedHeaderList()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(FixedHeaderList),
new FrameworkPropertyMetadata(typeof(FixedHeaderList)));
// A transparent background provides hit-test on
// non-filled zones of the control
BackgroundProperty.OverrideMetadata(typeof(FixedHeaderList),
new FrameworkPropertyMetadata(Brushes.Transparent));
}
public FixedHeaderList()
{
ContentLayoutTransform = new ScaleTransform(1.0, 1.0);
}
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
// The Delta is a large value (one tick is about 120)
// and we want small values (0.01 granularity should be good)
// to ensure that zooming is smooth
this.Zoom += e.Delta / 2000.0;
e.Handled = true;
base.OnMouseWheel(e);
}
Modifications dans le template par défaut :
<Style TargetType="{x:Type HeaderedContentControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type HeaderedContentControl}">
<DockPanel LastChildFill="True">
<ContentControl x:Name="HeaderSite" Content="{TemplateBinding Header}"
Width="{TemplateBinding local:FixedHeaderList.HeaderWidth}"
Height="{TemplateBinding local:FixedHeaderList.HeaderHeight}"
ContentTemplate="{TemplateBinding local:FixedHeaderList.ItemHeaderTemplate}"
ContentTemplateSelector="{TemplateBinding local:FixedHeaderList.ItemHeaderTemplateSelector}"
DockPanel.Dock="{TemplateBinding local:FixedHeaderList.ItemContainerDock}"/>
<ContentControl x:Name="ContentSite" Content="{TemplateBinding Content}"
LayoutTransform="{TemplateBinding local:FixedHeaderList.ContentLayoutTransform}"
ContentTemplate="{TemplateBinding local:FixedHeaderList.ItemContentTemplate}"
ContentTemplateSelector="{TemplateBinding local:FixedHeaderList.ItemContentTemplateSelector}"/>
</DockPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Nous avons maintenant notre fonction de zoom implémentée. Une remarque cependant, le DockPanel n'est pas le plus approprié ici: En effet, il contraint l'espace de rendu à la taille du contrôle. Hors lorsque l'on va effectué un RenderTransform, les éléments graphiques sortant du cadre du DockPanel ne seront pas dessinés... Nous allons donc changer celà, en nous appuyant sur un DockPanel.
D'abord, nous allons supprimer l'attached property "ItemContainerDock" et la remplacer par une propriété "ContentPanelOrientation", et modifier le code répondant au changement d'orientation du FixedHeader :
public static Orientation GetContentPanelOrientation(DependencyObject obj)
{
return (Orientation)obj.GetValue(ContentPanelOrientationProperty);
}
public static void SetContentPanelOrientation(DependencyObject obj, Orientation value)
{
obj.SetValue(ContentPanelOrientationProperty, value);
}
public static readonly DependencyProperty ContentPanelOrientationProperty =
DependencyProperty.RegisterAttached("ContentPanelOrientation",
typeof(Orientation), typeof(FixedHeaderList),
new FrameworkPropertyMetadata(Orientation.Horizontal,
FrameworkPropertyMetadataOptions.Inherits)); private static void OnOrientationChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
var fhl = obj as FixedHeaderList;
if (fhl != null)
{
if (Orientation.Horizontal == (Orientation)args.NewValue)
SetContentPanelOrientation(fhl, Orientation.Vertical);
else
SetContentPanelOrientation(fhl, Orientation.Horizontal);
fhl.RefreshHeaderSizes();
}
}
Puis nous changerons le template par défaut pour remplacer le DockPanel par un StackPanel :
<ControlTemplate TargetType="{x:Type HeaderedContentControl}">
<StackPanel
Orientation="{TemplateBinding local:FixedHeaderList.ContentPanelOrientation}">
<ContentControl x:Name="HeaderSite" Content="{TemplateBinding Header}"
Width="{TemplateBinding local:FixedHeaderList.HeaderWidth}"
Height="{TemplateBinding local:FixedHeaderList.HeaderHeight}"
ContentTemplate="{TemplateBinding local:FixedHeaderList.ItemHeaderTemplate}"
ContentTemplateSelector="{TemplateBinding local:FixedHeaderList.ItemHeaderTemplateSelector}"
/>
<ContentControl x:Name="ContentSite" Content="{TemplateBinding Content}"
LayoutTransform="{TemplateBinding local:FixedHeaderList.ContentLayoutTransform}"
ContentTemplate="{TemplateBinding local:FixedHeaderList.ItemContentTemplate}"
ContentTemplateSelector="{TemplateBinding local:FixedHeaderList.ItemContentTemplateSelector}"/>
</StackPanel>
</ControlTemplate>
Et voilà, maintenant, nos contenus s'étendront sans contrainte, même si pour celà il devront passer hors de la surface d'affichage.
Scrolling et "Touch Navigation":
Il y'a 2 façons de gérer le scrolling d'un contrôle en WPF :
- Soit on place dans son template un ScrollViewer ou ScrollContentPresenter
- Soit on gère le scrolling à la main
Dans notre cas, nous allons gérer nous même le scrolling, car nous aurons besoin dans une des parties suivantes d'une chose qui fait défaut aux composants de scrolling déjà prêt de WPF : Les animations des propriétés ScrollX et ScrollY. De plus, le scrolling que nous allons faire est un peu particulier, car il fera en sorte que les headers soient toujours visibles, et qu'on ait l'impression que le contenu passe en dessous. Pour celà, nous allons avoir besoin d'affecter un RenderTransform aux contrôles de contenu, ainsi qu'un autre directement sur l'ItemsPanel : En effet, en fonction de l'orientation choisie, une des coordonées sera scrollée seulement sur les contenus et l'autre sur l'intégralité de L'itemsPanel (pour garder les headers en face des contenus).
Nous allons donc commencer par créer les Dependency / Attached properties dont nous allons avoir besoin :
public static TranslateTransform GetPanelRenderTransform(DependencyObject obj)
{
return (TranslateTransform)obj.GetValue(PanelRenderTransformProperty);
}
public static void SetPanelRenderTransform(DependencyObject obj, TranslateTransform value)
{
obj.SetValue(PanelRenderTransformProperty, value);
}
protected TranslateTransform PanelRenderTransform
{
get
{
return GetPanelRenderTransform(this);
}
set
{
SetPanelRenderTransform(this, value);
}
}
public static readonly DependencyProperty PanelRenderTransformProperty =
DependencyProperty.RegisterAttached("PanelRenderTransform",
typeof(TranslateTransform), typeof(FixedHeaderList),
new FrameworkPropertyMetadata(null,
FrameworkPropertyMetadataOptions.Inherits));
public static TranslateTransform GetContentRenderTransform(DependencyObject obj)
{
return (TranslateTransform)obj.GetValue(ContentRenderTransformProperty);
}
public static void SetContentRenderTransform(DependencyObject obj, TranslateTransform value)
{
obj.SetValue(ContentRenderTransformProperty, value);
}
protected TranslateTransform ContentRenderTransform
{
get
{
return GetContentRenderTransform(this);
}
set
{
SetContentRenderTransform(this, value);
}
}
public static readonly DependencyProperty ContentRenderTransformProperty =
DependencyProperty.RegisterAttached("ContentRenderTransform",
typeof(TranslateTransform), typeof(FixedHeaderList),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.Inherits));
public double ScrollX
{
get { return (double)GetValue(ScrollXProperty); }
set { SetValue(ScrollXProperty, value); }
}
public static readonly DependencyProperty ScrollXProperty =
DependencyProperty.Register("ScrollX",
typeof(double), typeof(FixedHeaderList),
new UIPropertyMetadata(0.0, OnScrollXChanged));
private static void OnScrollXChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
var ctl = obj as FixedHeaderList;
if (ctl != null)
ctl.OnScrollXChanged((double)args.OldValue, (double)args.NewValue);
}
protected virtual void OnScrollXChanged(double oldValue, double newValue)
{
}
public double ScrollY
{
get { return (double)GetValue(ScrollYProperty); }
set { SetValue(ScrollYProperty, value); }
}
public static readonly DependencyProperty ScrollYProperty =
DependencyProperty.Register("ScrollY",
typeof(double), typeof(FixedHeaderList),
new UIPropertyMetadata(0.0, OnScrollYChanged));
private static void OnScrollYChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
var ctl = obj as FixedHeaderList;
if (ctl != null)
ctl.OnScrollYChanged((double)args.OldValue, (double)args.NewValue);
}
protected virtual void OnScrollYChanged(double oldValue, double newValue)
{
}
public double MaxScrollX
{
get { return (double)GetValue(MaxScrollXProperty); }
set { SetValue(MaxScrollXProperty, value); }
}
public static readonly DependencyProperty MaxScrollXProperty =
DependencyProperty.Register("MaxScrollX",
typeof(double), typeof(FixedHeaderList),
new UIPropertyMetadata(0.0, OnMaxScrollXChanged));
private static void OnMaxScrollXChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
var ctl = obj as FixedHeaderList;
if (ctl != null)
ctl.OnMaxScrollXChanged((double)args.OldValue, (double)args.NewValue);
}
protected virtual void OnMaxScrollXChanged(double oldValue, double newValue)
{
}
public double MaxScrollY
{
get { return (double)GetValue(MaxScrollYProperty); }
set { SetValue(MaxScrollYProperty, value); }
}
public static readonly DependencyProperty MaxScrollYProperty =
DependencyProperty.Register("MaxScrollY",
typeof(double), typeof(FixedHeaderList),
new UIPropertyMetadata(0.0, OnMaxScrollYChanged));
private static void OnMaxScrollYChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
var ctl = obj as FixedHeaderList;
if (ctl != null)
ctl.OnMaxScrollYChanged((double)args.OldValue, (double)args.NewValue);
}
protected virtual void OnMaxScrollYChanged(double oldValue, double newValue)
{
}
Pour l'instant les méthodes de prise en charge des évènements sont vides. Nous allons les compléter un peu plus tard. Pour l'instant, nous allons créer une méthode qui va calculer les valeurs de MaxScrollX et MaxScrollY, et nous l'appellerons aux moments auportuns :
protected override void OnChildDesiredSizeChanged(UIElement child)
{
base.OnChildDesiredSizeChanged(child);
UpdateMaxScrollValues();
}
protected override Size ArrangeOverride(Size arrangeBounds)
{
var retval = base.ArrangeOverride(arrangeBounds);
UpdateMaxScrollValues();
return retval;
}
protected override void ParentLayoutInvalidated(UIElement child)
{
base.ParentLayoutInvalidated(child);
UpdateMaxScrollValues();
}
protected virtual void UpdateMaxScrollValues()
{
if (!m_resizeHandlerSet)
SetResizeHandler();
var itemsPresenter = (Template.FindName("itemsPresenter", this) as FrameworkElement);
if (itemsPresenter != null)
{
var itemsPanel = ItemsPanel.FindName("itemsPanel", itemsPresenter) as Panel;
if (itemsPanel != null)
{
if (Orientation == Orientation.Vertical)
{
var val = itemsPanel.ActualHeight - itemsPresenter.ActualHeight;
MaxScrollY = val >= 0 ? val : 0;
double currentMaxWidth = 0.0;
foreach (object oChild in itemsPanel.Children)
{
var child = oChild as HeaderedContentControl;
if (child != null)
{
var contentSite = child.Template.FindName("ContentSite", child) as Control;
if (contentSite != null)
{
var actualWidth = contentSite.ActualWidth * Zoom;
currentMaxWidth = actualWidth > currentMaxWidth ? actualWidth : currentMaxWidth;
}
}
}
var maxX = currentMaxWidth + HeaderSize - itemsPanel.ActualWidth;
MaxScrollX = maxX > 0.0 ? maxX : 0.0;
}
else
{
var val = itemsPanel.ActualWidth - itemsPresenter.ActualWidth;
MaxScrollX = val >= 0 ? val : 0;
double currentMaxHeight = 0.0;
foreach (var oChild in itemsPanel.Children)
{
var child = oChild as HeaderedContentControl;
if (child != null)
{
var contentSite = child.Template.FindName("ContentSite", child) as Control;
if (contentSite != null)
{
var actualHeight = contentSite.ActualHeight * Zoom;
currentMaxHeight = actualHeight > currentMaxHeight ? actualHeight : currentMaxHeight;
}
}
}
var maxY = currentMaxHeight + HeaderSize - itemsPanel.ActualHeight;
MaxScrollY = maxY > 0.0 ? maxY : 0.0;
}
}
}
}
bool m_resizeHandlerSet = false;
private void SetResizeHandler()
{
var presenter = Template.FindName("itemsPresenter", this) as FrameworkElement;
if (presenter != null && ItemsPanel != null)
{
try
{
var panel = ItemsPanel.FindName("itemsPanel", presenter) as Panel;
panel.SizeChanged += delegate
{
UpdateMaxScrollValues();
};
m_resizeHandlerSet = true;
}
catch
{
Debug.WriteLine("Not templated");
}
}
}
Vous pouvez noter que nous faisons des appels à Template.FindName ou ItemsPanel.FindName. Celà nécessite que nous nomions certains contrôles dans nos templates. Voilà donc le style général mis à jour avec des noms, affichant des ScrollBars, et avec les Bindings corrects en ce qui concerne les RenderTransforms :
<Style TargetType="{x:Type local:FixedHeaderList}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:FixedHeaderList}">
<Border SnapsToDevicePixels="true"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="{Binding ElementName=scrollBarBottom,Path=ActualHeight}" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="{Binding ElementName=scrollBarRight,Path=ActualWidth}"/>
</Grid.ColumnDefinitions>
<ItemsPresenter x:Name="itemsPresenter"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
<ScrollBar Orientation="Vertical"
x:Name="scrollBarRight"
Grid.Column="1"
Value="{Binding ScrollY,
Mode=TwoWay,
RelativeSource = {RelativeSource TemplatedParent}}"
Maximum="{TemplateBinding MaxScrollY}"
ViewportSize="{Binding ElementName=itemsPresenter,Path=ActualHeight}"/>
<ScrollBar Orientation="Horizontal"
x:Name="scrollBarBottom"
Grid.Row="1"
Value="{Binding ScrollX,
Mode=TwoWay,
RelativeSource = {RelativeSource TemplatedParent}}"
Maximum="{TemplateBinding MaxScrollX}"
ViewportSize="{Binding ElementName=itemsPresenter,Path=ActualWidth}"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemsPanel" >
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel IsItemsHost="True" x:Name="itemsPanel"
Orientation="{Binding Orientation,
RelativeSource={RelativeSource FindAncestor
,AncestorType={x:Type local:FixedHeaderList}}}"
/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="{x:Type HeaderedContentControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type HeaderedContentControl}">
<StackPanel
Orientation="{TemplateBinding local:FixedHeaderList.ContentPanelOrientation}"
RenderTransform="{TemplateBinding local:FixedHeaderList.PanelRenderTransform}">
<ContentControl x:Name="HeaderSite" Content="{TemplateBinding Header}"
Width="{TemplateBinding local:FixedHeaderList.HeaderWidth}"
Height="{TemplateBinding local:FixedHeaderList.HeaderHeight}"
ContentTemplate="{TemplateBinding local:FixedHeaderList.ItemHeaderTemplate}"
ContentTemplateSelector="{TemplateBinding local:FixedHeaderList.ItemHeaderTemplateSelector}"
/>
<Border ClipToBounds="True">
<ContentControl x:Name="ContentSite" Content="{TemplateBinding Content}"
LayoutTransform="{TemplateBinding local:FixedHeaderList.ContentLayoutTransform}"
ContentTemplate="{TemplateBinding local:FixedHeaderList.ItemContentTemplate}"
ContentTemplateSelector="{TemplateBinding local:FixedHeaderList.ItemContentTemplateSelector}"
RenderTransform="{TemplateBinding local:FixedHeaderList.ContentRenderTransform}"/>
</Border>
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
</Style>
On peut noter ici l'utilisation de binding avec des RelativeSource pour pouvoir faire du Binding bi-directionnel avec le contrôle templaté.
On peut aussi remarquer le <Border ClipToBounds="True"> autour du ContentControl de contenu : il permettra de créer l'illusion que le contenu passe sous le header en cas de Scrolling.
Nous pouvons maintenant gérer les changements de valeurs de ScrollX, ScrollY, MaxScrollX et MaxScrollY :
protected virtual void OnScrollXChanged(double oldValue, double newValue)
{
if (newValue < 0)
ScrollX = 0;
else if (newValue > MaxScrollX)
ScrollX = MaxScrollX;
else
{
if (Orientation == Orientation.Vertical)
{
ContentRenderTransform = new TranslateTransform(-newValue, 0);
}
else
{
PanelRenderTransform = new TranslateTransform(-newValue, 0);
}
}
}
protected virtual void OnScrollYChanged(double oldValue, double newValue)
{
if (newValue < 0)
ScrollY = 0;
else if (newValue > MaxScrollY)
ScrollY = MaxScrollY;
else
{
if (Orientation == Orientation.Vertical)
{
PanelRenderTransform = new TranslateTransform(0, -newValue);
}
else
{
ContentRenderTransform = new TranslateTransform(0, -newValue);
}
}
}
protected virtual void OnMaxScrollXChanged(double oldValue, double newValue)
{
if (ScrollX > newValue)
ScrollX = newValue;
}
protected virtual void OnMaxScrollYChanged(double oldValue, double newValue)
{
if (ScrollY > newValue)
ScrollY = newValue;
}
Et voilà pour le Scrolling. Tout ce qui nous reste maintenant, c'est la "Touch navigation". Pour faire cela, nous n'avons qu'à gérer les évènements de souris et modifier les valeurs de ScrollX et ScrollY en fonction :
bool m_touching = false;
Point m_lastPoint;
protected override void OnMouseDown(MouseButtonEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
{
CaptureMouse();
m_lastPoint = e.GetPosition(this);
m_touching = true;
}
}
protected override void OnMouseUp(MouseButtonEventArgs e)
{
m_touching = false;
ReleaseMouseCapture();
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (m_touching)
{
var newPoint = e.GetPosition(this);
ScrollX -= newPoint.X - m_lastPoint.X;
ScrollY -= newPoint.Y - m_lastPoint.Y;
m_lastPoint = newPoint;
}
else
base.OnMouseMove(e);
}
Et voilà, nous avons maintenant une expérience utilisateur intéressante. Dans la prochaine partie, nous irons encore plus loin, en introduisant la "Warcraft User Experience" dans notre contrôle... Stay tuned :).
Vous pouvez télécharger le code source final de l'article ici.