Recréons la ComboBox pour Windows Phone

| AViSTO | LABORIE |

En effectuant le portage d'une application Android vers Windows Phone, je me suis heurté à une difficulté à laquelle je ne m'attendais vraiment pas : l'absence d'équivalent au Spinner d'Android ou à la ComboBox de Silverlight. Je me suis donc lancé dans la création de mon propre composant graphique, et ce travail a été un très bon prétexte pour explorer plus à fond les possibilités offertes par Silverlight, et plus précisément le principe du DataBinding.

Cet article va donc retracer étape par étape ce développement, en présentant à chaque fois les mécanismes utilisés.

N'hésitez pas à commenter cet article sur le forum : Commentez ♪

- Titre : Recréons la ComboBox pour Windows Phone

- Auteurs : AViSTO, LABORIE

- Parution : 25 juin 2014

- Licence : Le contenu de cet article est rédigé par AViSTO et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Sommaire

- I. Contexte et objectifs

- I-A. L'application de test

- I-B. Structure de l'application

- I-C. Résultat avec le ListPicker

- II. Créer notre propre ComboBox

- II-A. Étape : Création de la liste déroulante

- II-B. Étape : Ajout des éléments à la ComboBox

- II-C. Étape : Convertir les valeurs pour l'affichage

- II-D. Étape : Gestion des événements

- III. Gérer les sources de données

- III-A. Étape : Ajout d'une valeur « vide »

- III-B. Étape : Ajout de listes multiples

- III-C. Étape 7: Personnaliser l'apparence de la ComboBox

- IV. Conclusion

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 :

DataScore.cs

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 :

MainPage.xaml

<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 :

DataScore.cs

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 :

FlightPresenter.xaml

<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 :

FlightPresenter.cs

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 :

FlightPresenter.xaml

<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 :

CombinedCombobox.xaml

<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 :

CombinedCombobox.xaml.cs

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 :

CombinedCombobox.xaml.cs

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 :

FlightPresenter.xaml

<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 :

CombinedComboBox.xaml.cs

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):

CombinedComboBox.xaml.cs

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 :

FlightPresenter.xaml

<combinedComboBox:CombinedComboBox
SelectedItem="{Binding Destination}"
Items="{Binding EuropeanAirports }"
ItemToStringConverter="{StaticResource AirportToStringConverter}"/>

AirportToStringConverter.cs

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 :

CombinedComboBoxPrintableItem.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml

<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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

public readonly NotifyCollectionChangedEventHandler ItemCollectionChangedHandler;

Que nous initialisons dans le constructeur:

CombinedCombBox.xaml.cs

ItemCollectionChangedHandler = (eventSender, eventArgs) => ItemCollectionChanged(this);

Et finalement:

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBoxItemTapedEventArgs.cs

public class CombinedComboBoxItemTapedEventArgs : EventArgs
{
public object Item;
public CombinedComboBoxItemTapedEventArgs(object item)
{
Item = item;
}
}

CombinedComboBoxChangeEventArgs.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

FlightPresenter.xaml

<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 :

AirportToStringConverter.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

FlightPresenter.xaml.cs

public FlightPresenter()
{
InitializeComponent();
var dataStore = (DataStore)Application.Current.Resources["DataStore"];
LeavingComboBox.AddExtraCollection(dataStore.FrenchAirports);
DestinationComboBox.AddExtraCollection(dataStore.FrenchAirports);
}

FlightPresenter.xaml

<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 :

CombinedComboBox.xaml

<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 :

CombinedComboBox.xaml

<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é :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml.cs

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 :

CombinedComboBox.xaml

<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 :

CombinedComboBox.xaml.cs

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 :

FlightPresenter.xaml

<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…