简体   繁体   中英

WPF: ComboBox with reset item

I want to make a ComboBox in WPF that has one null item on the top, when this gets selected, the SelectedItem should be set to null (reset to default state). I've searched like forever, but didn't find a solution that was satisfying.

If possible I would want it to do it only with XAML code or an attached behaviour, because I don't really like changing stuff in the ViewModel for the View, or overriding standard controls.

Here is what I've come up with so far (shortened code):

[...]
<Popup x:Name="PART_Popup" [...]>
    <Border x:Name="PopupBorder" [...]>
        <ScrollViewer x:Name="DropDownScrollViewer" [...]>
            <StackPanel [...]>
                <ComboBoxItem>(None)</ComboBoxItem>
                <ItemsPresenter x:Name="ItemsPresenter"/>
            </StackPanel>
        </ScrollViewer>
    </Border>
</Popup>
[...]

OpenCombo

I think the best way would be to somehow add an event trigger that sets the SelectedIndex to -1 when the item gets selected, but here is where I've got stuck.

Any ideas how to do this? Or an better way, like an attached behaviour?

Think about implementing a Null Object Pattern for the "None" combobox item and add this item to your items list. Then implement custom logic for saving null object in that class, or just check if selected item is of NullItem type.

I used the following solution for a similar problem. It makes use of the Converter property of the binding to go back and forth between the internal representation (that null is a reasonable value) and what I want to appear in the ComboBox. I like that there's no need to add an explicit list in a model or viewmodel, but I don't like the fragile connection between the string literal in the converter and that in the ComboBox.

<ComboBox SelectedValue="{Binding MyProperty, Converter={x:Static Converters:MyPropertySelectionConverter.Instance}}" >
    <ComboBox.ItemsSource>
        <CompositeCollection>
            <sys:String>(none)</sys:String>
            <CollectionContainer Collection="{Binding Source={x:Static Somewhere}, Path=ListOfPossibleValuesForMyProperty}" />
        </CompositeCollection>
    </ComboBox.ItemsSource>
</ComboBox>

and then the converter looks like:

public class MyPropertySelectionConverter : IValueConverter
{
    public static MyPropertySelectionConverter Instance
    {
        get { return s_Instance; }
    }

    public const String NoneString = "(none)";

    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        Object retval = value as MyPropertyType;
        if (retval == null)
        {
            retval = NoneString;
        }
        return retval;
    }

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        Object retval = null;
        if (value is MyPropertyType)
        {
            retval = value;
        }
        else if (String.Equals(NoneString, value as String, StringComparison.OrdinalIgnoreCase))
        {
            retval = null;
        }
        else
        {
            retval = DependencyProperty.UnsetValue;
        }
        return retval;
    }


    private static MyPropertySelectionConverter s_Instance = new MyPropertySelectionConverter();
}

It is possible to reset the selection if you select an item.

<ComboBox x:Name="cb">
    <ComboBox.Items>
        <ComboBoxItem Content="(None)">
            <ComboBoxItem.Triggers>
                <EventTrigger RoutedEvent="Selector.Selected">
                    <BeginStoryboard>
                        <Storyboard Storyboard.TargetName="cb" Storyboard.TargetProperty="SelectedItem">
                            <ObjectAnimationUsingKeyFrames Duration="0:0:0">
                                <DiscreteObjectKeyFrame Value="{x:Null}" />
                            </ObjectAnimationUsingKeyFrames>
                        </Storyboard>
                    </BeginStoryboard>                               
                </EventTrigger>
            </ComboBoxItem.Triggers>
        </ComboBoxItem>
        <ComboBoxItem>First Item</ComboBoxItem>
        <ComboBoxItem>Second Item</ComboBoxItem>
    </ComboBox.Items>
</ComboBox>

Unfortunately this will not work with ItemsSource and a CompositeCollection to add this reset item to an arbitrary list. The reason is WPF can't resolve the Storyboard.TargetName in this scope. But maybe this helps you go on with retemplating the ComboBox .

While I agree there are plenty solutions to WPF ComboBox 's null item issue, Andrei Zubov's reference to Null Object Pattern inspired me to try a less overkilling alternative, which consists on wrapping every source item allow with a null value (also wrapped) before injecting the whole wrapped collection into ComboBox.ItemsSource property. Selected item will be available into SelectedWrappedItem property.

