Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Remove selection when selected item gets deleted from ListBox

Tags:

wpf

I have a ListBox that has its ItemsSource bound to a custom class that (properly) implements an INotifyCollectionChanged and a SelectedItem bound to a field in a ViewModel.

The problem is that when I remove a currently SelectedItem from the ItemsSource collection it immediately changes the selection to an neighboring item. I would very much prefer if it just removed selection.

The reason why it's such a problem for me is following. The ItemsSource class contains elements from some other collection that either satisfy some (during runtime constant) Predicate or are Active. Being Active is "synchronized" with being SelectedItem (there're reasons for that). So it's very much possible for an item be allowed in the ListBox only if it's selected which means it might be supposed to vanish when user selects some other one.

My function (deep in "model") that gets called when SelectedItem gets changed:

//Gets old Active item
var oldActiveSchema = Schemas.FirstOrDefault(sch => sch.IsActive);

//Makes the new Item active (which triggers adding it into `ItemsSource` in case it didn't satisfy the Predicate)
((PowerSchema)newActiveSchema).IsActive = true;
//Triggers PropertyChanged on ViewModel with the new Active item
CurrentSchema = newActiveSchema;
RaisePropertyChangedEvent(nameof(CurrentSchema)); (#1)

//Changes the old item so it stops being Active -> gets removed from `ItemsSource` (#2)
if (oldActiveSchema != null) { ((PowerSchema)oldActiveSchema).IsActive = false; }

The issue is that for some reason an update of ListBox due to the change of SelectedItem that's supposed to get triggered by (#1) gets postponed (the message to update the ListBox probably ends up in a WPF message loop and waits there until current computation finishes).

The removal of oldActiveSchema from the ItemsSource, on the other hand, is immediate and also instantly triggers a change of SelectedItem to a one that's next to the old one (when you remove the selected item, a neighboring gets selected instead). And because the change of SelectedItem triggers my function that sets CurrentSchema to the wrong (neighboring) item it rewrites the user-selected CurrentSchema (#1) and by the time the message to update ListBox due to PropertyChanged gets run it just updates it with the neighboring one.

Any help is greatly appreciated.


Actual code if anyone would like to dig deeper:

  • ListBox
  • ViewModel
  • The model's method
  • Callstack when neighboring item gets selected as SelectedItem instead of the one user chose
    • line 46: the SelectedItem chosen by user enters the method as one that's supposed to get active
    • line 45: the old SelectedItem stops being active -> gets removed from collection (44-41)
    • line 32: MoveCurrencyOffDeletedElement moves SelectedItem
    • line 5: SelectedItem gets changed to a neighboring one
like image 770
Petrroll Avatar asked Mar 09 '17 18:03

Petrroll


People also ask

How to clear selected item in ListBox c#?

ListBox. ObjectCollection class, and how to clear all item selection using the ClearSelected method. The code first moves the currently selected item in the ListBox to the top of the list. The code then removes all items before the currently selected item and clears all selections in the ListBox.


1 Answers

Diagnosis

The key to your problems is that you set IsSynchronizedWithCurrentItem="True" on your ListBox. What it does is it keeps the ListBox.SelectedItem and ListBox.Items.CurrentItem in sync. Also, ListBox.Items.CurrentItem is synchronized with the ICollectionView.CurrentItem property of the default collection view for the source collection (this view is returned by CollectionViewSource.GetDefaultView(Schemas) in your case). Now when you remove an item from the Schemas collection which also happens to be the CurrentItem of the corresponding collection view, the view by default updates its CurrentItem to the next item (or the previous one if the removed item was the last one, or to null if the removed item was the only item in the collection).

The second part of the problem is that when the ListBox.SelectedItem is changed causing an update to your view-model property, your RaisePropertyChangedEvent(nameof(ActiveSchema)) is processed after the update process is finished, in particular after the control is returned from the ActiveSchema setter. You can observe that the getter is not hit immediately, but only after the setter is done. What's important, the CurrentItem of the Schemas view is also not updated immediately to reflect the newly selected item. On the other hand, when you set IsActive = false on the previously selected item, it causes immediate "removal" of this item from the Schemas collection, which in turn causes an update of the CurrentItem of the collection view, and the chain immediately continues to update the ListBox.SelectedItem. You can observe that at this point the ActiveSchema setter will be hit again. So your ActiveSchema will be changed again (to the item next to the previously selected one) even before you've finished processing the previous change (to the item selected by the user).

Solution

There are several ways to address this issue:

#1

Set the IsSynchronizedWithCurrentItem="False" on your ListBox (or leave it untouched). This will make your problem go away with no effort. If however for some reason it is required, use any of the other solutions.

#2

Prevent reentrant attempts to set the ActiveSchema by using a guard flag:

bool ignoreActiveSchemaChanges = false;
public IPowerSchema ActiveSchema
{
    get { return pwrManager.CurrentSchema; }
    set
    {
        if (ignoreActiveSchemaChanges) return;
        if (value != null && !value.IsActive)
        {
            ignoreActiveSchemaChanges = true;
            pwrManager.SetPowerSchema(value);
            ignoreActiveSchemaChanges = false;
        }
    }
}

This will cause automatic updates to the collection view's CurrentItem to be ignored by your view-model, and ultimately the ActiveSchema will maintain the expected value.

#3

Manually update the collection view's CurrentItem to the newly selected item before you "remove" the previously selected one. You will need a reference to the MainWindowViewModel.Schemas collection, so you can either pass it as a parameter to your setNewCurrSchema method or encapsulate the code in a delegate and pass that as the parameter. I'll only show the second option:

In the PowerManager class:

//we pass the action as an optional parameter so that we don't need to update
//other code that uses this method
private void setNewCurrSchema(IPowerSchema newActiveSchema, Action action = null)
{
    var oldActiveSchema = Schemas.FirstOrDefault(sch => sch.IsActive);

    ((PowerSchema)newActiveSchema).IsActive = true;
    CurrentSchema = newActiveSchema;
    RaisePropertyChangedEvent(nameof(CurrentSchema));

    action?.Invoke();

    if (oldActiveSchema != null)
    {
        ((PowerSchema)oldActiveSchema).IsActive = false;
    }
}

In the MainWindowViewModel class:

public IPowerSchema ActiveSchema
{
    get { return pwrManager.CurrentSchema; }
    set
    {
        if (value != null && !value.IsActive)
        {
            var action = new Action(() =>
            {
                //this will cause a reentrant attempt to set the ActiveSchema,
                //but it will be ignored because at this point value.IsActive == true
                CollectionViewSource.GetDefaultView(Schemas).MoveCurrentTo(value);
            });
            pwrManager.SetPowerSchema(value, action);
        }
    }
}

Note though that this requires a reference to the PresentationFramework assembly. If you don't want that dependency in your view-model assembly, you could create an event which would be subscribed to by the view and the required code would be run by the view (which already depends on the PresentationFramework assembly). This method is often referred to as interaction request pattern (see User Interaction Patterns) section in the Prism 5.0 guide on MSDN).

#4

Defer the "removal" of the previously selected item until the binding update is finished. This can be achieved by queueing the code to be executed using a Dispatcher:

private void setNewCurrSchema(IPowerSchema newActiveSchema)
{
    var oldActiveSchema = Schemas.FirstOrDefault(sch => sch.IsActive);

    ((PowerSchema)newActiveSchema).IsActive = true;
    CurrentSchema = newActiveSchema;
    RaisePropertyChangedEvent(nameof(CurrentSchema));

    if (oldActiveSchema != null)
    {
        //queue the code for execution
        //in case this code is called due to binding update the current dispatcher will be
        //the one associated with UI thread so everything should work as expected
        Dispatcher.CurrentDispatcher.InvokeAsync(() =>
        {
            ((PowerSchema)oldActiveSchema).IsActive = false;
        });
    }
}

This requires reference to the WindowsBase assembly, which again can be avoided in the view-model assembly by utilizing the method described for solution #3.

Personally I'd go with solution #1 or #2 because it keeps your PowerManager class clean, and #3 and #4 seem prone to unexpected behavior.

like image 97
Grx70 Avatar answered Nov 15 '22 10:11

Grx70