I. Traduction▲
Ceci est la traduction la plus fidèle possible de l'article de Sébastien Pertus, Create a custom user control using Xaml and C# for Windows 8Create a custom user control using Xaml and C# for Windows 8.
II. Introduction▲
Comme vous le savez, on peut facilement développer des applications natives pour Windows 8 en utilisant XAML et C# (ou encore VB.NET ;)).
Parmi beaucoup d'autres choses, XAMLnous permet de créer un contrôle personnalisé afin de factoriser notre UI.
Aujourd'hui, je souhaiterais vous montrer comment créer ce genre de contrôles et pour ce faire, je voudrais tout d'abord vous présenter la version finale du XAML Carousel Control :
Comme vous pouvez le constater, il s'agit d'un carrousel en 3D qui peut être utilisé pour afficher une longue liste d'éléments (avec un grand miroir (si vous aimez ça :)).
(Si vous souhaitez savoir comment réaliser la même chose en utilisant WinJS et HTML, vous pouvez lire l'articleCreate a custom user control using JavaScript and WinJS for Windows 8rédigé par mon collègue David Catuhe.)
Ainsi, vous vous demandez sans doute comment développer un si beau (!!) contrôle. La réponse sera décomposée en six parties :
- Comment configurer la mise en page ? ;
- Déclaration et instanciation du contrôle ;
- Utiliser des templates ;
- Utiliser le binding de données ;
- Intégrer les entrées utilisateur et des animations ;
- Optimisations.
III. Configurer la mise en page▲
Le contrôle lui-même est construit en utilisant un Canvas, appelé Carousel. Le Carousel contient une propriété DataTemplate appelée ItemTemplate, décrivant l'aspect visuel de chaque élément du carrousel.
Dans notre exemple, vous pouvez voir que j'ai fait un miroir défini par l'image originale sur laquelle j'ai fait un CompositeTransform (ScaleY) et un PlaneProjection (RotationX).
Au final, le Carousel contient un rectangle noir qui délimite la perspective :
Grâce au DataTemplate et à la propriété de dépendance, vous pouvez définir le style de chaque partie du contrôle en définissant les propriétés et en déclarant un modèle d'élément.
Par exemple, voici deux styles différents : sans miroir et sans Rotation. Et un autre avec 90 ° en Rotation, ayant les propriétés Depthet TranslateXau max.
IV. Déclaration et instanciation du contrôle▲
Aussi simplement que ça :
<
ctrl
:
LightStone />
Cela dit, vous pouvez configurer plusieurs propriétés :
IV-A. ItemsSource▲
Sur le même modèle qu'une ListBox ou une ListView, vous devez configurer un ItemSource. Il vaut mieux privilégier un objet de type ObservableCollection<T> auquel vous pouvez ajouter ou supprimer des éléments pendant l'exécution de votre application (cf. la partie sur le DataBinding un peu plus loin).
IV-B. Propriétés de dépendance▲
- TransitionDuration : durée de l'animation (ms).
- Depth : profondeur sur les éléments non sélectionnés.
- Rotation : rotation des éléments non sélectionnés.
- TranslateX : translation sur l'axe X des éléments non sélectionnés.
- TranslateY : translation sur l'axe Y de tous les éléments.
IV-C. Fonction d'accélération▲
Vous pouvez configurer une fonction d'accélération pour tous les éléments. Ci-joint la documentation MSDN associée aux fonctions d'accélération pour savoir comment les utiliser.
J'ai décidé d'utiliser un CubicEase avec un mode d'accélération configuré sur « EaseOut ».
V. Templates▲
L'infrastructure Xaml fournit un moyen simple d'implémenter un ItemTemplate. Pour aller plus loin sur le sujet, vous verrez dans le code la méthode permettant d'afficher un élément lié à un DataTemplate. Il vous faut implémenter une propriété DataTemplate qui sera déclarée dans votre contrôle xaml. Dans l'exemple fourni, vous verrez que j'ai déclaré une image, et une image en miroir avec une opacité.
<
ctrl
:
LightStone.ItemTemplate >
< DataTemplate >
< Grid >
< Grid.RowDefinitions >
< RowDefinition Height
=
"Auto"
/>
< RowDefinition Height
=
"Auto"
/>
</ Grid.RowDefinitions >
< Image Source
=
"{Binding BitmapImage}"
Width
=
"600"
VerticalAlignment
=
"Bottom"
Stretch
=
"Uniform"
></ Image >
< Rectangle Grid . Row
=
"1"
Fill
=
"Black"
Margin
=
"0,10"
></ Rectangle >
< Image Grid . Row
=
"1"
VerticalAlignment
=
"Top"
Width
=
"600"
Margin
=
"0,10"
Source
=
"{Binding BitmapImage}"
Stretch
=
"Uniform"
Opacity
=
"0.1"
>
< Image.RenderTransform >
< CompositeTransform ScaleY
=
"1"
/>
</ Image.RenderTransform >
< Image.Projection >
< PlaneProjection RotationX
=
"180"
></ PlaneProjection >
</ Image.Projection >
</ Image >
</ Grid >
</ DataTemplate >
</
ctrl
:
LightStone.ItemTemplate >
V-A. Implémentation▲
La propriété ItemTemplate est un DataTemplate. En voici le code. Nous n'avons pas besoin de déclarer une propriété de dépendance, car nous ne voulons pas effectuer de modifications durant l'exécution.
///
<
summary
>
///
<
Item Template
/// </summary
>
public
DataTemplate ItemTemplate
{
get
{
return
itemTemplate;
}
set
{
itemTemplate =
value
;
}
}
Durant la liaison, une méthode est responsable de la liaison et de l'affichage, grâce à la méthode LoadContent, qui va créer tous les UIElements :
///
<
summary
>
/// Bind all Items
///
<
/summary
>
private
void
Bind
(
)
{
if
(
ItemsSource ==
null
)
return
;
this
.
Children.
Clear
(
);
this
.
internalList.
Clear
(
);
foreach
(
object
item in
ItemsSource)
this
.
CreateItem
(
item);
this
.
Children.
Add
(
rectangle);
}
///
<
summary
>
/// Create an item (Load data template and bind)
///
<
/summary
>
private
FrameworkElement CreateItem
(
object
item,
Double opacity =
1
)
{
FrameworkElement element =
ItemTemplate.
LoadContent
(
) as
FrameworkElement;
if
(
element ==
null
)
return
null
;
element.
DataContext =
item;
element.
Opacity =
opacity;
element.
RenderTransformOrigin =
new
Point
(
0
.
5
,
0
.
5
);
PlaneProjection planeProjection =
new
PlaneProjection
(
);
planeProjection.
CenterOfRotationX =
0
.
5
;
planeProjection.
CenterOfRotationY =
0
.
5
;
element.
Projection =
planeProjection;
this
.
internalList.
Add
(
element);
this
.
Children.
Add
(
element);
return
element;
}
VI. Liaison de données▲
Le mécanisme de liaison de données est fourni par la propriété de dépendance appelée ItemsSource.
Par ailleurs, vous devez fournir votre propre collection. Vous trouverez dans l'exemple une ObservableCollection d'objets de type Data.
Voici la classe Data(définie par un BitmapImage et un Titre)
public
class
Data
{
public
BitmapImage BitmapImage {
get
;
set
;
}
public
String Title {
get
;
set
;
}
}
Ci-
joint la liste ObservableCollection<
Data>
utilisée :
public
ObservableCollection<
Data>
Datas {
get
;
set
;
}
public
MainPageViewModel
(
)
{
this
.
Datas =
new
ObservableCollection<
Data>(
);
this
.
Datas.
Add
(
new
Data {
BitmapImage =
new
BitmapImage
(
new
Uri
(
"ms-appx:///Assets/pic01.jpg"
,
UriKind.
Absolute)),
Title =
"Wall 05"
}
);
this
.
Datas.
Add
(
new
Data {
BitmapImage =
new
BitmapImage
(
new
Uri
(
"ms-appx:///Assets/pic02.jpg"
,
UriKind.
Absolute)),
Title =
"Wall 06"
}
);
this
.
Datas.
Add
(
new
Data {
BitmapImage =
new
BitmapImage
(
new
Uri
(
"ms-appx:///Assets/pic03.jpg"
,
UriKind.
Absolute)),
Title =
"Wall 07"
}
);
}
VI-A. Implémentation▲
J'ai utilisé une propriété de dépendance afin d'obtenir une méthode de callback lorsque mon ItemsSource est modifié, afin d'autoriser une nouvelle liaison de données.
Remarquez le gestionnaire de la méthode ItemsSourceChangedCallback :
///
<
summary
>
/// Items source : Better if ObservableCollection :)
///
<
/summary
>
public
IEnumerable<
Object>
ItemsSource
{
get
{
return
(
IEnumerable<
Object>
)GetValue
(
ItemsSourceProperty);
}
set
{
SetValue
(
ItemsSourceProperty,
value
);
}
}
// Using a DependencyProperty as the backing store for ItemsSource.
//This enables animation, styling, binding, etc...
public
static
readonly
DependencyProperty ItemsSourceProperty =
DependencyProperty.
Register
(
"ItemsSource"
,
typeof
(
IEnumerable<
Object>
),
typeof
(
LightStone),
new
PropertyMetadata
(
0
,
ItemsSourceChangedCallback));
Le ItemsSourceChangedCallback me permet d'implémenter mon propre mécanisme lorsque j'ajoute ou supprime un ou plusieurs éléments :
private
static
void
ItemsSourceChangedCallback
(
DependencyObject dependencyObject,
DependencyPropertyChangedEventArgs args)
{
if
(
args.
NewValue ==
null
)
return
;
if
(
args.
NewValue ==
args.
OldValue)
return
;
LightStone lightStone =
dependencyObject as
LightStone;
if
(
lightStone ==
null
)
return
;
var
obsList =
args.
NewValue as
INotifyCollectionChanged;
if
(
obsList !=
null
)
{
obsList.
CollectionChanged +=
(
sender,
eventArgs) =>
{
switch
(
eventArgs.
Action)
{
case
NotifyCollectionChangedAction.
Remove:
foreach
(
var
oldItem in
eventArgs.
OldItems)
{
for
(
int
i =
0
;
i <
lightStone.
internalList.
Count;
i++
)
{
var
fxElement =
lightStone.
internalList[
i]
as
FrameworkElement;
if
(
fxElement ==
null
||
fxElement.
DataContext !=
oldItem) continue
;
lightStone.
RemoveAt
(
i);
}
}
break
;
case
NotifyCollectionChangedAction.
Add:
foreach
(
var
newItem in
eventArgs.
NewItems)
lightStone.
CreateItem
(
newItem,
0
);
break
;
}
};
}
lightStone.
Bind
(
);
}
VII. Saisies utilisateurs et animations▲
VII-A. Saisies▲
Afin de répondre aux saisies des utilisateurs, il vous suffit de gérer les événements du pointeur.
Remarque : Pour aller plus loin, vous pouvez utiliser le GestureRecognizer, mais dans cet exemple nous souhaitons juste gérer des gestes simples.
Le principe est simple : si l'utilisateur déplace son doigt/souris/stylet sur plus de 40pixels, on peut changer l'élément courant en fonction de la direction du mouvement.
Voici le code :
///
<
summary
>
/// Initial pressed position
///
<
/summary
>
private
void
OnPointerPressed
(
object
sender,
PointerRoutedEventArgs args)
{
initialOffset =
args.
GetCurrentPoint
(
this
).
Position.
X;
}
///
<
summary
>
/// Calculate Behavior
///
<
/summary
>
private
void
OnPointerReleased
(
object
sender,
PointerRoutedEventArgs pointerRoutedEventArgs)
{
// Minimum amount to declare as a manipulation
const
int
moveThreshold =
40
;
// last position
var
clientX =
pointerRoutedEventArgs.
GetCurrentPoint
(
this
).
Position.
X;
// Here is a "Tap on Item"
if
(!(
Math.
Abs
(
clientX -
initialOffset) >
moveThreshold))
return
;
isIncrementing =
(
clientX <
initialOffset);
// Here is a manipulation
if
(
clientX <
initialOffset)
{
this
.
SelectedIndex =
(
this
.
SelectedIndex <
(
this
.
internalList.
Count -
1
))
?
this
.
SelectedIndex +
1
:
this
.
SelectedIndex;
}
else
if
(
this
.
SelectedIndex >
0
)
{
this
.
SelectedIndex--;
}
initialOffset =
clientX;
}
J'ai utilisé une autre méthode pour répondre à l'événement tactile (pas pour un geste, mais pour l'action "clic")
Pour obtenir le bon élément sur lequel nous avons cliqué, j'ai utilisé la méthode UIElement.TransformToVisual qui retourne un objet transformé pouvant être utilisé pour modifier les coordonnées depuis l'objet UIElement :
var
rect =
child.
TransformToVisual
(
this
).
TransformBounds
(
new
Rect
(
0
,
0
,
child.
DesiredSize.
Width,
child.
DesiredSize.
Height));
Voici la méthode complète :
///
<
summary
>
/// Tap an element
///
<
/summary
>
private
void
OnTapped
(
object
sender,
TappedRoutedEventArgs args)
{
var
positionX =
args.
GetPosition
(
this
).
X;
for
(
int
i =
0
;
i <
this
.
internalList.
Count;
i++
)
{
var
child =
internalList[
i];
var
rect =
child.
TransformToVisual
(
this
).
TransformBounds
(
new
Rect
(
0
,
0
,
child.
DesiredSize.
Width,
child.
DesiredSize.
Height));
if
(!(
positionX >=
rect.
Left) ||
!(
positionX <=
(
rect.
Left +
rect.
Width))) continue
;
isIncrementing =
(
i >
this
.
SelectedIndex);
this
.
SelectedIndex =
i;
return
;
}
}
VII-B. Animations▲
La transition entre chaque élément est assurée par une fonctionnalité assez sympathique disponible sur chaque UIElement : le UIElement.Projection.
Projection permet d'obtenir ou d'attribuer la perspective en 3D à appliquer lorsque l'on affiche l'élément. Lorsque vous appliquez une projection, vous pouvez choisir entre une Matrix3DProjection ou une PlaneProjection. Dans cet exemple, j'ai utilisé la PlaneProjection.
Chaque élément sera animé des propriétés de sa PlaneProjection. Selon les propriétés de dépendance que vous aurez fournies dans votre contrôle xaml bien entendu :)
Grâce au mécanisme d'Animation de XAML, nous pourrons animer tous les éléments avec un seul StoryBoard :
///
<
summary
>
/// Update all positions. Launch every animations on all items with a unique StoryBoard
///
<
/summary
>
private
void
UpdatePosition
(
)
{
if
(
storyboard.
GetCurrentState
(
) !=
ClockState.
Stopped)
{
storyboard.
SkipToFill
(
);
storyboard.
Stop
(
);
storyboard =
new
Storyboard
(
);
}
isUpdatingPosition =
true
;
for
(
int
i =
0
;
i <
this
.
internalList.
Count;
i++
)
{
var
item =
internalList[
i];
PlaneProjection planeProjection =
item.
Projection as
PlaneProjection;
if
(
planeProjection ==
null
)
continue
;
// Get properties
var
depth =
(
i ==
this
.
SelectedIndex) ?
0
:
-(
this
.
Depth);
var
rotation =
(
i ==
this
.
SelectedIndex) ?
0
:
((
i <
this
.
SelectedIndex) ?
Rotation :
-
Rotation);
var
offsetX =
(
i ==
this
.
SelectedIndex) ?
0
:
(
i -
this
.
SelectedIndex) *
desiredWidth;
var
translateY =
TranslateY;
var
translateX =
(
i ==
this
.
SelectedIndex) ?
0
:
((
i <
this
.
SelectedIndex) ?
-
TranslateX :
TranslateX);
// CenterOfRotationX
// to Get good center of rotation for SelectedIndex, must know the animation behavior
int
centerOfRotationSelectedIndex =
isIncrementing ?
1
:
0
;
var
centerOfRotationX =
(
i ==
this
.
SelectedIndex) ?
centerOfRotationSelectedIndex :
((
i >
this
.
SelectedIndex) ?
1
:
0
);
planeProjection.
CenterOfRotationX =
centerOfRotationX;
// Dont animate all items
var
inf =
this
.
SelectedIndex -
(
MaxVisibleItems *
2
);
var
sup =
this
.
SelectedIndex +
(
MaxVisibleItems *
2
);
if
(
i <
inf ||
i >
sup)
continue
;
// Zindex and Opacity
var
deltaFromSelectedIndex =
Math.
Abs
(
this
.
SelectedIndex -
i);
int
zindex =
(
this
.
internalList.
Count *
100
) -
deltaFromSelectedIndex;
Canvas.
SetZIndex
(
item,
zindex);
Double opacity =
1d
-
(
Math.
Abs
((
Double)(
i -
this
.
SelectedIndex) /
(
MaxVisibleItems +
1
)));
var
newVisibility =
deltaFromSelectedIndex >
MaxVisibleItems
?
Visibility.
Collapsed
:
Visibility.
Visible;
// Item already present
if
(
item.
Visibility ==
newVisibility)
{
storyboard.
AddAnimation
(
item,
TransitionDuration,
rotation,
"(UIElement.Projection).(PlaneProjection.RotationY)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
depth,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetZ)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
translateX,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetX)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
offsetX,
"(UIElement.Projection).(PlaneProjection.LocalOffsetX)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
translateY,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetY)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
opacity,
"Opacity"
,
this
.
EasingFunction);
}
else
if
(
newVisibility ==
Visibility.
Visible)
{
// This animation will occur in the ArrangeOverride() method
item.
Visibility =
newVisibility;
item.
Opacity =
0d
;
}
else
if
(
newVisibility ==
Visibility.
Collapsed)
{
storyboard.
AddAnimation
(
item,
TransitionDuration,
rotation,
"(UIElement.Projection).(PlaneProjection.RotationY)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
depth,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetZ)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
translateX,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetX)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
offsetX,
"(UIElement.Projection).(PlaneProjection.LocalOffsetX)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
translateY,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetY)"
,
this
.
EasingFunction);
storyboard.
AddAnimation
(
item,
TransitionDuration,
0d
,
"Opacity"
,
this
.
EasingFunction);
storyboard.
Completed +=
(
sender,
o) =>
item.
Visibility =
Visibility.
Collapsed;
}
}
// When storyboard completed, Invalidate
storyboard.
Completed +=
(
sender,
o) =>
{
this
.
isUpdatingPosition =
false
;
this
.
InvalidateArrange
(
);
};
storyboard.
Begin
(
);
}
Pour le rendre plus simple et plus facile à utiliser, vous trouverez une méthode d'extension permettant de définir un DoubleAnimation directement sur un StoryBoard :
public
static
void
AddAnimation
(
this
Storyboard storyboard,
DependencyObject element,
int
duration,
double
fromValue,
double
toValue,
String propertyPath,
EasingFunctionBase easingFunction =
null
)
{
DoubleAnimation timeline =
new
DoubleAnimation
(
);
timeline.
From =
fromValue;
timeline.
To =
toValue;
timeline.
Duration =
TimeSpan.
FromMilliseconds
(
duration);
if
(
easingFunction !=
null
)
timeline.
EasingFunction =
easingFunction;
storyboard.
Children.
Add
(
timeline);
Storyboard.
SetTarget
(
timeline,
element);
Storyboard.
SetTargetProperty
(
timeline,
propertyPath);
}
VIII. Optimisations▲
Enfin, juste pour être sûr que notre contrôle fonctionne bien sur du matériel bas de gamme, nous devons nous assurer que seuls les éléments visibles sont effectivement gérés par le contrôle, et par la même occasion n'animer que les éléments visibles.
Pour atteindre cet objectif, il nous faut enlever les éléments du contrôle lorsque leur opacité est inférieure ou égale à 0. Bien évidemment, vous devez les réinsérer lorsqu'ils deviennent à nouveau visibles.
/// Dont animate all items
var
inf =
this
.
SelectedIndex -
(
MaxVisibleItems *
2
);
var
sup =
this
.
SelectedIndex +
(
MaxVisibleItems *
2
);
if
(
i <
inf ||
i >
sup)
continue
;
// Get if item is visible or not
var
newVisibility =
deltaFromSelectedIndex >
MaxVisibleItems
?
Visibility.
Collapsed
:
Visibility.
Visible;
Dans la méthode ArrangeOverride, nous pouvons vérifier si un élément apparaît ou pas :
// Items appears
if
(
container.
Visibility ==
Visibility.
Visible &&
container.
Opacity ==
0d
)
{
localStoryboard.
AddAnimation
(
container,
TransitionDuration,
rotation,
"(UIElement.Projection).(PlaneProjection.RotationY)"
,
this
.
EasingFunction);
localStoryboard.
AddAnimation
(
container,
TransitionDuration,
depth,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetZ)"
,
this
.
EasingFunction);
localStoryboard.
AddAnimation
(
container,
TransitionDuration,
translateX,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetX)"
,
this
.
EasingFunction);
localStoryboard.
AddAnimation
(
container,
TransitionDuration,
offsetX,
"(UIElement.Projection).(PlaneProjection.LocalOffsetX)"
,
this
.
EasingFunction);
localStoryboard.
AddAnimation
(
container,
TransitionDuration,
translateY,
"(UIElement.Projection).(PlaneProjection.GlobalOffsetY)"
,
this
.
EasingFunction);
localStoryboard.
AddAnimation
(
container,
TransitionDuration,
0
,
opacity,
"Opacity"
,
this
.
EasingFunction);
}
else
{
container.
Opacity =
opacity;
}
Vous trouverez le code complet iciici.
Amusez-vous bien !
IX. Remerciements▲
Je tiens à remercier Sébastien Pertus pour son aimable autorisation de traduire cet article, ainsi que Claude Leloup et Thomas Levesque pour leur relecture attentive et leurs corrections.