Manish Dalal's blog

Exploring .net!

Cascading ComboBox

In the post ComboBox in DataGrid, we examined the usage of a simple ComboBox, displaying a fixed list of values. One of the scenario that is quite common in business applications is that of a dependent data selection, where data from one item is used to narrow down selection choices for the next item; also referred as cascading or hierarchical selection. In a cascading scenario, list of values is variable, changing based on some other data.

image

We will use a Car model to showcase Cascading ComboBox behavior. For our example, Car will be classified as consisting of Make, Model and Color. Make determines Model and Model determines Color.

When user selects a Car’s Make, it will be used to narrow down available Models. Once a model is selected, it will be used it to filter out available colors to select from. CarMake - CarModel

Create a new SilverlightApplication and add the corresponding Web Application to host and test the Silverlight application.

We will use CarMake and CarModel classes to represent Make and Model respectively. To keep things simple we will just use two properties, Id to identify (primary key) and Name for description.

Each of the class also overrides Equals to customize equality comparison.

Next add the class Car with MakeId, ModelId and Color properties. Also implement INotifyPropertyChanged for property change notification.

public class Car : INotifyPropertyChanged {

    private int _makeId;
    public int MakeId {
        get { return _makeId; }
        set {
            if (_makeId == value) return;
            _makeId = value;
            RaisePropertyChanged("MakeId");
        }
    }

    private int _modelId;
    public int ModelId {
        get { return _modelId; }
        set {
            if (_modelId == value) return;
            _modelId = value;
            RaisePropertyChanged("ModelId");
        }
    }

    private string _color;
    public string Color {
        get { return _color; }
        set {
            if (_color == value) return;
            _color = value;
            if (value == null) {
                _color = EmptyFactory.GetEmptyColor(_modelInfo);
            }
            RaisePropertyChanged("Color");
        }
    }

    private CarMake _makeInfo;
    public CarMake MakeInfo {
        get { 
            return _makeInfo; 
        }
        set {
            _makeInfo = value;
            if (null != _makeInfo) {
                MakeId = _makeInfo.MakeId;
            } else {
                _makeInfo = EmptyFactory.GetEmptyCarMake();
                MakeId = _makeInfo.MakeId;
            }
            RaisePropertyChanged("MakeInfo");
            ModelInfo = EmptyFactory.GetEmptyCarModel(_makeInfo);

        }
    }


    private CarModel _modelInfo;
    public CarModel ModelInfo {
        get {
            return _modelInfo; 
        }
        set {
            if (value == _modelInfo) { return; }
            _modelInfo = value;
            if (null != _modelInfo) {
                ModelId = _modelInfo.ModelId;
            } else {
                _modelInfo = EmptyFactory.GetEmptyCarModel(_makeInfo);
                ModelId = _modelInfo.ModelId;
            }
            RaisePropertyChanged("ModelInfo");
            Color = EmptyFactory.GetEmptyColor(_modelInfo);
        }
    }