So, first you define your generic Wrapper...

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ComboBoxWrapperSample
{

    /// <summary>
    /// Wrapper that adds supports to null values upon ComboBox.ItemsSource
    /// </summary>
    /// <typeparam name="T">Source combobox items collection datatype</typeparam>
    public class ComboBoxNullableItemWrapper<T>
    {
        string _nullValueText;

        private T _value;

        public T Value
        {
            get { return _value; }
            set { _value = value; }
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="Value">Source object</param>
        /// <param name="NullValueText">Text to be presented whenever Value argument object is NULL</param>
        public ComboBoxNullableItemWrapper(T Value, string NullValueText = "(none)")
        {
            this._value = Value;
            this._nullValueText = NullValueText;
        }

        /// <summary>
        /// Text that will be shown on combobox items
        /// </summary>
        /// <returns></returns>
        public override string ToString()
        {
            string result;
            if (this._value == null)
                result = _nullValueText;
            else
                result = _value.ToString();
            return result;
        }

    }
}

Define your item model...

using System.ComponentModel;

namespace ComboBoxWrapperSample
{
    public class Person : INotifyPropertyChanged
    {
        // Declare the event
        public event PropertyChangedEventHandler PropertyChanged;

        public Person()
        {
        }

        // Name property
        private string _name;

        public string Name
        {
            get { return _name; }
            set
            {
                _name = value;
                OnPropertyChanged("Name");
            }
        }

        // Age property
        private int _age;

        public int Age
        {
            get { return _age; }
            set
            {
                _age = value;
                OnPropertyChanged("Age");
            }
        }

        protected void OnPropertyChanged(string name)
        {
            PropertyChangedEventHandler handler = PropertyChanged;
            if (handler != null)
            {
                handler(this, new PropertyChangedEventArgs(name));
            }
        }

        // Don't forget this override, since it's what defines ao each combo item is shown
        public override string ToString()
        {
            return string.Format("{0} (age {1})", Name, Age);
        }
    }
}

Define your ViewModel...

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;

namespace ComboBoxWrapperSample
{
    public partial class SampleViewModel : INotifyPropertyChanged
    {

        // SelectedWrappedItem- This property stores selected wrapped item
        public ComboBoxNullableItemWrapper<Person> _SelectedWrappedItem { get; set; }

        public ComboBoxNullableItemWrapper<Person> SelectedWrappedItem
        {
            get { return _SelectedWrappedItem; }
            set
            {
                _SelectedWrappedItem = value;
                OnPropertyChanged("SelectedWrappedItem");
            }
        }

        // ListOfPersons - Collection to be injected into ComboBox.ItemsSource property
        public ObservableCollection<ComboBoxNullableItemWrapper<Person>> ListOfPersons { get; set; }

        public SampleViewModel()
        {

            // Setup a regular items collection
            var person1 = new Person() { Name = "Foo", Age = 31 };
            var person2 = new Person() { Name = "Bar", Age = 42 };

            List<Person> RegularList = new List<Person>();
            RegularList.Add(person1);
            RegularList.Add(person2);

            // Convert regular collection into a wrapped collection
            ListOfPersons = new ObservableCollection<ComboBoxNullableItemWrapper<Person>>();
            ListOfPersons.Add(new ComboBoxNullableItemWrapper<Person>(null));
            RegularList.ForEach(x => ListOfPersons.Add(new ComboBoxNullableItemWrapper<Person>(x)));

            // Set UserSelectedItem so it targes null item
            this.SelectedWrappedItem = ListOfPersons.Single(x => x.Value ==null);

        }

        // INotifyPropertyChanged related stuff
        public event PropertyChangedEventHandler PropertyChanged;

        protected void OnPropertyChanged(string name)
        {
            PropertyChangedEventHandler handler = PropertyChanged;
            if (handler != null)
            {
                handler(this, new PropertyChangedEventArgs(name));
            }
        }
    }
}

And, finnaly your View (ok, it's a Window)

<Window x:Class="ComboBoxWrapperSample.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"        
            xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
            xmlns:local="clr-namespace:ComboBoxWrapperSample"
            xmlns:vm="clr-namespace:ComboBoxWrapperSample"
            xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
            xmlns:ignore="http://www.ignore.com"
            mc:Ignorable="d"
            d:DataContext="{d:DesignInstance {x:Type vm:SampleViewModel}, IsDesignTimeCreatable=False}"
            Title="MainWindow" Height="200" Width="300">
    <StackPanel Orientation="Vertical" Margin="10">
        <TextBlock Margin="0,10,0,0">Favorite teacher</TextBlock>
        <ComboBox ItemsSource="{Binding ListOfPersons}"
                SelectedItem="{Binding SelectedWrappedItem, Mode=TwoWay}">
        </ComboBox>
        <StackPanel Orientation="Horizontal" Margin="0,10,0,0">
            <TextBlock>Selected wrapped value:</TextBlock>
            <TextBlock Text="{Binding SelectedWrappedItem }" Margin="5,0,0,0" FontWeight="Bold"/>
        </StackPanel>
    </StackPanel>
</Window>

Reaching this point, did I mention that you could retrieve unwrapped selected item thru SelectedWrappedItem.Value property ?

Here you can get a working sample

Hope it helps someone else

Here's the ultimate super-simple solution to this problem:

Instead of having an item with a value of null in your ItemsSource, use DbNull.Value as item or as the item's value property.

That's all. You're done. No value converters, no code-behind, no xaml triggers, no wrappers, no control descendants...

It simply works!

Here's a short example for binding enum values including a "null item":

Create your ItemsSource like this:

   var enumValues = new ArrayList(Enum.GetValues(typeof(MyEnum)));

   enumValues.Insert(0, DBNull.Value);

   return enumValues;

Bind this to the ItemsSource of the ComboBox.

Bind the SelectedValue of your ComboBox to any Property having a Type of MyEnum? (ie Nullable<MyEnum>).

Done!

Background: This approach works because DbNull.Value is not the same like a C# null value, while on the other hand the framework includes a number of coercion methods to convert between those two. Eventually, this is similar to the mentioned "Null object pattern", but without the need for creating an individual null object and without the need for any value converters.

A little more elaborate than some answers here, but didn't want to have any code behind or ViewModel changes in mine. I wrote this as a WPF behavior. When attached to the XAML, it will inject a button in the visual. It will set the Default value of -1 (or you can adjust to be something else default). This is a re-usable control that is easy to add on your XAML throughout your project. Hope this helps. Open to feedback if you spot an error.

  1. There are no external references, you can use this with your code and no other DLLs. (well, it does use System.Windows.Interactivity but most will have this in WPF apps)
  2. Its re-usable throughout your application
  3. Style will conform to your themes.
  4. You can jack this up all you want
  5. I know this is an almost 6 year old thread (as of my writing in 2019), but if you like it -- make it the answer since there isn't one!

Resulting Visual:

Item Selected:

ComboBox清除示例

Behavior Code:

public class ComboBoxClearBehavior : Behavior<ComboBox>
{
    private Button _addedButton;
    private ContentPresenter _presenter;
    private Thickness _originalPresenterMargins;

    protected override void OnAttached()
    {
        // Attach to the Loaded event. The visual tree at this point is not available until its loaded.
        AssociatedObject.Loaded += AssociatedObject_Loaded;

        // If the user or code changes the selection, re-evaluate if we should show the clear button
        AssociatedObject.SelectionChanged += AssociatedObject_SelectionChanged;

        base.OnAttached();
    }

    protected override void OnDetaching()
    {
        // Its likely that this is already de-referenced, but just in case the visual was never loaded, we will remove the handler anyways.
        AssociatedObject.Loaded -= AssociatedObject_Loaded;
        base.OnDetaching();
    }

    private void AssociatedObject_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        EvaluateDisplay();
    }

    /// <summary>
    /// Checks to see if the UI should show a Clear button or not based on what is or isn't selected.
    /// </summary>
    private void EvaluateDisplay()
    {
        if (_addedButton == null) return;
        _addedButton.Visibility = AssociatedObject.SelectedIndex == -1 ? Visibility.Collapsed : Visibility.Visible;

        // To prevent the text or content from being overlapped by the button, adjust the margins if we have reference to the presenter.
        if (_presenter != null)
        {
            _presenter.Margin = new Thickness(
                _originalPresenterMargins.Left, 
                _originalPresenterMargins.Top, 
                _addedButton.Visibility == Visibility.Visible ? ClearButtonSize + 6 : _originalPresenterMargins.Right, 
                _originalPresenterMargins.Bottom);
        }
    }

    private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
    {
        // After we have loaded, we will have access to the Children objects. We don't want this running again.
        AssociatedObject.Loaded -= AssociatedObject_Loaded;

        // The ComboBox primary Grid is named  MainGrid. We need this to inject the button control. If missing, you may be using a custom control.
        if (!(AssociatedObject.FindChild("MainGrid") is Grid grid)) return;

        // Find the content presenter. We need this to adjust the margins if the Clear icon is present.
        _presenter = grid.FindChildren<ContentPresenter>().FirstOrDefault();
        if (_presenter != null) _originalPresenterMargins = _presenter.Margin;

        // Create the new button to put in the view
        _addedButton = new Button
        {
            Height = ClearButtonSize, 
            Width = ClearButtonSize,
            HorizontalAlignment = HorizontalAlignment.Right
        };


        // Find the resource for the button - In this case, our NoChromeButton Style has no button edges or chrome
        if (Application.Current.TryFindResource("NoChromeButton") is Style style)
        {
            _addedButton.Style = style;
        }

        // Find the resource you want to put in the button content
        if (Application.Current.TryFindResource("RemoveIcon") is FrameworkElement content)
        {
            _addedButton.Content = content;
        }

        // Hook into the Click Event to handle clearing
        _addedButton.Click += ClearSelectionButtonClick;

        // Evaluate if we should display. If there is nothing selected, don't show.
        EvaluateDisplay();

        // Add the button to the grid - First Column as it will be right justified.
        grid.Children.Add(_addedButton);
    }

    private void ClearSelectionButtonClick(object sender, RoutedEventArgs e)
    {
        // Sets the selected index to -1 which will set the selected item to null.
        AssociatedObject.SelectedIndex = -1;
    }

    /// <summary>
    /// The Button Width and Height. This can be changed in the Xaml if a different size visual is desired.
    /// </summary>
    public int ClearButtonSize { get; set; } = 15;
}

Usage:

<ComboBox 
 ItemsSource="{Binding SomeItemsSource, Mode=OneWay}"
 SelectedValue="{Binding SomeId, Mode=TwoWay}"
 SelectedValuePath="SomeId">
  <i:Interaction.Behaviors>
    <behaviors:ComboBoxClearBehavior />
  </i:Interaction.Behaviors>
</ComboBox>

You will need two things for this Behavior -- you may already have them, but here they are:

1.) The Button Template - The code is looking for a style. In my case, it's called NoChromeButton- If you are looking for a turnkey solution, you can add mine to your resources file:

<Style x:Key="NoChromeButton"
       TargetType="{x:Type Button}">
    <Setter Property="Background"
            Value="Transparent" />
    <Setter Property="BorderThickness"
            Value="1" />
    <Setter Property="Foreground"
            Value="{DynamicResource WindowText}" />
    <Setter Property="HorizontalContentAlignment"
            Value="Center" />
    <Setter Property="VerticalContentAlignment"
            Value="Center" />
    <Setter Property="Cursor"
            Value="Hand"/>
    <Setter Property="Padding"
            Value="1" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type Button}">
                <Grid x:Name="Chrome"
                      Background="{TemplateBinding Background}"
                      SnapsToDevicePixels="true">
                    <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                      Margin="{TemplateBinding Padding}"
                                      RecognizesAccessKey="True"
                                      SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                      VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
                </Grid>
                <ControlTemplate.Triggers>
                    <Trigger Property="IsEnabled"
                             Value="false">
                        <Setter Property="Foreground"
                                Value="#ADADAD" />
                        <Setter Property="Opacity"
                                TargetName="Chrome"
                                Value="0.5" />
                    </Trigger>
                    <Trigger
                        Property="IsMouseOver"
                        Value="True">
                        <Setter
                            TargetName="Chrome"
                            Property="Background"
                            Value="{DynamicResource ButtonBackgroundHover}" />
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Also you will need your icon for the clear. If you have one, just update the code to use that resource (named "RemoveIcon"). Otherwize.. here is mine:

<Viewbox x:Key="RemoveIcon"
         x:Shared="False"
         Stretch="Uniform">
    <Canvas Width="58"
            Height="58">
        <Path Fill="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Control, Mode=FindAncestor}}">
            <Path.Data>
                <PathGeometry Figures="M 29 0 C 13 0 0 13 0 29 0 45 13 58 29 58 45 58 58 45 58 29 58 13 45 0 29 0 Z M 43.4 40.6 40.6 43.4 29 31.8 17.4 43.4 14.6 40.6 26.2 29 14.6 17.4 17.4 14.6 29 26.2 40.6 14.6 43.4 17.4 31.8 29 Z"
                              FillRule="NonZero" />
            </Path.Data>
        </Path>
    </Canvas>
</Viewbox>

删除以下行并添加CheckBox,然后您可以执行自定义操作。

    <ComboBoxItem>(None)</ComboBoxItem>

Still not 100% happy with this solution, but the best thing I found so far, you only need to override the ComboBox Style and apply an AttachedBehaviour .

<ComboBox ItemsSource="{Binding Names}"
          ext:ComboBoxHelper.IsNullable="True" />

Source: http://xamlblog.com/PostPage.aspx?postId=16#/Posts/16

Edit: Link to the Internet Archive since the link is broken: https://web.archive.org/web/20160420174905/http://xamlblog.com/PostPage.aspx?postId=16

Please use the following code.

    <ComboBoxItem IsSelected="{Binding ClearSelectedItems}">(None)</ComboBoxItem>

In the viewmodel, catch the "ClearSelectedItems" change notification and clear the SelectedItems of ItemsControl.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM