Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Selection Manager for WPF/MVVM

DZone's Guide to

Selection Manager for WPF/MVVM

In this article, we show you how to select different kinds of elements programmatically in the MVVM Lite platform, using C# as our coding language.

· Web Dev Zone ·
Free Resource

Learn how error monitoring with Sentry closes the gap between the product team and your customers. With Sentry, you can focus on what you do best: building and scaling software that makes your users’ lives better.

Introduction

It is quite a common situation when UI shows a lot of different kinds of elements (text blocks, images, graphics, etc.) structured in different ways (lists, trees, etc.), but only one of these elements could be selected at the same time.

In this article, I will try to create a class which will help to deal with selection. WPF is used for the demo, but this approach can be used in UWP, Xamarin, Windows Forms, and maybe some other technologies.

Source code can also be found on GitHub

Interfaces

To be handled by the Selection Manager, and object should implement the ISelectableElement interface:

/// <summary>
/// Classes must implement this interface to be handled by <see cref="SelectionManager"/>
/// <remarks>Property <see cref="Selected"/> have to fire PropertyChanged event./></remarks>
/// </summary>
public interface ISelectableElement: INotifyPropertyChanged
{
    /// <summary>
    /// Selection flag.
    /// </summary>
    bool Selected { get; set; }
}

SelectionManager ipmlements the ISelectionManager interface so that it's able to use the dependency injection pattern:

/// <summary>
/// Manages SelectedElement in hierarchical collection of elements (only one element selected at the particular moment).
/// </summary>
public interface ISelectionManager: INotifyPropertyChanged
{
    /// <summary>
    /// Gets and sets selected element
    /// </summary>
    ISelectableElement SelectedElement { get; set; }

    /// <summary>
    /// Adds collection of the objects to manager
    /// </summary>
    /// <param name="collection">The collection to be added</param>
    void AddCollection(INotifyCollectionChanged collection);

    /// <summary>
    /// Removes collection of the objects from manager
    /// </summary>
    /// <param name="collection">The collection to be removed</param>
    void RemoveCollection(INotifyCollectionChanged collection);
}

Helpers

PropertyHelper is used to get a property name:

internal class PropertyHelper
{
    public static string GetPropertyName<T>(Expression<Func<T>> propertyLambda)
    {
        var me = propertyLambda.Body as MemberExpression;

        if (me == null)
        {
            throw new ArgumentException(
                "You must pass a lambda of the form: '() => Class.Property' or '() => object.Property'");
        }

        return me.Member.Name;
    }
}

ObservableCollection does not fire CollectionChanged with the list of removed (old) items after calling Clear(). It is possible to use ObservableCollection and not the Clear() method or to use ObservableCollectionEx to be able to use the Clear() method.

/// <summary>
/// Works the same as <see cref="ObservableCollection{T}"/>. 
/// Fires <see cref="ObservableCollection{T}.CollectionChanged"/> event with <see cref="NotifyCollectionChangedEventArgs.Action"/> equal to <see cref="NotifyCollectionChangedAction.Remove"/> after calling <see cref="Collection{T}.Clear"/> methods.
/// <see cref="ObservableCollection{T}"/> fires event with <see cref="NotifyCollectionChangedEventArgs.Action"/> equal to <see cref="NotifyCollectionChangedAction.Reset"/> and empty list of old items./>
/// </summary>
/// <typeparam name="T">The type of elements in the list.</typeparam>
public class ObservableCollectionEx<T> : ObservableCollection<T>
{
    /// <summary>
    /// Removes all items from the collection and fire CollectionChanged
    /// </summary>
    protected override void ClearItems()
    {
        var items = new List<T>(Items);
        base.ClearItems();
        OnCollectionChanged(
            new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, items));
    }
}

SelectionManager

AddCollection adds all elements in the collection to the internal list and searches for subelements using reflection (if some of the element properties implement ObservableCollection<> and elements of this collection implement ISelectableElement this collection also will be managed by SelectionManager).

RemoveCollection removes all elements and subelements from SelectionManager

SelectionManager will handle adding and removing subelements automatically.

public class SelectionManager : ISelectionManager
{
    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>
    /// Gets and sets selected element
    /// </summary>
    public ISelectableElement SelectedElement
    {
        get
        {
            return _selectedElement;
        }

        set
        {
            _selectedElement = value;
            OnPropertyChanged();
        }
    }

    private ISelectableElement _selectedElement;
    private readonly List<ISelectableElement> _elements = new List<ISelectableElement>();