    #region INotifyPropertyChanged Members

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string propertyName) {
        if (null != PropertyChanged) {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    public void RaisePropertyChanged(string propertyName) {
        //OnPropertyChanged(propertyName);
        if (null != Application.Current.RootVisual) {
            Application.Current.RootVisual.Dispatcher.BeginInvoke(() =>
            OnPropertyChanged(propertyName));
        }
    }
    #endregion
}

Note that we have also added MakeInfo and ModelInfo helper methods for data binding to ComboBox’s SelectedItem property. This allows us to show user friendly name and save Id, as I have already outlined in the previous ComboBox post. image

We will use a helper factory to create the empty car and other related classes. EmptyFactory provides methods to create empty instances of Car, CarMake, CarModel, and Color. Alternatively you can always have each class provide corresponding static create methods, but keeping it in external factory class allows us to reduce interdependency and complexity in the model classes.

imageNext we add the class to provide data for various lists, CarListProvider. It includes methods that provide various lists for car, namely MakeList, ModelList and ColorList.

In order to provide the proper model list, we need to know which car make user has selected. Since Car is already providing change notification for MakeInfo, we just subscribe to Car’s PropertyChanged event and look for changes to MakeInfo. When that occurs, we use MakeId to provide proper list of Models.

Similarly we listen for changes to ModelInfo. When user selects a Model from dropdown, it triggers Car’s ModelInfo change notification, which enables us to provide appropriate Color list for the selected Model.

Add the class to provide list data, CarListProvider.cs as shown:

public class CarListProvider : INotifyPropertyChanged {

    Car _car;
    public CarListProvider(Car car) {
        _car = car;
        _car.PropertyChanged += new System.ComponentModel.PropertyChangedEventHandler(_car_PropertyChanged);
    }

    void _car_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) {
        switch (e.PropertyName) {
            case "MakeInfo":
                RaisePropertyChanged("ModelList");
                break;
            case "ModelInfo":
                RaisePropertyChanged("ColorList");
                break;
            default:
                break;
        }
    }

    public List<CarMake> MakeList {
        get {
            return new List<CarMake> { EmptyFactory.GetEmptyCarMake(),
                                    new CarMake() { MakeId=100, MakeName="Toyota (100)"},  
                                    new CarMake() { MakeId=200, MakeName="Honda (200)"},   
                                    new CarMake() { MakeId=300, MakeName="Nissan (300)"}};
        }
    }

    public ObservableCollection<CarModel> ModelList {
        get {
            RaisePropertyChanged("IsModelListEnabled");

            int makeId = _car.MakeId;
            if (-1 == makeId) {
                return new ObservableCollection<CarModel>() { EmptyFactory.GetEmptyCarModel()};
            }

            return new ObservableCollection<CarModel> { 
                                EmptyFactory.GetEmptyCarModel(_car.MakeInfo),   
                                new CarModel() { ModelId=makeId + 1, ModelName=string.Format("Model{0}",makeId + 1)},  
                                new CarModel() { ModelId=makeId + 2, ModelName=string.Format("Model{0}",makeId + 2)},   
                                new CarModel() { ModelId=makeId + 3, ModelName=string.Format("Model{0}",makeId + 3)},  
                                new CarModel() { ModelId=makeId + 4, ModelName=string.Format("Model{0}",makeId + 4)}};

        }
    }

    public ObservableCollection<string> ColorList {
        get {
            RaisePropertyChanged("IsColorListEnabled");

            int modelId = _car.ModelId;
            if (-1 == modelId) {
                return new ObservableCollection<string>() { EmptyFactory.GetEmptyColor() };
            }
            return new ObservableCollection<string> { EmptyFactory.GetEmptyColor(_car.ModelInfo), 
                                                        string.Format("Red{0}", modelId), 
                                                       string.Format("White{0}", modelId), 
                                                       string.Format("Blue{0}", modelId), 
                                                       string.Format("Green{0}", modelId), 
                                                    };
        }
    }

    public bool IsColorListEnabled {
        get {
            return (-1 != _car.ModelId);
        }
    }

    public bool IsModelListEnabled {
        get {
            return (-1 != _car.MakeId);
        }
    }

    #region INotifyPropertyChanged Members

    public event PropertyChangedEventHandler PropertyChanged;

    protected void OnPropertyChanged(string propertyName) {
        if (null != PropertyChanged) {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    public void RaisePropertyChanged(string propertyName) {
        //OnPropertyChanged(propertyName);
        if (null != Application.Current.RootVisual) {
            Application.Current.RootVisual.Dispatcher.BeginInvoke(() =>
            OnPropertyChanged(propertyName));
        }

    }
    #endregion
}

Note that we are using a dictionary to cache list of models for a make. If no model list is found, we create new model list, add it to cache and return the model list. In a production application,  you will make service call to get data from the server.

In addition to providing lists for make, model, and color, CarListProvider also exposes two properties, IsModelListEnabled and IsColorListEnabled. These properties are used to enable/disable ComboBox when user has pending parent selection. Alternatively you can just default selection to the first item in the list and always keep ComboBox enabled.

We will also add CarListProvider to Car and expose it for data binding. Add following code to class Car:

public Car() {
    _carListProvider = new CarListProvider(this);
}

public Car(CarMake makeInfo, CarModel modelInfo, string color) : this(){
    _makeInfo = makeInfo;
    _makeId = _makeInfo.MakeId;
    _modelInfo = modelInfo;
    _modelId = _modelInfo.ModelId;
    _color = color;
}

private CarListProvider _carListProvider;
public CarListProvider CarListProvider {
    get { return _carListProvider; }
    set { _carListProvider = value; }
}

Encapsulation of CarListProvider in the Car for this single record scenario is not a requirement, you can also create CarListProvider in resource (as show here) and later set Car as property on it. However it will be a requirement when we go for the multi record scenario later.

To test the working of Cascading ComboBox, add following code to Page.xaml

<UserControl x:Class="SilverlightApplication.Page"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
    xmlns:src="clr-namespace:SilverlightApplication"
    Width="200" Height="200">
    <Grid x:Name="LayoutRoot" Background="White" Margin="5">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <TextBlock Text="Make:"/>
        <ComboBox ItemsSource="{Binding Path=CarListProvider.MakeList}"
                  DisplayMemberPath="MakeName"
                  Grid.Column="1"
                  SelectedItem="{Binding Path=MakeInfo, Mode=TwoWay}"
                  x:Name="makeComboBox"
                  />
        <TextBlock Grid.Row="1" Text="Model:"/>
        <ComboBox ItemsSource="{Binding Path=CarListProvider.ModelList}"
                  DisplayMemberPath="ModelName"
                  Grid.Column="1"
                  Grid.Row="1"
                  SelectedItem="{Binding Path=ModelInfo, Mode=TwoWay}"
                  IsEnabled="{Binding Path=CarListProvider.IsModelListEnabled}"
                  />
        <TextBlock Grid.Row="2" Text="Color:"/>
        <ComboBox ItemsSource="{Binding Path=CarListProvider.ColorList}"
                  Grid.Column="1"
                  Grid.Row="2"
                  SelectedItem="{Binding Path=Color, Mode=TwoWay}"
                  IsEnabled="{Binding Path=CarListProvider.IsColorListEnabled}"
                  />
    </Grid>
</UserControl>

We are data binding ItemsSource of each ComboBox to corresponding list properties of the CarListProvider. This allows us to update ItemsSource and provide new list data whenever user makes changes to parent selection. Notice that for Make and Model ComboBox, we are also setting DisplayMemberPath to underlying classes Name methods. SelectedItem is bound to Car’s helper properties, that behind the scene/internally set Id properties. Finally IsEnabled property allows us to disable ComboBox when there is pending parent selection.

Add following code to Page.xaml.cs code behind

public partial class Page : UserControl {
    public Page() {
        InitializeComponent();
        //
        this.DataContext = EmptyFactory.GetEmptyCar();
        //this.DataContext = new Car(CarMake.GetEmpty(),CarModel.GetEmpty(),"Please select a color");
    }
}

F5 and test the application.

image  image   image   image

When use selects a Make, Model list only show appropriate Models for the selected Make. Change Make and notice that Model list is also changed. Whenever Model changes, Color list is also changed.

Cascading ComboBox in DataGrid

So far we have seen how to model Cascading ComboBox behavior in a simple one record FormView kind of scenario. However when you want to show Cascading ComboBox in a DataGrid, we have to deal with multiple rows.

imageEach row represents a Car and user can select different make, model and color for each Car. Traditionally CarListProvider will be separate from Car, perhaps created in a control resource. However this does not work for the multi row scenarios. Each row needs to maintain its own state, so that changes in one do not affect other.

This can be achieved by scoping CarListProvider at the Car level. This is the reason we created and exposed CarListProvider from the Car class earlier. It is optional in single record scenarios, but a requirement for the multi record mode.

In a production application, to prevent multiple calls back to server, CarListProvider should be backed up by a client side service wrapper class, that all CarListProviders get data from. This class should cache the list data and reduce redundant roundtrips to the server.

Change Page.xaml as shown.

<UserControl x:Class="SilverlightApplication.Page"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
    xmlns:data="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data"
    xmlns:src="clr-namespace:SilverlightApplication"
    >
    <Grid x:Name="LayoutRoot" Background="White">
        <data:DataGrid AutoGenerateColumns="False" ItemsSource="{Binding}" Margin="10" ColumnWidth="100">
            <data:DataGrid.Columns>
                <data:DataGridTemplateColumn Header="Make" SortMemberPath="MakeInfo.MakeName" >
                    <data:DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding MakeInfo.MakeName}" />
                        </DataTemplate>
                    </data:DataGridTemplateColumn.CellTemplate>
                    <data:DataGridTemplateColumn.CellEditingTemplate>
                        <DataTemplate>
                            <ComboBox ItemsSource="{Binding CarListProvider.MakeList}" 
                                      SelectedItem="{Binding MakeInfo, Mode=TwoWay}" 
                                      DisplayMemberPath="MakeName"
                                      src:ComboBoxService.ForceOpen="True"/>
                        </DataTemplate>
                    </data:DataGridTemplateColumn.CellEditingTemplate>
                </data:DataGridTemplateColumn>
                <data:DataGridTemplateColumn Header="Model"  SortMemberPath="ModelInfo.ModelName">
                    <data:DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding ModelInfo.ModelName}" />
                        </DataTemplate>
                    </data:DataGridTemplateColumn.CellTemplate>
                    <data:DataGridTemplateColumn.CellEditingTemplate>
                        <DataTemplate>
                            <ComboBox ItemsSource="{Binding CarListProvider.ModelList}" 
                                      DisplayMemberPath="ModelName"
                                      src:ComboBoxService.ForceOpen="true"
                                      SelectedItem="{Binding ModelInfo, Mode=TwoWay}" 
                                      IsEnabled="{Binding Path=CarListProvider.IsModelListEnabled}"/>
                        </DataTemplate>
                    </data:DataGridTemplateColumn.CellEditingTemplate>
                </data:DataGridTemplateColumn>
                <data:DataGridTemplateColumn Header="Color" SortMemberPath="Color" >
                    <data:DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding Color}"/>
                        </DataTemplate>
                    </data:DataGridTemplateColumn.CellTemplate>
                    <data:DataGridTemplateColumn.CellEditingTemplate>
                        <DataTemplate>
                            <ComboBox ItemsSource="{Binding CarListProvider.ColorList}" 
                                      src:ComboBoxService.ForceOpen="true"
                                      SelectedItem="{Binding Color, Mode=TwoWay}" 
                                      IsEnabled="{Binding Path=CarListProvider.IsColorListEnabled}"/>
                        </DataTemplate>
                    </data:DataGridTemplateColumn.CellEditingTemplate>
                </data:DataGridTemplateColumn>
            </data:DataGrid.Columns>
        </data:DataGrid>
    </Grid>
