Click here to Skip to main content
15,884,099 members
Articles / Desktop Programming / WPF

Selection Manager for WPF/MVVM

Rate me:
Please Sign up or sign in to vote.
4.89/5 (6 votes)
15 Sep 2017MIT1 min read 12.2K   210   13  
The article presents idea and implementation of class to manage selection of only one element in different linear and hierarchical structures

Source Code

Introduction

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

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

Interfaces

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

C#
/// <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 ISelectionManager interface to be able to use DependancyInjection pattern:

C#
/// <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 property name:

C#
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 list of removed (old) items after calling Clear(). It is possible to use ObservableCollection and do not use Clear() method or use ObservableCollectionEx to be able to use Clear() method.

C#
/// <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 searchs 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.

C#
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

Tests

NUnit and NSubstitute are used for writing Unit Tests.

Demo

Demo application contains list and tree. Selection is managed by SelectionManager.

MVVM Light framework was used to make 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 this elements.

C#
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();
    }
}

MainForm xaml code.

XML
<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>

Demo looks like this.

Image 2

Image 3

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
Software Developer
United States United States
Full Stack Software Developer with major experience in enterprise software development for different industries.
Have a experience in wide range of technologies:
- JavaScript: React, Redux, TypeScript, Saga, Thunk, Cordova, Jest, Enzyme, Material Design, React-MD, Semantic UI, WebStorm
- .Net: C#, WPF, WCF, Windows Forms
- Java: Spring, Spring Boot, Spring Cloud, Spring Data, microservices, jUnit, Mochito, IntelliJ
- DB: Kafka, Oracle, SQL Server, PL/SQL

Comments and Discussions

 
-- There are no messages in this forum --