Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Refreshing ListCollectionView sets the value of selected item in ComboBox to null

I have a view with a ListBox and two ComboBoxes. When I select an item in the ListBox, the content/value of the ComboBoxes is refreshed based on the value of the properties of the selected item. In my scenario, the ListBox holds a list of clients, the first ComboBox holds a list of countries. The selected item is the country of origin of the client. The second ComboBox holds a list of cities. The selected city is the city of origin of the client.

The ItemsSource property of the second ComboBox is bound to a ListViewCollection based on an ObservableCollection of all the cities using a filter. When the selection in the country ListBox changes, I refresh the filter to display only cities belonging to the selected country.

Let's assume client A is from Auckland, New Zealand and client B is from Toronto, Canada. When I select A, everything works fine. The second ComboBox is populated with New Zealand cities only and Auckland is selected. Now I select B and the selected country is now Canada and the list of cities contains only Canadian cities, Toronto is selected. If now I go back to A, New Zealand is selected in the countries, the list of cities contains only cities from New Zealand but Auckland is not selected.

When I debug this scenario, I notice that when I select B, the call to ListCollectionView.Refresh() sets the value of the city on the client A initially selected to null (put a breakpoint at the call to Refresh and another one on the city setter on the model, see code below).

I guess - although I'm not 100% sure - that it is happening because I have a TwoWay binding on the SelectedItem of the city ComboBox and when the filter updates the list to the Canadian cities, Auckland disappears and this information is sent back to the property which is then updated to null. Which, in a way, makes sense.

My question is: How can I avoid this to happen? How can I prevent the property on my model to be updated when the ItemsSource is only refreshed?

Below is my code (it's a bit long although I tried to make it the smallest possible amount of code that makes the issue reproducible):

public class Country
{
    public string Name { get; set; }
    public IEnumerable<City> Cities { get; set; }
}

public class City
{
    public string Name { get; set; }
    public Country Country { get; set; }
}

public class ClientModel : NotifyPropertyChanged
{
    #region Fields
    private string name;
    private Country country;
    private City city;
    #endregion

    #region Properties
    public string Name
    {
        get
        {
            return this.name;
        }

        set
        {
            this.name = value;
            this.OnPropertyChange("Name");
        }
    }

    public Country Country
    {
        get
        {
            return this.country;
        }

        set
        {
            this.country = value;
            this.OnPropertyChange("Country");
        }
    }

    public City City
    {
        get
        {
            return this.city;
        }

        set
        {
            this.city = value;
            this.OnPropertyChange("City");
        }
    }
    #endregion
}

public class ViewModel : NotifyPropertyChanged
{
    #region Fields
    private ObservableCollection<ClientModel> models;
    private ObservableCollection<Country> countries;
    private ObservableCollection<City> cities;
    private ListCollectionView citiesView;

    private ClientModel selectedClient;
    #endregion

    #region Constructors
    public ViewModel(IEnumerable<ClientModel> models, IEnumerable<Country> countries, IEnumerable<City> cities)
    {
        this.Models = new ObservableCollection<ClientModel>(models);
        this.Countries = new ObservableCollection<Country>(countries);
        this.Cities = new ObservableCollection<City>(cities);
        this.citiesView = (ListCollectionView)CollectionViewSource.GetDefaultView(this.cities);
        this.citiesView.Filter = city => ((City)city).Country.Name == (this.SelectedClient != null ? this.SelectedClient.Country.Name : string.Empty);

        this.CountryChangedCommand = new DelegateCommand(this.OnCountryChanged);
    }
    #endregion

    #region Properties
    public ObservableCollection<ClientModel> Models
    {
        get
        {
            return this.models;
        }

        set
        {
            this.models = value;
            this.OnPropertyChange("Models");
        }
    }

    public ObservableCollection<Country> Countries
    {
        get
        {
            return this.countries;
        }

        set
        {
            this.countries = value;
            this.OnPropertyChange("Countries");
        }
    }

    public ObservableCollection<City> Cities
    {
        get
        {
            return this.cities;
        }

        set
        {
            this.cities = value;
            this.OnPropertyChange("Cities");
        }
    }

    public ListCollectionView CitiesView
    {
        get
        {
            return this.citiesView;
        }
    }

    public ClientModel SelectedClient
    {
        get
        {
            return this.selectedClient;
        }

        set
        {
            this.selectedClient = value;
            this.OnPropertyChange("SelectedClient");
        }
    }

    public ICommand CountryChangedCommand { get; private set; }

    #endregion

    #region Methods
    private void OnCountryChanged(object obj)
    {
        this.CitiesView.Refresh();
    }
    #endregion
}

Now here's the XAML:

    <Grid Grid.Column="0" DataContext="{Binding SelectedClient}">
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="25"/>
            <RowDefinition Height="25"/>
        </Grid.RowDefinitions>

        <TextBlock Grid.Column="0" Grid.Row="0" Text="Country"/>
        <local:ComboBox Grid.Column="1" Grid.Row="0" SelectedItem="{Binding Country}"
                        Command="{Binding DataContext.CountryChangedCommand, RelativeSource={RelativeSource AncestorType={x:Type local:MainWindow}}}"
                        ItemsSource="{Binding DataContext.Countries, RelativeSource={RelativeSource AncestorType={x:Type local:MainWindow}}}">
            <local:ComboBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}"/>
                </DataTemplate>
            </local:ComboBox.ItemTemplate>
        </local:ComboBox>

        <TextBlock Grid.Column="0" Grid.Row="1" Text="City"/>
        <ComboBox Grid.Column="1" Grid.Row="1" SelectedItem="{Binding City}"
                  ItemsSource="{Binding DataContext.CitiesView, RelativeSource={RelativeSource AncestorType={x:Type local:MainWindow}}}">
            <ComboBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}"/>
                </DataTemplate>
            </ComboBox.ItemTemplate>
        </ComboBox>
    </Grid>

    <ListBox Grid.Column="1" ItemsSource="{Binding Models}" SelectedItem="{Binding SelectedClient}">
        <ListBox.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Name}"/>
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
</Grid>