</UserControl>

Change page.xaml.cs

ObservableCollection<Car> _cars;
public Page() {
    InitializeComponent();
    //
    _cars = new ObservableCollection<Car>() {
        new Car(
            new CarMake { MakeId = 100, MakeName = "Toyota (100)" },
            new CarModel { ModelId = 101, ModelName = "Model101" },
            "Red101"
        ),
        new Car(
            new CarMake { MakeId = 300, MakeName = "Nissan (300)" },
            new CarModel { ModelId = 302, ModelName = "Model302" },
            "White302"
        ),
        EmptyFactory.GetEmptyCar()
    };
    this.DataContext = _cars;
}   

F5 and test the application:

image image   image99

Change a Car’s Make, note that model list for that make is shown on that row. If you check model list on another row, it will be for that row’s make.

Note that we have a slight quirk in the behavior, when there is a pending parent selection, child still show a disabled dropdown. It would be best not to show dropdown at all, but that will require change to template at run time and increase complexity quite a bit. But it is still workable, as user is not able to select. Alternatively you can always write a custom DataGrid column and achieve the proper UI behavior. Also note that even though we used data from one ComboBox to drive other ComboBox, any other field can be used to drive the ComboBox.

Source Code:CascadingComboBox.zip

Technorati Tags:

Comments