    /// <summary>
    /// Adds collection of the objects to manager
    /// </summary>
    /// <param name="collection">The collection to be added</param>
    public void AddCollection(INotifyCollectionChanged collection)
    {
        collection.CollectionChanged += collection_CollectionChanged;
        foreach (var element in (ICollection)collection)
        {
            var selectableElement = element as ISelectableElement;
            if (selectableElement != null)
            {
                AddElement(selectableElement);
            }
        }
    }

    /// <summary>
    /// Removes collection of the objects from manager
    /// </summary>
    /// <param name="collection">The collection to be removed</param>
    public void RemoveCollection(INotifyCollectionChanged collection)
    {
        collection.CollectionChanged -= collection_CollectionChanged;
        foreach (var element in (ICollection)collection)
        {
            var selectableElement = element as ISelectableElement;
            if (selectableElement != null)
            {
                RemoveElement(selectableElement);
            }
        }
    }

    private void OnPropertyChanged([CallerMemberName] string property = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
    }

    private void AddElement(ISelectableElement element)
    {
        _elements.Add(element);
        element.PropertyChanged += element_PropertyChanged;
        AddSelectableElements(element);
        if (_elements.Any() && _elements.All(e => !e.Selected))
        {
            _elements[0].Selected = true;
        }
    }

    private void RemoveElement(ISelectableElement element)
    {
        _elements.Remove(element);
        RemoveSelectableElements(element);
        element.PropertyChanged -= element_PropertyChanged;

        if (SelectedElement == element)
        {
            SelectedElement = null;
            if (_elements.Count > 0)
            {
                _elements[0].Selected = true;
            }
        }
    }

    private void element_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        var currentElement = (ISelectableElement)sender;
        if (e.PropertyName != PropertyHelper.GetPropertyName(() => currentElement.Selected))
        {
            return;
        }

        if (currentElement.Selected)
        {
            foreach (var selectedElement in _elements
                .Where(element => element != currentElement && element.Selected))
            {
                selectedElement.Selected = false;
            }

            SelectedElement = currentElement;
        }
        else
        {
            SelectedElement = null;
        }
    }

    private void collection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null)
        {
            foreach (var item in e.NewItems)
            {
                if (e.OldItems == null || !e.OldItems.Contains(item))
                {
                    var element = item as ISelectableElement;
                    if (element != null)
                    {
                        AddElement(element);
                    }
                }
            }
        }

        if (e.OldItems != null)
        {
            foreach (var item in e.OldItems)
            {
                if (e.NewItems == null || !e.NewItems.Contains(item))
                {
                    var element = item as ISelectableElement;
                    if (element != null)
                    {
                        RemoveElement(element);
                    }
                }
            }
        }
    }

    private void AddSelectableElements(ISelectableElement rootElement)
    {
        foreach (var prop in rootElement.GetType().GetProperties().Where(IsPropertyObservable))
        {
            var value = (INotifyCollectionChanged)prop.GetValue(rootElement);
            AddCollection(value);
        }
    }

    private void RemoveSelectableElements(ISelectableElement rootElement)
    {
        foreach (var prop in rootElement.GetType().GetProperties().Where(IsPropertyObservable))
        {
            var value = (INotifyCollectionChanged)prop.GetValue(rootElement);
            RemoveCollection(value);
        }
    }

    private bool IsPropertyObservable(PropertyInfo prop)
    {
        if (!prop.PropertyType.IsGenericType)
        {
            return false;
        }

        var observableCollectionType = GetObservableCollectionType(prop.PropertyType);
        if (observableCollectionType != null &&
            typeof(ISelectableElement).IsAssignableFrom(observableCollectionType.GenericTypeArguments[0]))
        {
            return true;
        }

        return false;
    }

    private Type GetObservableCollectionType(Type type)
    {
        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ObservableCollection<>))
        {
            return type;
        }

        if (type.BaseType == null)
        {
            return null;
        }

        return GetObservableCollectionType(type.BaseType);
    }
}

Tests

Image title

NUnit and NSubstitute are used for writing Unit Tests.

Demo

Thi demo application contains a list and a tree. The selection is managed by SelectionManager.

The MVVM Light framework was used to make the code more compact and clear.

There are two types of the objects which support selection.

class ListElementViewModel : ViewModelBase, ISelectableElement
{
    private string _description;
    public string Description
    {
        get { return _description; }
        set { Set(ref _description, value); }
    }

