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:
public interface ISelectableElement: INotifyPropertyChanged
{
bool Selected { get; set; }
}
SelectionManager
ipmlements ISelectionManager
interface to be able to use DependancyInjection pattern:
public interface ISelectionManager: INotifyPropertyChanged
{
ISelectableElement SelectedElement { get; set; }
void AddCollection(INotifyCollectionChanged collection);
void RemoveCollection(INotifyCollectionChanged collection);
}
Helpers
PropertyHelper
is used to get 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 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.
public class ObservableCollectionEx<T> : ObservableCollection<T>
{
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.
public class SelectionManager : ISelectionManager
{
public event PropertyChangedEventHandler PropertyChanged;
public ISelectableElement SelectedElement
{
get
{
return _selectedElement;
}
set
{
_selectedElement = value;
OnPropertyChanged();
}
}
private ISelectableElement _selectedElement;
private readonly List<ISelectableElement> _elements = new List<ISelectableElement>();
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);
}
}
}
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
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.
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.
<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.