Rachida Dukes said:

your articles are wonderful. I was wondering if your planning to do any tutorial about the authentication with silverlight using ASP.net membership or Windows Live ID?

Keep the good work.

Thanks again,

Rachida

# October 22, 2008 5:05 PM

Matt Casto said:

Great article!

I'm interesting in what you're using to make your images.  They look great!

# October 24, 2008 8:48 PM

Simon Kingaby said:

Your articles are excellent.  Thank you for posting them.

I am trying to put together a proof-of-concept in Silverlight 2.  I have the main grid binding to a Silverlight  web service.  Now I need to populate the Combos from my database.  All of the samples I can find have the combo being bound to a locally created collection, as in your examples.  How do I populate the MakeList, ModelList, etc. with data from a Silverlight Webservice that has to be called Async.  Do you have an example of databinding via Silverlight Web Services in your bag of tricks?

Again, thanks for these excellent posts!

# October 29, 2008 7:36 AM

manish.dalal said:

Rachida,

Thanks for the encouraging comment. For authentication, we are still using ASP.net (Silverlight is just a component on aspx page). Silvelight vNext will have backed in authentication (see PDC08 sessions).

Matt Casto,

Thanks for the encouraging comment. Diagram are crated with PowerPoint 2007

Simon Kingaby,

Thanks for the encouraging comment. In order to load ItemSource in async manner, you will have to either block UI (have a look at my previous post on async validation) or load data at startup. I will try to update post with complete end to end solution soon.

