Click here to Skip to main content
15,884,298 members
Please Sign up or sign in to vote.
0.00/5 (No votes)
See more:
I know there are quite a few posts on the internet regarding how to implement cascading combo box following mvvm in a WPF c# app but I still could not make it work in my case.

Can anyone help me with this ?

I have a model with some properties in it. In one of my combo box's I want to set unique values of a property called Name as the itemsource and when I select an item from that combo box the other combo box should use that selection and find its matching unique value of the property called GSTIN. Now both of these properties are a part of the same class.

What I have tried:

C#
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Data;
using System.Data.SQLite;
using System.Runtime.CompilerServices;
using System.Linq;
using System.Windows;
using System.Windows.Input;

namespace Purchase
{
	public partial class Window1 : Window
	{
		public Window1()
		{
			InitializeComponent();
			this.DataContext=new BVM();
		}
	}
	
	#region Icommand
	public class RelayCommand : ICommand
	{
		private readonly Action<object> _execute;
		private readonly Predicate<object> _canExecute;

		public RelayCommand(Action<object> execute)
			: this(execute, null)
		{
		}

		public RelayCommand(Action<object> execute, Predicate<object> canExecute)
		{
			if (execute == null)
				throw new ArgumentNullException("execute");
			_execute = execute;
			_canExecute = canExecute;
		}

		public bool CanExecute(object parameter)
		{
			return _canExecute == null ? true : _canExecute(parameter);
		}

		public event EventHandler CanExecuteChanged
		{
			add { CommandManager.RequerySuggested += value; }
			remove { CommandManager.RequerySuggested -= value; }
		}

		public void Execute(object parameter)
		{
			_execute(parameter);
		}

	}
	#endregion
	
	#region Base viewmodel
	public class ViewModelBase : INotifyPropertyChanged
	{
		public event PropertyChangedEventHandler PropertyChanged;

		protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
		{
			var handler = PropertyChanged;
			if (handler != null)
				handler(this, new PropertyChangedEventArgs(propertyName));
		}
		protected virtual void SetValue<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
		{
			field = value;
			OnPropertyChanged(propertyName);
		}
		
	}
	#endregion
	
	#region Model
	public class Bills : ViewModelBase
	{
		
		private int _id;
		public int ID
		{
			get
			{
				return _id;
			}
			set
			{
				SetValue(ref _id, value);
			}
		}

		private string _Name;
		public string Name
		{
			get
			{
				return _Name;
			}
			set
			{
				SetValue(ref _Name, value);
			}
		}

		...
		
		private string _GSTIN;
		public string GSTIN
		{
			get
			{
				return _GSTIN;
			}
			set
			{
				SetValue(ref _GSTIN, value);
			}
		}

		...

	}
	#endregion
	
	#region Main viewmodel
	public class BVM : ViewModelBase
	{
		private ObservableCollection<Bills> _allBills;
		public ObservableCollection<Bills> AllBillss
		{
			get
			{
				if (this._allBills == null) this._allBills = GetAllBillsFromDB();
				return _allBills;
			}

		}
		
		//1st combo box source
		private ObservableCollection<string> _comboItems;
		public ObservableCollection<string> ComboItems
		{
			get
			{
				if (_comboItems == null) this._comboItems = new ObservableCollection<string>(AllBillss.Select(b => b.Name).Distinct().OrderBy(b => b).ToList());
				return this._comboItems;
			}
			set
			{
				_comboItems = value;
				OnPropertyChanged("ComboItems");
			}
		}
		
		//2nd combo box source
		private ObservableCollection<string> _comboItems2;
		public ObservableCollection<string> ComboItems2
		{
			get
			{
				if (_comboItems2 == null) this._comboItems2 = new ObservableCollection<string>(AllBillss.Where(t=>t.Name != null && t.Name == _comboItems.ToString()).Select(s=>s.GSTIN).Distinct().ToList());
				return this._comboItems2;
			}

			set
			{
				_comboItems2 = value;
				OnPropertyChanged("ComboItems2");
			}
		}
		