    private bool _selected;
    public bool Selected
    {
        get { return _selected; }
        set { Set(ref _selected, value); }
    }
}


class HierarchicalElementViewModel: ViewModelBase, ISelectableElement
{
    private string _name;
    public string Name
    {
        get { return _name; }
        set { Set(ref _name, value); }
    }

    public ObservableCollection<HierarchicalElementViewModel> Subitems { get; set; }

    private bool _selected;
    public bool Selected
    {
        get { return _selected; }
        set { Set(ref _selected, value); }
    }

    public ICommand AddSubitemCommand { get; }
    public ICommand RemoveCommand { get; }

    public HierarchicalElementViewModel ParentViewModel { get; }

    public HierarchicalElementViewModel(HierarchicalElementViewModel parentViewModel)
    {
        ParentViewModel = parentViewModel;
        Subitems = new ObservableCollection<HierarchicalElementViewModel>();
        AddSubitemCommand = new RelayCommand(Add);
        RemoveCommand = new RelayCommand(Remove, () => ParentViewModel != null);
    }

    private void Add()
    {
        Subitems.Add(new HierarchicalElementViewModel(this) { Name = "Child Element" });
    }
    private void Remove()
    {
        ParentViewModel.Subitems.Remove(this);
    }
}

MainViewModel contains two collections of these elements.

class MainViewModel : ViewModelBase
{
    public ObservableCollection<HierarchicalElementViewModel> HierarchicalElements { get; }

    public ObservableCollection<ListElementViewModel> ListElements { get; }

    public RelayCommand AddHierarchicalElementCommand { get; }

    public RelayCommand RemoveHierarchicalElementCommand { get; }

    public RelayCommand AddListElementCommand { get; }

    public RelayCommand RemoveListElementCommand { get; }

    public ISelectionManager Manager { get; }

    public MainViewModel()
    {
        HierarchicalElements = new ObservableCollection<HierarchicalElementViewModel>();
        ListElements = new ObservableCollection<ListElementViewModel>();
        AddHierarchicalElementCommand = new RelayCommand(AddHierarchicalElement);
        RemoveHierarchicalElementCommand = new RelayCommand(
            RemoveHierarchicalElement,
            () => Manager.SelectedElement is HierarchicalElementViewModel);
        AddListElementCommand = new RelayCommand(AddListElement);
        RemoveListElementCommand = new RelayCommand(
            RemoveListElement,
            () => Manager.SelectedElement is ListElementViewModel);
        Manager = new SelectionManager.SelectionManager();
        Manager.PropertyChanged += ManagerOnPropertyChanged;
        Manager.AddCollection(HierarchicalElements);
        Manager.AddCollection(ListElements);
    }

    private void AddHierarchicalElement()
    {
        var selectedHierarchicalElement = Manager.SelectedElement as HierarchicalElementViewModel;
        if (selectedHierarchicalElement != null)
        {
            var newItem = new HierarchicalElementViewModel(selectedHierarchicalElement) { Name = "Child Element" };
            selectedHierarchicalElement.Subitems.Add(newItem);
            newItem.Selected = true;
        }
        else
        {
            var newItem = new HierarchicalElementViewModel(null) { Name = "Root Element" };
            HierarchicalElements.Add(newItem);
            newItem.Selected = true;
        }
    }

    private void RemoveHierarchicalElement()
    {
        var hierarchicalElement = Manager.SelectedElement as HierarchicalElementViewModel;

        if (hierarchicalElement?.ParentViewModel != null)
        {
            hierarchicalElement.ParentViewModel.Subitems.Remove(hierarchicalElement);
        }
        else
        {
            HierarchicalElements.Remove(hierarchicalElement);
        }
    }

    private void AddListElement()
    {
        var newItem = new ListElementViewModel { Description = "List Element" };
        ListElements.Add(newItem);
        newItem.Selected = true;
    }

    private void RemoveListElement()
    {
        ListElements.Remove((ListElementViewModel)Manager.SelectedElement);
    }
    private void ManagerOnPropertyChanged(object sender, PropertyChangedEventArgs propertyChangedEventArgs)
    {
        RemoveHierarchicalElementCommand.RaiseCanExecuteChanged();
        RemoveListElementCommand.RaiseCanExecuteChanged();
    }
}

The MainForm is written in XML code.

