Sonic Soul
Sonic Soul

Reputation: 24989

Deselecting ComboBoxItems in MVVM

I am using a standard wpf/mvvm application where i bind combo boxes to collections on a ViewModel.

I need to be able to de-select an item from the dropdown. Meaning, users should be able to select something, and later decide that they want to un-select it (select none) for it. the problem is that there are no empty elements in my bound collection

my initial thought was simply to insert a new item in the collection which would result having an empty item on top of the collection.

this is a hack though, and it affects all code that uses that collection on the view model.

for example if someone was to write

_myCollection.Frist(o => o.Name == "foo") 

this will throw a null reference exception.

possible workaround is:

_myCollection.Where(o => o != null).First(o => o.Name == "foo");

this will work, but no way to ensure any future uses of that collection won't cause any breaks.

what's a good pattern / solution for being able to adding an empty item so the user can de-select. (I am also aware of CollectionView structure, but that seems like a overkill for something so simple)

Update

went with @hbarck suggestion and implemented CompositeCollection (quick proof of concept)

    public CompositeCollection MyObjects {
        get {
            var col = new CompositeCollection();

            var cc1 = new CollectionContainer();
            cc1.Collection = _actualCollection;

            var cc2 = new CollectionContainer();
            cc2.Collection = new List<MyObject>() { null }; // PROBLEM

            col.Add(cc2);
            col.Add(cc1);
            return col;
        }
    }

this code work with existing bindings (including SelectedItem) which is great.

One problem with this is, that if the item is completely null, the SelectedItem setter is never called upon selecting it.

if i modify that one line to this:

            cc2.Collection = new List<MyObject>() { new MyObject() }; // PROBLEM

the setter is called, but now my selected item is just a basic initialized class instead of null.. i could add some code in the setter to check/reset, but that's not good.

Upvotes: 6

Views: 3758

Answers (5)

hbarck
hbarck

Reputation: 2944

I think the easiest way would be to use a CompositeCollection. Just append your collection to another collection which only contains the empty item (null or a placeholder object, whatever suites your needs), and make the CompositeCollection the ItemsSource for the ComboBox. This is probably what it is intended for.

Update:

This turns out to be more complicated than I first thought, but actually, I came up with this solution:

<Window x:Class="ComboBoxFallbackValue"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:t="clr-namespace:TestWpfDataBinding"
    xmlns:s="clr-namespace:System;assembly=mscorlib"
    xmlns:w="clr-namespace:System.Windows;assembly=WindowsBase"
Title="ComboBoxFallbackValue" Height="300" Width="300">
<Window.Resources>
    <t:TestCollection x:Key="test"/>
    <CompositeCollection x:Key="MyItemsSource">
        <x:Static Member="t:TestCollection.NullItem"/>
        <CollectionContainer Collection="{Binding Source={StaticResource test}}"/>
    </CompositeCollection>
    <t:TestModel x:Key="model"/>
    <t:NullItemConverter x:Key="nullItemConverter"/>
</Window.Resources>
<StackPanel>
    <ComboBox x:Name="cbox" ItemsSource="{Binding Source={StaticResource MyItemsSource}}" IsEditable="true" IsReadOnly="True" Text="Select an Option" SelectedItem="{Binding Source={StaticResource model}, Path=TestItem, Converter={StaticResource nullItemConverter}, ConverterParameter={x:Static t:TestCollection.NullItem}}"/>
    <TextBlock Text="{Binding Source={StaticResource model}, Path=TestItem, TargetNullValue='Testitem is null'}"/>
</StackPanel>

Basically, the pattern is that you declare a singleton NullInstance of the class you use as items, and use a Converter which converts this instance to null when setting the VM property. The converter can be written universally, like this (it's VB, I hope you don't mind):

Public Class NullItemConverter
Implements IValueConverter

Public Function Convert(value As Object, targetType As System.Type, parameter As Object, culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.Convert
    If value Is Nothing Then
        Return parameter
    Else
        Return value
    End If
End Function

Public Function ConvertBack(value As Object, targetType As System.Type, parameter As Object, culture As System.Globalization.CultureInfo) As Object Implements System.Windows.Data.IValueConverter.ConvertBack
    If value Is parameter Then
        Return Nothing
    Else
        Return value
    End If
End Function

End Class

Since you can reuse the converter, you can set this all up in XAML; the only thing that remains to be done in code is to provide the singleton NullItem.

Upvotes: 3

cordialgerm
cordialgerm

Reputation: 8503

One simple approach is to re-template the ComboBox so that when there is an item select a small X appears on the right side of the box. Clicking that clears out the selected item.

This has the advantage of not making your ViewModels any more complicated

Upvotes: 0

Thelonias
Thelonias

Reputation: 2935

Personally, I tend to add an "empty" version of whatever object is in my collection I'm binding to. So, for example, if you're binding to a list of strings, then in your viewmodel, insert an empty string at the beginning of the collection. If your Model has the data collection, then wrap it with another collection in your viewmodel.

MODEL:

public class Foo
{
    public List<string> MyList { get; set;}
}

VIEW MODEL:

public class FooVM
{
    private readonly Foo _fooModel ;

    private readonly ObservableCollection<string> _col;
    public ObservableCollection<string> Col // Binds to the combobox as ItemsSource
    {
        get { return _col; }
    }

    public string SelectedString { get; set; } // Binds to the view

    public FooVM(Foo model)
    {
        _fooModel = model;
        _col= new ObservableCollection<string>(_fooModel.MyList);
        _col.Insert(0, string.Empty);
    }
}

Upvotes: 2

McGarnagle
McGarnagle

Reputation: 102793

You could also extend the ComboBox to enable de-selecting. Add one or more hooks (eg, pressing the escape key) that allow the user to set the SelectedItem to null.

using System.Windows.Input;

public class NullableComboBox : ComboBox
{
    public NullableComboBox()
        : base()
    {
        this.KeyUp += new KeyEventHandler(NullableComboBox_KeyUp);

        var menuItem = new MenuItem();
        menuItem.Header = "Remove selection";
        menuItem.Command = new DelegateCommand(() => { this.SelectedItem = null; });
        this.ContextMenu = new ContextMenu();
        this.ContextMenu.Items.Add(menuItem);
    }

    void NullableComboBox_KeyUp(object sender, System.Windows.Input.KeyEventArgs e)
    {
        if (e.Key == Key.Escape || e.Key == Key.Delete) 
        {
            this.SelectedItem = null;
        }
    }
}

Edit Just noticed Florian GI's comment, the Context Menu might be another good deselect hook to add.

Upvotes: 1

Dan Bryant
Dan Bryant

Reputation: 27515

One option would be to create an adapter collection that you expose specifically for consumers that want an initial 'empty' element. You would need to create a wrapper class that implements IList (if you want same performance as with ObservableCollection) and INotifyCollectionChanged. You would need to listen to INotifyCollectionChanged on the wrapped collection, then rebroadcast the events with indices shifted up by one. All of the relevant list methods would also need to shift indices by one.

public sealed class FirstEmptyAdapter<T> : IList<T>, IList, INotifyCollectionChanged
{
    public FirstEmptyCollection(ObservableCollection<T> wrapped)
    {
    }

    //Lots of adapter code goes here...
}

Bare minimum if you want to avoid the IList methods is to implement INotifyCollectionChanged and IEnumerable<T>.

Upvotes: 0

Related Questions