		//1st combo box selected item
		private string _SelectedCBItem;
		public string SelectedCBItem
		{
			get { return _SelectedCBItem; }
			set
			{
				_SelectedCBItem = value;
				OnPropertyChanged("SelectedCBItem");
				this._comboItems2 = new ObservableCollection<string>(AllBillss.Where(t=>t.Name != null && t.Name == _comboItems.ToString()).Select(s=>s.GSTIN).Distinct().ToList());
				OnPropertyChanged("ComboItems2");
			}
		}
		
		//2nd combo box selected item
		private string _SelectedCBItem2;
		public string SelectedCBItem2
		{
			get { return _SelectedCBItem2; }
			set
			{
				SetValue(ref _SelectedCBItem2, value);
			}
		}
		
		public BVM()
		{
			
		}
		
	}
	...
}


XAML
XML
<Label Margin="5" Grid.Column="0" Content="Vendor :" Height="25" Width="60" />
<ComboBox Margin="5" Grid.Column="1" Height="25" Width="350" ItemsSource="{Binding ComboItems}" SelectedItem="SelectedCBItem" />
<Label Margin="5" Grid.Column="2" Content="GSTIN :" Height="25" Width="60" />
<ComboBox Margin="5" Grid.Column="3" Height="25" Width="220" ItemsSource="{Binding ComboItems2}" SelectedItem="SelectedCBItem2" />
Posted
Updated 3-Feb-23 5:39am
v2

1 solution

You need to use a CollectionViewSource[^] to filter your sub-collection. Microsoft have hte following example: How to: Filter Data in a View - WPF .NET Framework | Microsoft Learn[^]

Here is a working example put together to demonstrate how to implement using MVVM pattern:

1. Data Source, courtesy of: Major cities of the world - Dataset - DataHub - Frictionless Data[^]

2. Code-behind:
C#
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;

namespace WpfCascadingComboBoxes;

public partial class MainWindow : INotifyPropertyChanged
{
    public MainWindow()
    {
        InitializeComponent();
        _ = LoadData();
    }

    private string _url = "https://pkgstore.datahub.io/core/world-cities/world-cities_json/data/5b3dd46ad10990bca47b04b4739a02ba/world-cities_json.json";

    private CountryModel? selectedCountry;
    private ListCollectionView filteredCitiesView;

    public List<CityModel> Cities { get; } = new();
    
    public ObservableCollection<CountryModel> Countries { get; } = new();

    public ListCollectionView FilteredCitiesView
    {
        get => filteredCitiesView;
        private set
        {
            if (Equals(value, filteredCitiesView)) return;

            filteredCitiesView = value;
            OnPropertyChanged();
        }
    }

    public CountryModel? SelectedCountry
    {
        get => selectedCountry;
        set
        {
            if (Equals(value, selectedCountry)) return;

            selectedCountry = value;
            ApplyFilter();
            OnPropertyChanged();
        }
    }

    private async Task LoadData()
    {
        JsonSerializerOptions options = new();

        await using Stream stream = await new HttpClient().GetStreamAsync(_url);

        // deserialize the stream an object at a time...
        await foreach (CountryDTO item in JsonSerializer
                           .DeserializeAsyncEnumerable<CountryDTO>(stream, options))
        {
            Process(item);
        }

        PrepareFiltering();

        SelectedCountry = Countries.First();

        ApplyFilter();
    }

    private void PrepareFiltering()
    {
        FilteredCitiesView = (ListCollectionView)CollectionViewSource.GetDefaultView(Cities);
        FilteredCitiesView.SortDescriptions.Clear();
        FilteredCitiesView.SortDescriptions.Add(new SortDescription("Name",
            ListSortDirection.Ascending));
    }