If it's of any help, here's also the code of my custom ComboBox to handle notification of changes in the country selection.

public class ComboBox : System.Windows.Controls.ComboBox, ICommandSource
{
    #region Fields
    public static readonly DependencyProperty CommandProperty = DependencyProperty.Register(
        "Command",
        typeof(ICommand),
        typeof(ComboBox));

    public static readonly DependencyProperty CommandParameterProperty = DependencyProperty.Register(
        "CommandParameter",
        typeof(object),
        typeof(ComboBox));

    public static readonly DependencyProperty CommandTargetProperty = DependencyProperty.Register(
        "CommandTarget",
        typeof(IInputElement),
        typeof(ComboBox));
    #endregion

    #region Properties
    public ICommand Command
    {
        get { return (ICommand)this.GetValue(CommandProperty); }
        set { this.SetValue(CommandProperty, value); }
    }

    public object CommandParameter
    {
        get { return this.GetValue(CommandParameterProperty); }
        set { this.SetValue(CommandParameterProperty, value); }
    }

    public IInputElement CommandTarget
    {
        get { return (IInputElement)this.GetValue(CommandTargetProperty); }
        set { this.SetValue(CommandTargetProperty, value); }
    }
    #endregion

    #region Methods

    protected override void OnSelectionChanged(System.Windows.Controls.SelectionChangedEventArgs e)
    {
        base.OnSelectionChanged(e);

        var command = this.Command;
        var parameter = this.CommandParameter;
        var target = this.CommandTarget;

        var routedCommand = command as RoutedCommand;
        if (routedCommand != null && routedCommand.CanExecute(parameter, target))
        {
            routedCommand.Execute(parameter, target);
        }
        else if (command != null && command.CanExecute(parameter))
        {
            command.Execute(parameter);
        }
    }
    #endregion
}

For this simplified example, I create and populate the view model in the constructor of my Window, here:

public MainWindow()
{
    InitializeComponent();

    Country canada = new Country() { Name = "Canada" };
    Country germany = new Country() { Name = "Germany" };
    Country vietnam = new Country() { Name = "Vietnam" };
    Country newZealand = new Country() { Name = "New Zealand" };

    List<City> canadianCities = new List<City>
    {
        new City { Country = canada, Name = "Montréal" },
        new City { Country = canada, Name = "Toronto" },
        new City { Country = canada, Name = "Vancouver" }
    };
    canada.Cities = canadianCities;

    List<City> germanCities = new List<City>
    {
        new City { Country = germany, Name = "Frankfurt" },
        new City { Country = germany, Name = "Hamburg" },
        new City { Country = germany, Name = "Düsseldorf" }
    };
    germany.Cities = germanCities;

    List<City> vietnameseCities = new List<City>
    {
        new City { Country = vietnam, Name = "Ho Chi Minh City" },
        new City { Country = vietnam, Name = "Da Nang" },
        new City { Country = vietnam, Name = "Hue" }
    };
    vietnam.Cities = vietnameseCities;

    List<City> newZealandCities = new List<City>
    {
        new City { Country = newZealand, Name = "Auckland" },
        new City { Country = newZealand, Name = "Christchurch" },
        new City { Country = newZealand, Name = "Invercargill" }
    };
    newZealand.Cities = newZealandCities;

    ObservableCollection<ClientModel> models = new ObservableCollection<ClientModel>
    {
        new ClientModel { Name = "Bob", Country = newZealand, City = newZealandCities[0] },
        new ClientModel { Name = "John", Country = canada, City = canadianCities[1] }
    };

    List<Country> countries = new List<Country>
    {
        canada, newZealand, vietnam, germany
    };

    List<City> cities = new List<City>();
    cities.AddRange(canadianCities);
    cities.AddRange(germanCities);
    cities.AddRange(vietnameseCities);
    cities.AddRange(newZealandCities);

    ViewModel vm = new ViewModel(models, countries, cities);

    this.DataContext = vm;
}

It should be possible to reproduce the issue by simply copy/pasting all of the above code. I'm using .NET 4.0.

Finally, I read this article (and some others) and tried to adapt/apply the given recommendations to my case but without any success. I guess I'm doing things wrongly:

I also read this question but if my ListBox grows big I may end up having to keep track of hundreds of items explicitely which I don't want to do if possible.

like image 779
Guillaume Avatar asked Nov 02 '22 23:11

Guillaume


1 Answers

You have a bit redundant model. You have list of countries, and every country has list of cities. And then you compose the overall list of cities, which you update when selection changed. If you will change data source of the Cities ComboBox, you will get desired behavior:

    <ComboBox Grid.Column="1" Grid.Row="1" SelectedItem="{Binding City}"
              ItemsSource="{Binding Country.Cities}">
        <ComboBox.ItemTemplate>
            <DataTemplate>
                <TextBlock Text="{Binding Name}"/>
            </DataTemplate>
        </ComboBox.ItemTemplate>
    </ComboBox>

You have a right guess about why the city is set to null.

But if you want to stay your model as you have described above - you should to change the order of methods call. To do this, you should use Application.Current.Dispatcher property (and you do not need to change ComboBox mentioned above):

private void OnCountryChanged()
{
    var uiDispatcher = System.Windows.Application.Current.Dispatcher;
    uiDispatcher.BeginInvoke(new Action(this.CitiesView.Refresh));
}
like image 102
stukselbax Avatar answered Nov 12 '22 16:11

stukselbax