I. Contexte et objectifs▲
La ComboBox dont nous allons parler dans cet article est un contrôle visuel qui permet de sélectionner une valeur dans une liste déroulante de choix possible. Il est présent dans la plupart des librairies ou frameworks d'affichage, parfois sous d'autres noms. Certains considèrent ce composant comme inadapté au développement mobile, car difficile à utiliser sur de petits écrans. Dans certains cas, il reste pourtant la meilleure solution.
Une recherche rapide sur Internet montre que beaucoup de développeurs rencontrent ce problème. Deux approches sont généralement retenues :
- utiliser une ComboBox en l‘ajoutant directement depuis le XAML décrivant la page, puis modifier le style. Le composant ComboBox existe bel et bien, même si l'éditeur d'interface de Visual Studio ne le propose pas, mais n'est pas customisé pour s'intégrer dans une interface Windows Phone. Il est alors possible de complètement redéfinir le style pour l'adapter à l'application. La démarche pour y parvenir est présentée ici ;
- utiliser le composant « ListPicker » du Windows Phone Toolkit. Il reproduit exactement le comportement voulu, avec quelques spécificités, notamment le passage en plein écran à partir d'un certain nombre d'entrées. Cet article offre un aperçu de son utilisation.
La première solution me parait lourde à mettre en place et délicate à réutiliser sur d'autres projets.
La deuxième est intéressante, mais le passage en plein écran au-delà de trois éléments ne me satisfait pas. Je trouve qu'il rend la navigation moins fluide dans le cas de nombreuses ComboBox les unes après les autres, et ce composant s'intègre mal dans la structure de ma page.
J'ai donc choisi une troisième approche, qui consiste à créer ma propre ComboBox. Cet article a pour objectif de présenter le composant qui en résulte et les différentes étapes de sa réalisation.
I-A. L'application de test▲
Pour mieux comprendre le besoin et illustrer la création du composant, nous allons nous intéresser à une application minimaliste mettant en jeu de nombreuses ComboBox.
Notre application de démonstration permettra le remplissage, pour différents vols, de leurs aéroports de départ et d'arrivée.
Les vols devront être affichés dans une ListBox, les uns en dessous des autres, comme indiqué sur la figure suivante :
Les noms des aéroports seront indiqués dans des ComboBox pour permettre de les modifier.
I-B. Structure de l'application▲
La solution Visual Studio comprendra deux projets :
- CombinedComboBox, de type librairie pour Windows Phone, qui contiendra le composant développé ;
- CombinedComboBoxTestApp, de type application pour Windows Phone, qui contiendra l'application de test et référencera le premier projet.
Dans la suite du document, les extraits de codes seront présentés dans des blocs de couleur
- verts si le fichier appartient au projet CombinedComboBoxTestApp ;
- bleu si le fichier appartient au projet CombinedComboBox.
L'application de test sera constituée d'une vue principale MainPage.xaml, d'un UserControl FlightPresenter.xaml servant à représenter un élément de la liste, et d'une classe DataStore servant à fournir les données.
Nous rajouterons également un fichier AirportToStringConverter.cs dont l'utilité sera présentée plus tard.
Le fichier DataStore.cs contient la classe DataStore qui expose trois listes distinctes :
- une liste des vols ;
- une liste d'aéroports français ;
- une liste d'aéroports européens.
Toujours dans le fichier DataStore.cs, nous écrivons deux autres classes, nécessaires pour représenter les données :
public
class
Airport
{
public
string
City {
get
;
set
;
}
public
string
Name {
get
;
set
;
}
public
int
RunwayCount {
get
;
set
;
}
public
Airport
(
string
city,
string
name,
int
runwayCount)
{
City =
city;
Name =
name;
RunwayCount =
runwayCount;
}
public
bool
Equals
(
Airport a)
{
return
Name ==
a.
Name;
}
}
public
class
Flight
{
public
int
FlightNumber {
get
;
set
;
}
public
Airport Destination {
get
;
set
;
}
public
Airport Leaving {
get
;
set
;
}
public
Flight
(
int
number,
Airport leaving,
Airport destination)
{
FlightNumber =
number;
Destination =
destination;
Leaving =
leaving;
}
}
Le code de la page principal est très simple et n'évoluera pas dans la suite :
<
phone
:
PhoneApplicationPage
[…]
DataContext
=
"{Binding Source={StaticResource DataStore}}"
>
<Grid
x
:
Name
=
"LayoutRoot"
Background
=
"WhiteSmoke"
>
<Grid.RowDefinitions>
<RowDefinition
Height
=
"Auto"
/>
<RowDefinition
Height
=
"*"
/>
</Grid.RowDefinitions>
<Border Grid.
Row
=
"0"
Margin
=
"10"
Padding
=
"0,0,0,4"
BorderThickness
=
"0,0,0,1"
BorderBrush
=
"Firebrick"
>
<Button
Content
=
"CombinedComboBox test app"
Command
=
"{ Binding ClickOnPageTitleCommand }"
FontSize
=
"30"
Foreground
=
"Black"
/>
</Border>
<ListBox
x
:
Name
=
"FlightList"
ItemsSource
=
"{ Binding Flights }"
Grid.
Row
=
"1"
>
<ListBox.ItemContainerStyle>
<Style
TargetType
=
"ListBoxItem"
>
<Setter
Property
=
"HorizontalContentAlignment"
Value
=
"Stretch"
/>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<
local
:
FlightPresenter
DataContext
=
"{ Binding }"
/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox >
</Grid >
</
phone
:
PhoneApplicationPage >
La commande ClickOnPageTitleCommand sera présentée plus loin dans le document, elle servira à des fins de test. Pour l'instant nous pouvons la créer, vide, dans notre DataStore :
private
ICommand _clickOnPageTitleCommand;
public
ICommand ClickOnPageTitleCommand
{
get
{
return
_clickOnPageTitleCommand ??
(
_clickOnPageTitleCommand =
new
DelegateCommand
(
ClickOnPageTitle));
}
}
private
void
ClickOnPageTitle
(
){}
Reste le fichier FlightPresenter.xaml, qui instanciera les ComboBox. La structure de base est la suivante :
<UserControl […]>
<UserControl.Resources>
</UserControl.Resources>
<Border
BorderBrush
=
"Gray"
BorderThickness
=
"0,0,0,1"
>
<Grid>
<Grid.RowDefinitions >
<RowDefinition
Height
=
"Auto"
/>
<RowDefinition
Height
=
"Auto"
/>
</Grid.RowDefinitions >
<Grid.ColumnDefinitions >
<ColumnDefinition
Width
=
"100"
/>
<ColumnDefinition
Width
=
"Auto"
/>
<ColumnDefinition
Width
=
"*"
/>
</Grid.ColumnDefinitions>
<TextBlock
Text
=
"{Binding FlightNumber}"
Tap
=
"OnFlightNameTaped"
Foreground
=
"Black"
Margin
=
"0,36,0,36"
FontSize
=
"26"
Grid.
Column
=
"0"
Grid.
RowSpan
=
"2"
TextAlignment
=
"Center"
/>
<TextBlock
Foreground
=
"Black"
Margin
=
" 0,10,0,5"
Text
=
"dep."
FontSize
=
"26"
Grid.
Column
=
"1"
Grid.
Row
=
"0"
/>
<TextBlock
Foreground
=
"Black"
Margin
=
"0,5,0,10"
Text
=
"dest."
FontSize
=
"26"
Grid.
Column
=
"1"
Grid.
Row
=
"1"
/>
[ComboBox pour la provenance ]
[ComboBox pour la destination]
</Grid>
</Border>
</UserControl>
Nous y retrouvons le champ FlightNumber de la classe Flight, affiché grâce à un Binding (dans l'extrait de code de MainPage.xaml plus haut, la ligne <
local
:
FlightPresenter
DataContext
=
"{Binding}"
/>
dans le DataTemplate de la ListBox définissait le vol en cours d'affichage comme DataContext pour le FlightPresenter).
Il faudra également créer la méthode OnFlightNameTaped pour que ce code fonctionne :
private
void
OnFlightNameTaped
(
object
sender,
GestureEventArgs e)
{
DestinationComboBox.
IsEnabled =
!
DestinationComboBox.
IsEnabled;
}
Il ne nous restera donc plus qu'à remplacer les annotations surlignées en vert par l'instanciation de nos ComboBox.
I-C. Résultat avec le ListPicker▲
Commençons dans un premier temps par tester le code avec le ListPicker du Windows Phone toolkit évoqué dans l'introduction. Nous ne listerons pour l'instant que les aéroports européens (mais non français). Le code remplaçant les annotations et l'application en résultant sont visibles ci-dessous :
<
toolkit
:
ListPicker
x
:
Name
=
"LeavingComboBox"
SelectedItem
=
"{Binding Leaving}"
ItemsSource
=
"{Binding Source={StaticResource DataStore}, Path=EuropeanAirports}"
FontSize
=
"26"
Margin
=
"10,5"
VerticalAlignment
=
"Center"
Grid.
Column
=
"2"
Grid.
Row
=
"0"
>
<
toolkit
:
ListPicker.ItemTemplate>
<DataTemplate>
<TextBlock
Text
=
"{ Binding Name }"
TextTrimming
=
"WordEllipsis"
/>
</DataTemplate>
</
toolkit
:
ListPicker.ItemTemplate>
</
toolkit
:
ListPicker>
<
toolkit
:
ListPicker
x
:
Name
=
"DestinationComboBox"
SelectedItem
=
"{Binding Destination}"
ItemsSource
=
"{Binding Source={StaticResource DataStore}, Path=EuropeanAirports}"
FontSize
=
"26"
Margin
=
"10,5"
VerticalAlignment
=
"Center"
Grid.
Column
=
"2"
Grid.
Row
=
"1"
>
<
toolkit
:
ListPicker.ItemTemplate>
<DataTemplate>
<TextBlock
Text
=
"{ Binding Name }"
TextTrimming
=
"WordEllipsis"
/>
</DataTemplate>
</
toolkit
:
ListPicker.ItemTemplate>
</
toolkit
:
ListPicker>
On remarque sur la deuxième image comme la page se déforme pour permettre le déroulement de la liste. Sur la troisième image, en déroulant la liste en bas du téléphone, il est nécessaire de scroller pour voir la liste qui s'affiche hors de l'écran.
Pour obtenir le visuel ci-dessus il faut également ajouter un style aux ListPicker, comme indiqué ici, mais je ne présenterai pas ce code, trop long et non essentiel à la compréhension.
En ajoutant de nouveaux aéroports dans le DataStore, il est également possible d'observer le passage en mode plein écran.
On remarquera enfin que si la liste Flights contient des destinations qui ne sont pas dans la liste EuropeanAirports, une exception sera levée à l'exécution. Ce comportement peut être limitant dans certains cas. Imaginons par exemple que l'aéroport de Madrid soit en rénovation. Le personnel ne serait plus autorisé à créer de nouveaux vols en partance ou à destination de cet aéroport, il ne figurera donc pas dans la liste déroulante. Par contre, les vols qui avaient déjà été prévus pour cette destination seraient tout de même accueillis grâce à un dispositif spécial : Madrid doit donc pouvoir apparaitre comme valeur sélectionnée sur ces vols. L'image ci-dessous illustre ce phénomène :
II. Créer notre propre ComboBox▲
Nous allons maintenant remplacer le ListPicker utilisé dans cet exemple par notre propre composant, qui pourra :
- s'afficher toujours par-dessus la page sans en modifier la disposition ;
- se repositionner pour ne pas dépasser de la page ;
- accepter des valeurs sélectionnées n'appartenant pas à la liste des valeurs sélectionnables ;
- permettre de définir le mode d'affichage des éléments soit de manière classique avec des DataTemplates, soit en spécifiant un simple Converter pour offrir une syntaxe plus concise ;
- accepter une valeur vide permettant d'annuler la sélection ;
- concaténer plusieurs sources de données dans le menu déroulant.
Le composant que nous allons réaliser permet d'obtenir le rendu suivant :
On remarquera que la liste déroulante n'est pas alignée sur le champ, mais qu'elle se centre par rapport à la page. C'est un comportement que je trouve appréciable dans le cas d'une utilisation sur mobile, puisque cela permet de tirer parti de tout l'espace disponible, tout en conservant un menu façon pop-up plutôt qu'une nouvelle page plein écran. Il serait possible de modifier ce comportement, nous en reparlerons dans la conclusion.
Nous allons suivre les étapes suivantes pour la réalisation du composant :
- Étape 1 : Création de la liste déroulante
- Étape 2 : Ajout des éléments à la ComboBox
- Étape 3 : Convertir les valeurs pour l'affichage
- Étape 4 : Gestion des événements
- Étape 5 : Ajout d'une valeur « vide »
- Étape 6 : Ajout de listes multiples
- Étape 7 : Personnaliser l'apparence de la ComboBox
II-A. Étape : Création de la liste déroulante▲
Nous utiliserons un UserControl pour créer notre ComboBox, qui sera composé :
- d'un ContentControl affichant la valeur active :
- d'un ContextMenu servant de liste déroulante.
L'intérêt du ContextMenu pour ce composant tient à ce qu'il s'affiche toujours au-dessus des éléments de la page, sans impacter leur disposition. C'est le menu qui apparait par exemple lorsque vous effectuez un clic long sur une application installée :
Le projet CombinedComboBox contiendra donc dans un premier temps deux fichiers : CombinedComboBox.xaml et CombinedComboBox.cs.
Le XAML de notre UserControl est assez simple :
<UserControl […]>
<ContentControl
x
:
Name
=
"LayoutRoot"
Tap
=
"OnRootTaped"
HorizontalContentAlignment
=
"Stretch"
VerticalContentAlignment
=
"Stretch"
Content
=
"{Binding SelectedItem}"
>
<
toolkit
:
ContextMenuService.ContextMenu>
<
toolkit
:
ContextMenu
x
:
Name
=
"ContextMenu"
IsZoomEnabled
=
"False"
ItemsSource
=
"{Binding Items}"
>
<
toolkit
:
ContextMenu.Template>
<ControlTemplate
TargetType
=
"toolkit:ContextMenu"
>
<Border
BorderThickness
=
"1"
BorderBrush
=
"DarkGray"
Background
=
"{Binding Background}"
MaxHeight
=
"500"
MaxWidth
=
"350"
>
<ScrollViewer>
<ItemsPresenter />
</ScrollViewer>
</Border>
</ControlTemplate>
</
toolkit
:
ContextMenu.Template>
<
toolkit
:
ContextMenu.ItemTemplate>
<DataTemplate>
<
toolkit
:
MenuItem
x
:
Name
=
"MenuItem"
Tap
=
"OnMenuItemTaped"
Padding
=
"0"
Header
=
"{Binding}"
/>
</DataTemplate>
</
toolkit
:
ContextMenu.ItemTemplate>
</
toolkit
:
ContextMenu>
</
toolkit
:
ContextMenuService.ContextMenu>
</ContentControl>
</UserControl>
La propriété « IsZoomEnable » du ContextMenu est mise à false pour éviter l'animation par défaut qui ne correspond pas au visuel attendu pour une ComboBox.
Nous souhaitons redéfinir le comportement par défaut du ContextMenu qui est d'attendre un clic long en le remplaçant par une ouverture immédiate au clic. Pour cela, nous utilisons l'événement Tap du TextBlock, géré par la méthode OnRootTaped, qui permet de dérouler la liste au simple clic.
Nous créons donc cette méthode dans le code-behind :
private
void
OnRootTaped
(
object
sender,
GestureEventArgs gestureEventArgs)
{
ContextMenu contextMenu =
ContextMenuService.
GetContextMenu
(
LayoutRoot);
if
(
contextMenu !=
null
)
{
contextMenu.
IsOpen =
true
;
}
}
Le XAML ci-dessus référence également la méthode « OnMenuItemTaped » pour gérer le clic sur un élément du menu. Nous la créons donc elle aussi dans le code-behind :
private
void
OnMenuItemTaped
(
object
sender,
RoutedEventArgs routedEventArgs)
{
var
b =
sender as
MenuItem;
if
(
b ==
null
) return
;
SelectedItem =
((
CombinedComboBoxPrintableItem)b.
DataContext).
Item;
[…]
ContextMenu.
IsOpen =
false
;
}
C'est cette méthode qui ferme le menu, et c'est également elle qui change l'élément sélectionné : le DataContext de l'élément « MenuItem » ayant propagé l'événement correspond à la valeur sélectionnée.
Pour pouvoir sélectionner un élément, il nous faut encore peupler notre ComboBox, et choisir la valeur initialement sélectionnée.
II-B. Étape : Ajout des éléments à la ComboBox▲
Pour ajouter des valeurs à la liste déroulante, nous voulons pouvoir utiliser un Binding directement depuis le XAML instanciant le composant, comme cela :
<
combinedComboBox
:
CombinedComboBox
SelectedItem
=
"{Binding Destination}"
Items
=
"{Binding EuropeanAirports}"
/>
En demandant une valeur pour le champ SelectedItem plutôt qu'un index de la liste Items, nous permettons à l'utilisateur de préciser une valeur qui n'apparait pas forcément dans la liste déroulante.
Les champs SelectedItem et Items sont tous les deux définis dans le code-behind comme des propriétés de dépendances :
public
object
SelectedItem
{
get
{
return
GetValue
(
SelectedItemProperty);
}
set
{
SetValue
(
SelectedItemProperty,
value
);
}
}
public
static
readonly
DependencyProperty SelectedItemProperty =
DependencyProperty.
Register
(
"SelectedItem"
,
typeof
(
object
),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
OnSelectionChanged)
);
public
IEnumerable Items
{
get
{
return
(
IEnumerable)GetValue
(
ItemsProperty);
}
set
{
SetValue
(
ItemsProperty,
value
);
}
}
public
static
readonly
DependencyProperty ItemsProperty =
DependencyProperty.
Register
(
"Items"
,
typeof
(
IEnumerable),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
OnItemsChanged)
);
Les propriétés de dépendances ont la particularité de déclencher des événements à chaque fois qu'elles sont modifiées. Pour créer une propriété de dépendance et la manipuler dans le code, il faut dans un premier temps définir un champ de type DependencyProperty (ItemsProperty ci-dessus), et l'enregistrer avec DependencyProperty.register, puis créer une propriété classique (Items) qui s'actualisera en même temps.
DependencyProperty.register accepte un paramètre de type PropertyMetadata. Ce paramètre permet de définir un handler pour réagir aux modifications de la propriété de dépendance. Il sera également invoqué si la modification est due à la mise à jour d'un Binding depuis le XAML, ce qui nous garantit de toujours être à jour par rapport aux valeurs fournies par l'utilisateur de notre ComboBox.
Dans le code ci-dessus, nous avons enregistré les méthodes OnSelectionChanged et OnItemsChanged. En remplaçant ces noms de méthode par null et en testant le code tel quel, nous obtenons l'affichage suivant :
Les valeurs affichées correspondent au retour d'un appel à la méthode ToString sur nos objets Airport, et nous avons donc sous les yeux les types de nos objets au lieu du nom des aéroports.
Nous allons devoir convertir les valeurs pour y remédier.
II-C. Étape : Convertir les valeurs pour l'affichage▲
Nous allons pour cela utiliser les méthodes OnSelectionChanged et OnItemsChanged dont nous avons parlé plus haut. Elles vont permettre d'appliquer une conversion aux différentes valeurs à chaque fois qu'elles seront modifiées.
Nous devons donc demander un Converter à l'utilisateur du composant (mais nous proposerons dans la suite une solution alternative en utilisant des DataTemplates):
public
IValueConverter ItemToStringConverter
{
get
{
return
(
IValueConverter)GetValue
(
ItemToStringConverterProperty);
}
set
{
SetValue
(
ItemToStringConverterProperty,
value
);
}
}
public
static
readonly
DependencyProperty ItemToStringConverterProperty =
DependencyProperty.
Register
(
"ItemToStringConverter"
,
typeof
(
IValueConverter),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
OnConverterChanged)
);
Nous ajoutons également un handler au changement de Converter, puisqu'il faudra également reconvertir les valeurs s'il est mis à jour.
C'est ensuite à l'utilisateur du composant de fournir le Converter dans le XAML instanciant le composant :
<
combinedComboBox
:
CombinedComboBox
SelectedItem
=
"{Binding Destination}"
Items
=
"{Binding EuropeanAirports }"
ItemToStringConverter
=
"{StaticResource AirportToStringConverter}"
/>
public
class
AirportToStringConverter :
IValueConverter
{
public
object
Convert
(
object
value
,
Type targetType,
object
parameter,
CultureInfo culture)
{
if
(
value
==
null
) return
null
;
var
airport =
(
Airport)value
;
if
(
airport.
Equals
(
DataStore.
Unknown))
return
"-"
;
return
airport.
Name;
}
public
object
ConvertBack
(
object
value
,
Type targetType,
object
parameter,
CultureInfo culture)
{
return
null
;
}
}
Le Converter étend la classe IValueConverter et doit donc implémenter Convert et ConvertBack. Nous nous contenterons dans notre cas d'implémenter la première, et laisserons la deuxième retourner null. Son utilité est de récupérer l'objet à affecter dans le cas d'un TwoWayBinding, mais nous verrons dans la suite que le Converter ne sert en réalité qu'à l'affichage, les objets d'origine sont toujours manipulés à l'arrière-plan.
Une fois ce Converter récupéré, nous savons obtenir le texte à afficher dans notre ComboBox. Il va maintenant falloir le stocker, puis effectuer les conversions aux bons moments.
Nous créons donc dans un premier temps une nouvelle classe dans le projet CombinedComboBox :
public
class
CombinedComboBoxPrintableItem
{
public
string
PrintableString {
get
;
set
;
}
public
object
Item {
get
;
set
;
}
public
CombinedComboBoxPrintableItem
(
object
item)
{
Item =
item;
PrintableString =
""
;
}
}
Et nous ajoutons deux propriétés de dépendance au code-behind de notre composant :
public
string
SelectedItemStringRepresentation
{
get
{
return
(
string
)GetValue
(
SelectedItemStringRepresentationItemProperty);
}
set
{
SetValue
(
SelectedItemStringRepresentationItemProperty,
value
);
}
}
public
static
readonly
DependencyProperty SelectedItemStringRepresentationItemProperty =
DependencyProperty.
Register
(
"SelectedItemStringRepresentation"
,
typeof
(
string
),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
public
List<
CombinedComboBoxPrintableItem>
PrintableItems
{
get
{
return
(
List<
CombinedComboBoxPrintableItem>
) GetValue
(
PrintableItemsProperty);
}
set
{
SetValue
(
PrintableItemsProperty,
value
);
}
}
public
static
readonly
DependencyProperty PrintableItemsProperty =
DependencyProperty.
Register
(
"PrintableItems"
,
typeof
(
List<
CombinedComboBoxPrintableItem>
),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
La première contiendra une simple chaîne de caractère, correspondant au résultat de la conversion de l'élément sélectionné, la deuxième contiendra pour chaque élément dans la liste déroulante un objet constitué à la fois de l'objet d‘origine et de sa représentation textuelle.
Il faut bien sûr actualiser le XAML associé pour prendre en compte les modifications :
<ContentControl […]
Content
=
"{Binding SelectedItemStringRepresentation}"
>
<
toolkit
:
ContextMenuService.ContextMenu>
<
toolkit
:
ContextMenu […]
ItemsSource
=
"{Binding printableItems}"
>
<
toolkit
:
ContextMenu.Template>
[…]
</
toolkit
:
ContextMenu.Template>
<
toolkit
:
ContextMenu.ItemTemplate>
<DataTemplate>
<
toolkit
:
MenuItem […]
Header
=
"{ Binding PrintableString }"
/>
</DataTemplate>
</
toolkit
:
ContextMenu.ItemTemplate>
</
toolkit
:
ContextMenu>
</
toolkit
:
ContextMenuService.ContextMenu>
</ContentControl>
Nous pouvons maintenant implémenter les callbacks sur les changements des propriétés Items, SelectedItem et ItemToStringConverter :
private
static
void
OnSelectionChanged
(
DependencyObject sender,
DependencyPropertyChangedEventArgs e)
{
var
combinedComboBox =
(
CombinedComboBox)sender;
[…]
combinedComboBox.
ConvertSelectedItem
(
);
}
private
static
void
OnItemsChanged
(
DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
OnMenuItemsConversionRequired
(
d,
e);
}
private
static
void
OnConverterChanged
(
DependencyObject sender,
DependencyPropertyChangedEventArgs e)
{
var
combinedComboBox =
(
CombinedComboBox)sender;
combinedComboBox.
ConvertMenuItems
(
);
combinedComboBox.
ConvertSelectedItem
(
);
}
Nous avons donc à partir de là trois nouvelles méthodes effectuant les conversions :
- ConvertSelectedItem va simplement définir la valeur du champ SelectedItemStringRepresentation en se servant du Converter. Si ce dernier n'a pas été fourni par l'utilisateur du composant, la méthode ToString de l'élément à afficher est appelée :
private
void
ConvertSelectedItem
(
)
{
SelectedItemStringRepresentation =
GetItemStringRepresentation
(
SelectedItem);
}
private
string
GetItemStringRepresentation
(
object
item)
{
if
(
ItemToStringConverter !=
null
)
{
return
(
string
)ItemToStringConverter.
Convert
(
item,
typeof
(
string
),
null
,
CultureInfo.
CurrentCulture
);
}
return
item.
ToString
(
);
}
- ConvertMenuItems peuplera la liste PrintableItems en effectuant les conversions pour chacun des éléments de la liste Items :
private
void
ConvertMenuItems
(
)
{
PrintableItems =
new
List<
CombinedComboBoxPrintableItem>(
);
if
(
Items !=
null
)
{
foreach
(
var
item in
Items)
{
PrintableItems.
Add
(
GetComboBoxPrintableItem
(
item));
}
}
}
private
CombinedComboBoxPrintableItem GetComboBoxPrintableItem
(
object
item)
{
return
new
CombinedComboBoxPrintableItem
(
item)
{
PrintableString =
GetItemStringRepresentation
(
item)
};
}
- la méthode OnMenuItemsConversionRequired aura un comportement plus complexe. Elle ne sera en effet appelée que lorsque la collection Items est remplacée, et nous aimerions qu'elle soit appelée à chaque fois qu'une valeur à l'intérieur de cette collection évolue. Pour que cela soit possible, il faudra que l'utilisateur fournisse la collection sous forme d'une ObservableCollection, ou d'une classe similaire implémentant l'interface INotifyCollectionChanged. Nous pouvons alors ajouter un handler sur les changements internes à la collection :
private
static
void
OnMenuItemsConversionRequired
(
DependencyObject sender,
DependencyPropertyChangedEventArgs e)
{
var
combinedComboBox =
(
CombinedComboBox)sender;
var
observableItems =
combinedComboBox.
Items as
INotifyCollectionChanged;
if
(
observableItems !=
null
)
{
observableItems.
CollectionChanged -=
combinedComboBox.
ItemCollectionChangedHandler;
observableItems.
CollectionChanged +=
combinedComboBox.
ItemCollectionChangedHandler;
}
combinedComboBox.
ConvertMenuItems
(
);
}
Pour éviter d'ajouter notre handler à plusieurs reprises, nous commençons par le supprimer. Nous utilisons pour cela le « -= », qui marcherait directement et simplement en passant un nom de méthode comme handler.
Nous ne pouvons toutefois pas utiliser cette solution, puisqu'une telle méthode aurait dû être statique, et que nous devons accéder à la ComboBox pour gérer l'événement. Nous devons donc utiliser un lambda, qui nous permettra d'accéder au paramètre « this ». Mais il n'est alors plus possible de supprimer notre handler avec le « -= » sans le mémoriser dans un premier temps. Nous devons donc créer un champ d'instance dans notre UserControl :
public
readonly
NotifyCollectionChangedEventHandler ItemCollectionChangedHandler;
Que nous initialisons dans le constructeur:
ItemCollectionChangedHandler =
(
eventSender,
eventArgs) =>
ItemCollectionChanged
(
this
);
Et finalement:
private
static
void
ItemCollectionChanged
(
CombinedComboBox combinedComboBox)
{
combinedComboBox.
ConvertMenuItems
(
);
}
Dernière remarque sur les différentes étapes de la conversion : reconvertir la valeur de SelectedItem à chaque changement de sélection peut sembler inutile puisque l'objet CombinedComboBoxPrintableItem, qui vient d'être sélectionné, contient déjà un champ PrintableString. Si la conversion est nécessaire dans le SelectionChanged, c'est en fait pour gérer la première valeur, qui n'est pas obtenue en sélectionnant un élément de la liste, et qui peut même ne pas correspondre à un élément existant dans la liste.
Les propriétés SelectedItemStringRepresentation et PrintableItems sur lesquels nous effectuons un Binding dans le XAML présenté plus haut sont donc maintenant accessibles, et actualisées à chaque fois que les collections d'origines ou la méthode de conversion changent. Le résultat à ce stade est le suivant :
II-D. Étape : Gestion des événements▲
Pour que l'utilisateur du composant puisse effectuer des actions complémentaires lorsque la ComboBox est manipulée, nous devons déclencher des événements et permettre l'ajout de handlers pour les gérer.
Nous allons proposer deux événements :
- SelectionChanged, qui se produira uniquement lorsque la valeur active de la ComboBox vient de changer pour une nouvelle valeur ;
- ItemTaped, qui signalera la sélection d'une entrée de la ComboBox, même s’il s'agit de l'entrée qui est déjà active.
Pour cela, il faut modifier le code-behind pour ajouter les événements en temps que champ d'instance :
public
event
EventHandler<
CombinedComboBoxChangeEventArgs>
SelectionChanged;
public
event
EventHandler<
CombinedComboBoxItemTapedEventArgs>
ItemTaped;
Nous créons également les classes de paramètres utilisées dans deux nouveaux fichiers ajoutés au projet CombinedComboBox :
public
class
CombinedComboBoxItemTapedEventArgs :
EventArgs
{
public
object
Item;
public
CombinedComboBoxItemTapedEventArgs
(
object
item)
{
Item =
item;
}
}
public
class
CombinedComboBoxChangeEventArgs :
EventArgs
{
public
object
OldValue;
public
object
NewValue;
public
CombinedComboBoxChangeEventArgs
(
object
oldValue,
object
newValue)
{
OldValue =
oldValue;
NewValue =
newValue;
}
}
Nous devons enfin déclencher les événements en réponse aux différentes actions. Pour cela nous modifions les méthodes OnSelectionChanged et OnMenuItemTaped présentées plus haut :
private
void
OnMenuItemTaped
(
object
sender,
RoutedEventArgs routedEventArgs)
{
var
b =
sender as
MenuItem;
if
(
b ==
null
) return
;
SelectedItem =
((
CombinedComboBoxPrintableItem)b.
DataContext).
Item;
if
(
ItemTaped !=
null
)
ItemTaped
(
this
,
new
CombinedComboBoxItemTapedEventArgs
(
SelectedItem));
ContextMenu.
IsOpen =
false
;
}
private
static
void
OnSelectionChanged
(
DependencyObject sender,
DependencyPropertyChangedEventArgs e)
{
var
combinedComboBox =
(
CombinedComboBox)sender;
if
((
e.
OldValue ==
null
||
!
e.
OldValue.
Equals
(
e.
NewValue))
&&
combinedComboBox.
SelectionChanged !=
null
)
{
combinedComboBox.
SelectionChanged
(
combinedComboBox,
new
CombinedComboBoxChangeEventArgs
(
e.
OldValue,
e.
NewValue)
);
}
combinedComboBox.
ConvertSelectedItem
(
);
}
III. Gérer les sources de données▲
III-A. Étape : Ajout d'une valeur « vide »▲
Un scénario fréquent dans l'utilisation d'une ComboBox est l'ajout d'une valeur par défaut « vide » permettant de ne rien sélectionner. Cette option se présente souvent sous la forme d'un tiret ‘-‘. Son ajout implique souvent de modifier directement la source de données remplissant la ComboBox. Il faut alors veiller dans toute la suite du traitement à ne pas prendre en compte cette valeur qui pourrait perturber le fonctionnement de l'application, en particulier s'il s'agit d'une valeur nulle. Cet article propose des solutions pour y parvenir.
Puisque nous développons notre propre ComboBox, nous allons pouvoir opter pour une solution plus simple, en ajoutant directement une propriété « EmptyValue » au composant :
public
object
EmptyValue
{
get
{
return
GetValue
(
EmptyValueProperty);
}
set
{
SetValue
(
EmptyValueProperty,
value
);
}
}
public
static
readonly
DependencyProperty EmptyValueProperty =
DependencyProperty.
Register
(
"EmptyValue"
,
typeof
(
object
),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
OnMenuItemsConversionRequired)
);
Puis dans la méthode ConvertMenuItems :
private
void
ConvertMenuItems
(
)
{
PrintableItems =
new
List<
CombinedComboBoxPrintableItem>(
);
// add the empty value at the beginin of the list
if
(
EmptyValue !=
null
)
{
PrintableItems.
Add
(
GetComboBoxPrintableItem
(
EmptyValue));
}
[…]
Ce qui dans notre exemple peut s'utiliser ainsi :
<
combinedComboBox
:
CombinedComboBox
SelectedItem
=
"{Binding Destination}"
Items
=
"{Binding ElementName=FlightList, Path =DataContext.EuropeanAirports}"
ItemToStringConverter
=
"{StaticResource CountryCaseConverter}"
EmptyValue
=
"{Binding Source={StaticResource DataStore},
Path =UnknownConstant}"
/>
L'affichage de cette valeur ne posera pas de problème puisque nous l'avons géré dans le code du Converter déjà présenté plus haut :
public
class
AirportToStringConverter :
IValueConverter
{
public
object
Convert
(
object
value
,
Type targetType,
object
parameter,
CultureInfo culture)
{
if
(
value
==
null
) return
null
;
var
airport =
(
Airport)value
;
if
(
airport.
Equals
(
DataStore.
Unknown))
return
"-"
;
return
airport.
Name;
}
public
object
ConvertBack
(
object
value
,
Type targetType,
object
parameter,
CultureInfo culture)
{
return
null
;
}
}
III-B. Étape : Ajout de listes multiples▲
Nous voudrions maintenant compléter le composant en permettant de concaténer plusieurs listes distinctes dans la ComboBox, d'où son nom, CombinedComboBox. Cette fonctionnalité nous permettra de proposer des éléments issus de la liste des aéroports français ET de la liste des autres aéroports européens sans devoir créer une liste contenant les deux dans le code gérant la logique (DataStore dans notre exemple). En WPF, les collections composites permettent de faire la même chose simplement, mais cette fonctionnalité n'existe pas pour Silverlight.
Nous allons procéder ainsi :
- ajout d'une propriété de dépendance pour accueillir une liste de collection ;
- ajout d'une méthode publique pour ajouter des collections à cette liste de collections ;
- pour chacune de ces nouvelles collections ajout d'un handler gérant leur modification ;
- mise à jour des méthodes de conversion pour prendre en compte les nouvelles collections.
La propriété de dépendance :
public
ObservableCollection<
IEnumerable>
ExtraItems
{
get
{
return
(
ObservableCollection<
IEnumerable>
)GetValue
(
ExtraItemsProperty);
}
set
{
SetValue
(
ExtraItemsProperty,
value
);
}
}
public
static
readonly
DependencyProperty ExtraItemsProperty =
DependencyProperty.
Register
(
"ExtraItems"
,
typeof
(
ObservableCollection<
IEnumerable>
),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
OnMenuItemsConversionRequired)
);
Créer une nouvelle collection plutôt que de modifier la liste Items permet de continuer à utiliser la liste simplement avec une seule source de donnée. Il aurait autrement été nécessaire de fournir une liste de liste dans tous les cas, ce qui compliquait le cas d'utilisation le plus fréquent.
Nous ajoutons ensuite la méthode pour l'ajout des collections supplémentaires :
public
void
AddExtraCollection
(
IEnumerable collection)
{
if
(
ExtraItems ==
null
)
ExtraItems =
new
ObservableCollection<
IEnumerable>(
);
ExtraItems.
Add
(
collection);
}
Il faut ensuite modifier le code de la méthode OnMenuItemsConversionRequired pour surveiller les changements sur les collections dans la collection ExtraItems :
private
static
void
OnMenuItemsConversionRequired
(
DependencyObject sender,
DependencyPropertyChangedEventArgs e)
{
var
combinedComboBox =
(
CombinedComboBox)sender;
var
observableItems =
combinedComboBox.
Items
as
INotifyCollectionChanged;
if
(
observableItems !=
null
)
{
observableItems.
CollectionChanged -=
combinedComboBox.
ItemCollectionChangedHandler;
observableItems.
CollectionChanged +=
combinedComboBox.
ItemCollectionChangedHandler;
}
if
(
combinedComboBox.
ExtraItems !=
null
)
{
combinedComboBox.
ExtraItems.
CollectionChanged -=
combinedComboBox.
ItemCollectionChangedHandler;
combinedComboBox.
ExtraItems.
CollectionChanged +=
combinedComboBox.
ItemCollectionChangedHandler;
foreach
(
var
collection in
combinedComboBox.
ExtraItems)
{
var
observableCollection =
collection as
INotifyCollectionChanged;
if
(
observableCollection !=
null
)
{
observableCollection.
CollectionChanged -=
combinedComboBox.
ItemCollectionChangedHandler;
observableCollection.
CollectionChanged +=
combinedComboBox.
ItemCollectionChangedHandler;
}
}
}
combinedComboBox.
ConvertMenuItems
(
);
}
Et finalement nous pouvons modifier la méthode ConvertMenuItems :
private
void
ConvertMenuItems
(
)
{
PrintableItems =
new
List<
CombinedComboBoxPrintableItem>(
);
// add the empty value at the beginning of the list
if
(
EmptyValue !=
null
)
{
PrintableItems.
Add
(
GetComboBoxPrintableItem
(
EmptyValue));
}
// add all the items list in order
if
(
Items !=
null
)
{
foreach
(
var
item in
Items)
{
PrintableItems.
Add
(
GetComboBoxPrintableItem
(
item));
}
}
// add all the items from the additional collections
if
(
ExtraItems !=
null
)
{
foreach
(
var
collection in
ExtraItems)
{
if
(
collection ==
null
) continue
;
foreach
(
var
item in
collection)
{
PrintableItems.
Add
(
GetComboBoxPrintableItem
(
item));
}
}
}
}
Nous pouvons donc maintenant ajouter des collections supplémentaires dans notre exemple, en passant par le code-behind de la classe FlightPresenter :
public
FlightPresenter
(
)
{
InitializeComponent
(
);
var
dataStore =
(
DataStore)Application.
Current.
Resources[
"DataStore"
];
LeavingComboBox.
AddExtraCollection
(
dataStore.
FrenchAirports);
DestinationComboBox.
AddExtraCollection
(
dataStore.
FrenchAirports);
}
<
combinedComboBox
:
CombinedComboBox
x
:
Name
=
"LeavingComboBox"
SelectedItem
=
"{Binding Leaving}"
Items
=
"{Binding Source={StaticResource DataStore}, Path=EuropeanAirports}"
ItemToStringConverter
=
"{StaticResource AirportToStringConverter}"
EmptyValue
=
"{Binding Source={StaticResource DataStore}, Path=UnknownConstant}"
Background
=
"GhostWhite"
Foreground
=
"Black"
FontSize
=
"26"
Margin
=
"10,5"
VerticalAlignment
=
"Center"
Grid.
Column
=
"2"
Grid.
Row
=
"0"
/>
Il serait également possible de ne plus utiliser Items dans le XAML, mais de passer directement les deux collections avec addExtraCollection. Nous pourrions de même passer la valeur de la liste Items depuis le code-behind. La seule contrainte est que les collections supplémentaires ne peuvent pas être ajoutées depuis le XAML, mais uniquement depuis le code-behind, avec la méthode addExtraCollection.
Nous savons donc maintenant afficher les aéroports français et ceux du reste de l'Europe dans la même liste, ainsi qu'une valeur vide :
III-C. Étape 7: Personnaliser l'apparence de la ComboBox▲
Nous voulons maintenant modifier l'apparence de notre ComboBox, et surtout, permettre à l'utilisateur de la personnaliser suivant ses besoins, qu'il s'agisse de modifier des couleurs de bordure, de fond… ou encore de définir un affichage plus complexe que ce que permet le Converter.
Nous allons pour cela jouer sur les différents templates des éléments constituant le UseControl. Nous avons en fait déjà manipulé ces templates, pour configurer un minimum l'affichage :
<ContentControl
x
:
Name
=
"LayoutRoot"
Tap
=
"OnRootTaped"
HorizontalContentAlignment
=
"Stretch"
VerticalContentAlignment
=
"Stretch"
Content
=
"{Binding SelectedItemStringRepresentation}"
>
<
toolkit
:
ContextMenuService.ContextMenu>
<
toolkit
:
ContextMenu
x
:
Name
=
"ContextMenu"
IsZoomEnabled
=
"False"
ItemsSource
=
"{Binding PrintableItems}"
>
<
toolkit
:
ContextMenu.Template>
<ControlTemplate
TargetType
=
"toolkit:ContextMenu"
>
<Border
BorderThickness
=
"1"
BorderBrush
=
"DarkGray"
Background
=
"{Binding Background}"
MaxHeight
=
"500"
MaxWidth
=
"350"
>
<ScrollViewer>
<ItemsPresenter />
</ScrollViewer>
</Border>
</ControlTemplate>
</
toolkit
:
ContextMenu.Template>
<
toolkit
:
ContextMenu.ItemTemplate>
<DataTemplate>
<
toolkit
:
MenuItem
x
:
Name
=
"MenuItem"
Tap
=
"OnMenuItemTaped"
Padding
=
"0"
Header
=
"{Binding PrintableString}"
/>
</DataTemplate>
</
toolkit
:
ContextMenu.ItemTemplate>
</
toolkit
:
ContextMenu>
</
toolkit
:
ContextMenuService.ContextMenu>
</ContentControl>
Deux templates sont définis ici : celui décrivant individuellement chaque élément du menu (ContextMenu.ItemTemplate), et celui décrivant l'apparence du menu dans son ensemble (ContextMenu.Template, utilisé ici pour rendre le menu scrollable et pour lui ajouter une bordure qui permet de mieux le voir).
Pour modifier les templates, nous allons procéder par étapes :
- modifier le XAML en faisant ressortir l'ensemble des éléments que nous souhaitons customiser ;
- ajouter pour chacun une propriété de dépendance permettant de définir son propre template ;
- définir des valeurs par défaut pour chaque template.
Le nouveau XAML pour le composant est donc le suivant :
<ContentControl
x
:
Name
=
"LayoutRoot"
Tap
=
"OnRootTaped"
HorizontalContentAlignment
=
"Stretch"
VerticalContentAlignment
=
"Stretch"
Content
=
"{Binding SelectedItem}"
ContentTemplate
=
"{Binding ActualSelectedItemTemplate}"
>
<
toolkit
:
ContextMenuService.ContextMenu>
<
toolkit
:
ContextMenu
x
:
Name
=
"ContextMenu"
IsZoomEnabled
=
"False"
ItemsSource
=
"{Binding PrintableItems}"
FontSize
=
"{Binding FontSize}"
Foreground
=
"{Binding Foreground}"
Template
=
"{Binding MenuTemplate}"
ItemContainerStyle
=
"{Binding MenuItemContainerStyle}"
>
<
toolkit
:
ContextMenu.ItemTemplate>
<DataTemplate>
<
toolkit
:
MenuItem
x
:
Name
=
"MenuItem"
Tap
=
"OnMenuItemTaped"
Padding
=
"0"
Header
=
"{Binding Item}"
HeaderTemplate
=
"{Binding ElementName=ContextMenu, Path=DataContext.MenuItemTemplate}"
/>
</DataTemplate>
</
toolkit
:
ContextMenu.ItemTemplate>
</
toolkit
:
ContextMenu>
</
toolkit
:
ContextMenuService.ContextMenu>
</ContentControl>
Nous y retrouvons cinq templates différents :
- le ContentTemplate du ContentControl définit la façon dont l'élément sélectionné est affiché, il s'agit d'un DataTemplate ;
- le template du menu a été présenté plus haut, il s'agit d'un ControlTemplate ;
- l'ItemContainerStyle du menu permet de jouer sur certains paramètres d'affichage des éléments du menu, c'est un style ;
- l'ItemTemplate décrit la façon donc chaque élément du menu sera affiché. C'est un DataTemplate que nous ne permettrons pas à l'utilisateur de redéfinir puisque nous voulons ajouter le handler OnMenuItemTaped ;
- le HeaderTemplate des MenuItem est un DataTemplate qui permettra à l'utilisateur de personnaliser l'affichage des entrées du menu aussi finement que l'aurait permis l'ItemTemplate. On notera que comme il se trouve à l'intérieur d'un DataTemplate son DataContext n'est plus le même que celui du reste du composant, et il faut utiliser ElementName dans notre Binding pour pouvoir référencer à nouveau les propriétés du code-behind.
Pour chaque style ou template que nous voulons permettre à l'utilisateur de modifier, nous créons donc une propriété de dépendance, sauf pour l'élément sélectionné. Il nécessite en effet l'ajout de trois propriétés, puisque c'est son aspect visuel qui permettra à l'utilisateur de savoir si le composant est activé ou pas.
Il faudra donc :
- un template pour le composant actif ;
- un template pour le composant désactivé (disabled) ;
- un template correspondant à celui en cours d'utilisation parmi les deux précédents.
Voici donc le code associé :
public
ControlTemplate MenuTemplate
{
get
{
return
(
ControlTemplate)GetValue
(
MenuTemplateProperty);
}
set
{
SetValue
(
MenuTemplateProperty,
value
);
}
}
public
static
readonly
DependencyProperty MenuTemplateProperty =
DependencyProperty.
Register
(
"MenuTemplate"
,
typeof
(
ControlTemplate),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
public
DataTemplate MenuItemTemplate
{
get
{
return
(
DataTemplate)GetValue
(
MenuItemTemplateProperty);
}
set
{
SetValue
(
MenuItemTemplateProperty,
value
);
}
}
public
static
readonly
DependencyProperty MenuItemTemplateProperty =
DependencyProperty.
Register
(
"MenuItemTemplate"
,
typeof
(
DataTemplate),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
public
DataTemplate SelectedItemTemplate
{
get
{
return
(
DataTemplate)GetValue
(
SelectedItemTemplateProperty);
}
set
{
SetValue
(
SelectedItemTemplateProperty,
value
);
}
}
public
static
readonly
DependencyProperty SelectedItemTemplateProperty =
DependencyProperty.
Register
(
"SelectedItemTemplate"
,
typeof
(
DataTemplate),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
protected
DataTemplate ActualSelectedItemTemplate
{
get
{
return
(
DataTemplate)GetValue
(
ActualSelectedItemTemplateProperty);
}
set
{
SetValue
(
ActualSelectedItemTemplateProperty,
value
);
}
}
public
static
readonly
DependencyProperty ActualSelectedItemTemplateProperty =
DependencyProperty.
Register
(
"ActualSelectedItemTemplate"
,
typeof
(
DataTemplate),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
public
DataTemplate DisabledSelectedItemTemplate
{
get
{
return
(
DataTemplate)GetValue
(
DisabledSelectedItemTemplateProperty);
}
set
{
SetValue
(
DisabledSelectedItemTemplateProperty,
value
);
}
}
public
static
readonly
DependencyProperty DisabledSelectedItemTemplateProperty =
DependencyProperty.
Register
(
"DisabledSelectedItemTemplateProperty"
,
typeof
(
DataTemplate),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
public
Style MenuItemContainerStyle
{
get
{
return
(
Style)GetValue
(
MenuItemContainerStyleProperty);
}
set
{
SetValue
(
MenuItemContainerStyleProperty,
value
);
}
}
public
static
readonly
DependencyProperty MenuItemContainerStyleProperty =
DependencyProperty.
Register
(
"MenuItemContainerStyle"
,
typeof
(
Style),
typeof
(
CombinedComboBox),
new
PropertyMetadata
(
null
)
);
Et le code permettant de mettre à jour la valeur du la propriété ActualSelectedItemTemplate suivant l'état du composant :
private
void
MonitorIsEnabledChange
(
)
{
// first set the appropriate template
OnIsEnabledChanged
(
);
// track change to update the template
IsEnabledChanged +=
(
sender,
e) =>
OnIsEnabledChanged
(
);
}
private
void
OnIsEnabledChanged
(
)
{
if
(
IsEnabled)
ActualSelectedItemTemplate =
SelectedItemTemplate;
else
ActualSelectedItemTemplate =
DisabledSelectedItemTemplate;
}
public
CombinedComboBox
(
)
{
InitializeComponent
(
);
LayoutRoot.
DataContext =
this
;
Loaded +=
(
sender,
e) =>
MonitorIsEnabledChange
(
);
ItemCollectionChangedHandler =
(
eventSender,
eventArgs) =>
ItemCollectionChanged
(
this
);
}
Il ne nous reste plus qu'à créer les styles et templates par défaut dans les ressources de notre UserControl :
<UserControl.Resources>
<ResourceDictionary>
<ControlTemplate
x
:
Key
=
"DefaultMenuTemplate"
TargetType
=
"toolkit:ContextMenu"
>
<Border
BorderThickness
=
"1"
BorderBrush
=
"DarkGray"
Background
=
"{Binding Background}"
MaxHeight
=
"500"
MaxWidth
=
"350"
>
<ScrollViewer>
<ItemsPresenter />
</ScrollViewer>
</Border>
</ControlTemplate>
<DataTemplate
x
:
Key
=
"DefaultMenuItemTemplate"
>
<TextBlock
Text
=
"{Binding ElementName=MenuItem, Path=DataContext.PrintableString}"
FontSize
=
"{Binding ElementName=ContextMenu, Path=FontSize}"
Foreground
=
"{Binding ElementName=ContextMenu, Path=Foreground}"
Margin
=
"0,5"
TextAlignment
=
"Center"
TextTrimming
=
"WordEllipsis"
/>
</DataTemplate>
<DataTemplate
x
:
Key
=
"DefaultSelectedItemTemplate"
>
<Grid>
<Path
Stroke
=
"{Binding ElementName=CombinedComboBoxUserControl, Path=Foreground}"
StrokeThickness
=
"1"
Fill
=
"{Binding ElementName=CombinedComboBoxUserControl, Path=Foreground}"
HorizontalAlignment
=
"Right"
VerticalAlignment
=
"Bottom"
>
<Path.Data>
<PathGeometry>
<PathGeometry.Figures>
<PathFigureCollection>
<PathFigure
IsClosed
=
"True"
StartPoint
=
"0,10"
>
<PathFigure.Segments>
<PathSegmentCollection>
<LineSegment
Point
=
"10,10"
/>
<LineSegment
Point
=
"10,0"
/>
</PathSegmentCollection>
</PathFigure.Segments>
</PathFigure>
</PathFigureCollection>
</PathGeometry.Figures>
</PathGeometry>
</Path.Data>
</Path>
<Border
x
:
Name
=
"SelectedItemBorder"
BorderThickness
=
"1"
BorderBrush
=
"{ Binding ElementName=CombinedComboBoxUserControl, Path=Foreground}"
>
<TextBlock
TextAlignment
=
"Center"
Text
=
"{ Binding ElementName=LayoutRoot, Path=DataContext.SelectedItemStringRepresentation}"
/>
</Border>
</Grid>
</DataTemplate>
<DataTemplate
x
:
Key
=
"DefaultDisabledSelectedItemTemplate"
>
<TextBlock
TextAlignment
=
"Center"
Text
=
"{ Binding ElementName=LayoutRoot, Path=DataContext.SelectedItemStringRepresentation}"
/>
</DataTemplate>
<Style
x
:
Key
=
"DefaultMenuItemContainerStyle"
TargetType
=
"toolkit:MenuItem"
>
<Setter
Property
=
"Margin"
Value
=
"0"
/>
<Setter
Property
=
"Padding"
Value
=
"0"
/>
</Style>
</ResourceDictionary>
</UserControl.Resources>
Et à les appeler dans le constructeur du composant :
public
CombinedComboBox
(
)
{
InitializeComponent
(
);
LayoutRoot.
DataContext =
this
;
// load default styles and templates
MenuTemplate =
(
ControlTemplate)Resources[
"DefaultMenuTemplate"
];
MenuItemTemplate =
(
DataTemplate)Resources[
"DefaultMenuItemTemplate"
];
SelectedItemTemplate =
(
DataTemplate)Resources[
"DefaultSelectedItemTemplate"
];
DisabledSelectedItemTemplate =
(
DataTemplate)Resources[
"DefaultDisabledSelectedItemTemplate"
];
MenuItemContainerStyle =
(
Style)Resources[
"DefaultMenuItemContainerStyle"
];
Loaded +=
(
sender,
e) =>
MonitorIsEnabledChange
(
);
ItemCollectionChangedHandler =
(
eventSender,
eventArgs) =>
ItemCollectionChanged
(
this
);
}
Voilà à quoi ressemble maintenant notre ComboBox :
Pour terminer, voici un exemple de customisation par l'utilisateur du composant, pour ajouter des couleurs et le nombre de pistes de chaque aéroport :
<
combinedComboBox
:
CombinedComboBox
x
:
Name
=
"DestinationComboBox"
SelectedItem
=
"{Binding Destination}"
Items
=
"{Binding Source={StaticResource DataStore}, Path=EuropeanAirports}"
ItemToStringConverter
=
"{StaticResource AirportToStringConverter}"
EmptyValue
=
"{Binding Source={StaticResource DataStore}, Path=UnknownConstant}"
Background
=
"GhostWhite"
Foreground
=
"Black"
FontSize
=
"26"
Margin
=
"10,5"
VerticalAlignment
=
"Center"
Grid.
Column
=
"2"
Grid.
Row
=
"1"
>
<
combinedComboBox
:
CombinedComboBox.MenuItemTemplate>
<DataTemplate>
<Border
Background
=
"BurlyWood"
BorderThickness
=
"0,0,0,3"
BorderBrush
=
"Brown"
Padding
=
"3,5,3,5"
>
<StackPanel>
<TextBlock
Text
=
"{Binding Converter={StaticResource AirportToStringConverter}}"
FontSize
=
"26"
TextAlignment
=
"Left"
TextTrimming
=
"WordEllipsis"
/>
<TextBlock
FontSize
=
"20"
TextAlignment
=
"Right"
>
<Run
Text
=
"Runway: "
/>
<Run
Text
=
"{ Binding RunwayCount }"
/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</
combinedComboBox
:
CombinedComboBox.MenuItemTemplate>
<
combinedComboBox
:
CombinedComboBox.MenuTemplate>
<ControlTemplate
TargetType
=
"toolkit:ContextMenu"
>
<Border
BorderThickness
=
"3"
BorderBrush
=
"Brown"
MaxHeight
=
"500"
MaxWidth
=
"350"
>
<ScrollViewer>
<ItemsPresenter />
</ScrollViewer>
</Border>
</ControlTemplate>
</
combinedComboBox
:
CombinedComboBox.MenuTemplate>
</
combinedComboBox
:
CombinedComboBox>
Et le résultat:
IV. Conclusion▲
Nous avons donc créé tout au long de cet article le composant qu'il nous manquait. Nous avons également pu en profiter pour explorer différents aspects intéressants des UserControl et des propriétés de dépendances.
Notre ComboBox peut évidemment être perfectionnée. L'amélioration qui me semble la plus intéressante serait le positionnement du menu par rapport à l'emplacement de la ComboBox :
Il est possible de forcer l'offset du menu, et une formule permettant de calculer la position optimale pour donner l'impression d'un déroulement de la liste sans dépassement de l'écran serait intéressante.
Et pourquoi pas exposer ensuite une propriété de dépendance pour choisir entre les différents modes de positionnement possibles ? À suivre…