    // Filter
    public bool Contains(object item)
    {
        CityModel? city = item as CityModel;
        return (city?.ParentId ?? -999) == (SelectedCountry?.Id ?? -1);
    }

    private void ApplyFilter()
        => FilteredCitiesView.Filter = Contains;

    // prepared models from JSON data
    private void Process(CountryDTO dto)
    {
        CountryModel? country = Countries
            .FirstOrDefault(x => x.Name.Equals(dto.Country));

        if (country == null)
        {
            country = new CountryModel(Countries.Count + 1, dto.Country);
            Countries.Add(country);
        }

        var city = new CityModel(Cities.Count + 1, country.Id, dto.Name);
        Cities.Add(city);
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

public class CountryDTO
{
    [JsonPropertyName("country")]
    public string? Country { get; set; }

    [JsonPropertyName("name")]
    public string? Name { get; set; }
}

public record CountryModel(int Id, string Name);

public record CityModel(int Id, int ParentId, string Name);

3. The XAML:
XML
<Window x:Class="WpfCascadingComboBoxes.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfCascadingComboBoxes"
        x:Name="Window"
        mc:Ignorable="d" Title="MainWindow" Height="450" Width="800">
    <Grid DataContext="{Binding ElementName=Window}">
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>

        <ComboBox Grid.Column="0"
                  Width="200"
                  VerticalAlignment="Center"
                  ItemsSource="{Binding Countries}"
                  DisplayMemberPath="Name"
                  SelectedItem="{Binding SelectedCountry}"/>

        <ComboBox Grid.Column="1"
                  Width="200"
                  VerticalAlignment="Center"
                  DisplayMemberPath="Name"
                  ItemsSource="{Binding FilteredCitiesView}"/>
    </Grid>
</Window>

NOTES:
* The data is being pulled in and prepared at the start of the app running. Wait for the country ComboBox to fill before selecting.
* Selecting a country will filter the City ComboBox via the CollectionViewSource[^].

UPDATE
Here is a modified version of the above solution working with a single collection.

1. Code-behind:
C#
public partial class MainWindow : INotifyPropertyChanged
{
    public MainWindow()
    {
        InitializeComponent();
        _ = LoadData();
    }

    // data source: https://datahub.io/core/world-cities#data
    private string _url = "https://pkgstore.datahub.io/core/world-cities/world-cities_json/data/5b3dd46ad10990bca47b04b4739a02ba/world-cities_json.json";

    private CountryDTO? selectedCountry;
    private ListCollectionView? filteredCountriesView;
    private ListCollectionView? filteredCitiesView;

    public List<CountryDTO> Countries { get; } = new();

    public ListCollectionView? FilteredCountriesView
    {
        get => filteredCountriesView;
        private set
        {
            if (Equals(value, filteredCountriesView)) return;

            filteredCountriesView = value;
            OnPropertyChanged();
        }
    }

    public ListCollectionView? FilteredCitiesView
    {
        get => filteredCitiesView;
        private set
        {
            if (Equals(value, filteredCitiesView)) return;

            filteredCitiesView = value;
            OnPropertyChanged();
        }
    }

    public CountryDTO? SelectedCountry
    {
        get => selectedCountry;
        set
        {
            if (Equals(value, selectedCountry)) return;

            selectedCountry = value;

            ApplyCityFilter();
            OnPropertyChanged();
        }
    }

    private async Task LoadData()
    {
        JsonSerializerOptions options = new();

        await using Stream stream = await new HttpClient().GetStreamAsync(_url);

        // deserialize the stream an object at a time...
        await foreach (CountryDTO item in JsonSerializer
                           .DeserializeAsyncEnumerable<CountryDTO>(stream, options))
        {
            Countries.Add(item);
        }

        PrepareCountryFiltering();
        PrepareCityFiltering();

        ApplyCountryFilter();
        SelectedCountry = Countries.First();
    }

    private void PrepareCountryFiltering()
    {
        FilteredCountriesView = (ListCollectionView)
            new CollectionViewSource { Source = Countries }.View;

        FilteredCountriesView.SortDescriptions.Clear();
        FilteredCountriesView.SortDescriptions.Add(
            new SortDescription(nameof(CountryDTO.CountryName),
            ListSortDirection.Ascending));
    }

    private void PrepareCityFiltering()
    {
        FilteredCitiesView = (ListCollectionView)
            new CollectionViewSource { Source = Countries }.View;

        FilteredCitiesView.SortDescriptions.Clear();
        FilteredCitiesView.SortDescriptions.Add(
            new SortDescription(nameof(CountryDTO.CityName),
            ListSortDirection.Ascending));
    }

    // Filter
    public bool DistinctCountry(object item)
    {
        CountryDTO? model = item as CountryDTO;
        if (model is null) return false;

        int index1 = Countries.IndexOf(model);
        int index2 = Countries.IndexOf(Countries.Last(x => 
            (x.CountryName ?? "no country name").Equals(model.CountryName)));

        return index1 == index2;
    }

    public bool FilterByCountry(object item)
    {
        CountryDTO? dto = item as CountryDTO;
        return (dto?.CountryName ?? "no country name")
            .Equals(SelectedCountry?.CountryName ?? "not selected");
    }

    private void ApplyCountryFilter()
    {
        if (FilteredCountriesView is null) return;
        FilteredCountriesView.Filter = DistinctCountry;
    }
    private void ApplyCityFilter()
    {
        if (FilteredCitiesView is null) return;
        FilteredCitiesView.Filter = FilterByCountry;
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

public class CountryDTO
{
    [JsonPropertyName("country")]
    public string? CountryName { get; set; }

    [JsonPropertyName("name")]
    public string? CityName { get; set; }
}

2. The XAML:
XML
<Window x:Class="WpfCascadingComboBoxes.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfCascadingComboBoxes"
        x:Name="Window"
        mc:Ignorable="d" Title="MainWindow" Height="450" Width="800">
    <Grid DataContext="{Binding ElementName=Window}">
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>

        <ComboBox Grid.Column="0"
                  Width="200"
                  VerticalAlignment="Center"
                  DisplayMemberPath="CountryName"
                  ItemsSource="{Binding FilteredCountriesView}"
                  SelectedItem="{Binding SelectedCountry}"/>

        <ComboBox Grid.Column="1"
                  Width="200"
                  VerticalAlignment="Center"
                  DisplayMemberPath="CityName"
                  ItemsSource="{Binding FilteredCitiesView}"/>
    </Grid>
</Window>

NOTES:
* Now both ComboBoxes are using a CollectionViewSource[^]
* The Countries filter uses a Distinct filter to remove duplicates.
* I've also changed the CountryDTO class property names to help with understanding the code.
 
Share this answer
 
v2
Comments
Member 12692000 3-Feb-23 19:26pm    
I'm a bit confused tbh. Do I need to create 2 different models/classes (one that has `Name` property and the other `GSTIN` property) just for this ? why ?
Graeme_Grant 3-Feb-23 19:55pm    
There are 23,018 records used in that sample app. So, I use distinct collections for each to demonstrate the performance of the CollectionViewSource.

Create a blank WPF app, add the above code, and run it. Set breakpoints and see how it works.

You could use a single collection, however, the root collection will require its own CollectionViewSource to filter out duplicates.
Graeme_Grant 3-Feb-23 20:50pm    
I have updated the solution with a new version that works with a single collection.
Member 12692000 4-Feb-23 8:07am    
Thanks for that, would have been great if you could provide a C#5.0 version as indicated in my tags for the question.
Graeme_Grant 4-Feb-23 15:27pm    
What .Net Framework version? Check Project properties. I am locked out from changing my version of C#.

This content, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)



CodeProject, 20 Bay Street, 11th Floor Toronto, Ontario, Canada M5J 2N8 +1 (416) 849-8900