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:
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);
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));
}
public bool Contains(object item)
{
CityModel? city = item as CityModel;
return (city?.ParentId ?? -999) == (SelectedCountry?.Id ?? -1);
}
private void ApplyFilter()
=> FilteredCitiesView.Filter = Contains;
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:
<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:
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 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);
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));
}
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:
<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
ComboBox
es 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.