# November 4, 2008 7:05 PM

marko said:

Could you make same example as this with three cascading combobox that uses wcf and database as data..

I tried but I'm getting NullReferenceException on third combo  when I select them all and try to select first combobox again..

public MainPage()

       {

           InitializeComponent();

           ServiceReference1.Service1Client webService = new ServiceReference1.Service1Client();

           webService.GetCountriesCompleted += new EventHandler<SilverlightApplication12.ServiceReference1.GetCountriesCompletedEventArgs>(webService_GetCountriesCompleted);

           webService.GetCountriesAsync();

       }

       void webService_GetDestinationByCountryIDCompleted(object sender, SilverlightApplication12.ServiceReference1.GetDestinationByCountryIDCompletedEventArgs e)

       {

           destinations.ItemsSource = e.Result;

           //e.Result.Insert(0, "select");

           //destinations.SelectedIndex = 0;

       }

       void webService_GetCountriesCompleted(object sender, SilverlightApplication12.ServiceReference1.GetCountriesCompletedEventArgs e)

       {

           countries.ItemsSource = e.Result;

           //e.Result.Insert(0, "select");

           //countries.SelectedIndex = 0;

       }

       private void countries_SelectionChanged(object sender, SelectionChangedEventArgs e)

       {

           ServiceReference1.Service1Client webService = new ServiceReference1.Service1Client();

           webService.GetDestinationByCountryIDCompleted += new EventHandler<SilverlightApplication12.ServiceReference1.GetDestinationByCountryIDCompletedEventArgs>(webService_GetDestinationByCountryIDCompleted);

               webService.GetDestinationByCountryIDAsync(countries.SelectedItem.ToString());

       }

       private void destinations_SelectionChanged(object sender, SelectionChangedEventArgs e)

       {

           ServiceReference1.Service1Client webService = new ServiceReference1.Service1Client();

           webService.GetHotelsByDestinationCompleted += new EventHandler<SilverlightApplication12.ServiceReference1.GetHotelsByDestinationCompletedEventArgs>(webService_GetHotelsByDestinationCompleted);

           webService.GetHotelsByDestinationAsync(destinations.SelectedItem.ToString());

       }

       void webService_GetHotelsByDestinationCompleted(object sender, SilverlightApplication12.ServiceReference1.GetHotelsByDestinationCompletedEventArgs e)

       {

           hotels.ItemsSource = e.Result;

           //e.Result.Insert(0, "select");

           //hotels.SelectedIndex = 0;

       }

# April 26, 2009 7:59 PM

Deep Zone said:

Do you have source code in VB?   I tried to follow what you did but in VB and got few errors.

# July 9, 2009 4:30 PM

Deep Zone said:

Just found a little problem which shows wrong watermark on 2nd dropdownlist.

# July 9, 2009 7:28 PM

mark said:

How would I reference the Selected Item in say the Make drop down, in the code behind.  I would like to place it in a string and display it back in another summary grid.

# October 22, 2009 12:27 PM

Lidermin said:

Very useful code, but I need something more simple, without applying the factory pattern; is it posible to do? (cascading comboboxes on a simplier way)?? Thanks in advance.

# April 23, 2010 12:01 PM

Sanjeev said:

Really good Example

# April 29, 2010 12:21 AM