<Window x:Class="SelectionManagerDemo.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:viewModel="clr-namespace:SelectionManagerDemo.ViewModel"
        mc:Ignorable="d"
        Title="Selection Manager Demo"
        Height="350"
        Width="525"
        d:DataContext="{d:DesignInstance IsDesignTimeCreatable=False, d:Type=viewModel:MainViewModel}">
    <Grid>
        <Grid.Resources>
            <DataTemplate DataType="{x:Type viewModel:ListElementViewModel}">
                <StackPanel Orientation="Horizontal">
                    <Ellipse Fill="AliceBlue"
                             Height="15"
                             Width="15"
                             Stroke="Blue"
                             StrokeThickness="2"
                             Margin="5"
                             VerticalAlignment="Center" />
                    <TextBlock Text="{Binding Description}"
                               VerticalAlignment="Center"
                               Margin="5" />
                </StackPanel>
            </DataTemplate>
            <DataTemplate DataType="{x:Type viewModel:HierarchicalElementViewModel}">
                <StackPanel Orientation="Horizontal">
                    <Polygon Points="0,0 15,0 15,15 0,15"
                             Stroke="Crimson"
                             StrokeThickness="2"
                             Margin="5"
                             VerticalAlignment="Center"
                             Fill="AliceBlue" />
                    <TextBlock Text="{Binding Name}"
                               VerticalAlignment="Center"
                               Margin="5" />
                </StackPanel>
            </DataTemplate>
        </Grid.Resources>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <StackPanel Orientation="Horizontal">
            <TextBlock Text="List Elements"
                       VerticalAlignment="Center"
                       Margin="3" />
            <Button Content="+"
                    Margin="3"
                    Width="25"
                    Height="25"
                    Command="{Binding AddListElementCommand}" />
            <Button Content="-"
                    Margin="3"
                    Width="25"
                    Height="25"
                    Command="{Binding RemoveListElementCommand}" />
        </StackPanel>
        <StackPanel Grid.Row="0"
                    Grid.Column="1"
                    Orientation="Horizontal">
            <TextBlock Text="Hierarchical Elements"
                       VerticalAlignment="Center" />
            <Button Content="+"
                    Margin="3"
                    Width="25"
                    Height="25"
                    Command="{Binding AddHierarchicalElementCommand}" />
            <Button Content="-"
                    Margin="3"
                    Width="25"
                    Height="25"
                    Command="{Binding RemoveHierarchicalElementCommand}" />
        </StackPanel>
        <ListBox Grid.Row="1"
                 Grid.Column="0"
                 ItemsSource="{Binding ListElements}">
            <ListBox.ItemContainerStyle>
                <Style TargetType="{x:Type ListBoxItem}"
                       d:DataContext="{d:DesignInstance viewModel:ListElementViewModel}">
                    <Setter Property="IsSelected"
                            Value="{Binding Selected, Mode=TwoWay}" />
                </Style>
            </ListBox.ItemContainerStyle>
        </ListBox>
        <TreeView Grid.Row="1"
                  Grid.Column="1"
                  ItemsSource="{Binding HierarchicalElements}">
            <TreeView.ItemContainerStyle>
                <Style TargetType="{x:Type TreeViewItem}"
                       d:DataContext="{d:DesignInstance viewModel:HierarchicalElementViewModel}">
                    <Setter Property="IsSelected"
                            Value="{Binding Selected, Mode=TwoWay}" />
                    <Setter Property="IsExpanded"
                            Value="True" />
                </Style>
            </TreeView.ItemContainerStyle>
            <TreeView.ItemTemplate>
                <HierarchicalDataTemplate ItemsSource="{Binding Subitems}">
                    <ContentPresenter Content="{Binding}" />
                </HierarchicalDataTemplate>
            </TreeView.ItemTemplate>
        </TreeView>
        <StackPanel Grid.Row="2"
                    Grid.Column="0"
                    Grid.ColumnSpan="2"
                    Orientation="Horizontal">
            <TextBlock Text="Selected Element:"
                       Margin="5"
                       VerticalAlignment="Center" />
            <ContentPresenter Content="{Binding Manager.SelectedElement}"
                              VerticalAlignment="Center" />
        </StackPanel>
    </Grid>
</Window>

The demo should look like this:

Image title

Image title

What’s the best way to boost the efficiency of your product team and ship with confidence? Check out this ebook to learn how Sentry's real-time error monitoring helps developers stay in their workflow to fix bugs before the user even knows there’s a problem.

Topics:
wpf ,c# ,mvvm ,xaml ,web dev

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}