Click here to Skip to main content
15,890,336 members
Articles / Desktop Programming / WPF
Tip/Trick

Single Selection Across Multiple ItemsControls (v2)

Rate me:
Please Sign up or sign in to vote.
0.00/5 (No votes)
26 Oct 2012Apache4 min read 7K   97   3  
Another approach to implementing single selection across multiple ItemsControls

Introduction

A few weeks ago, I posted a tip that offered a way to implement a single selection set over a number of ItemsControls.  After actually using it for a while, I noticed that the behavior of the selection was not intuitive.  Rather than updating my previous post, I thought it would be beneficial to post this as an alternate solution in case someone found the first approach more to their liking.

Background 

I started by studying exactly how Windows Explorer manages selection.  Here is what I found:

  • Without holding CTRL: 
    • Mouse down on icon selects it
    • Mouse up does nothing
    • Mouse down on another icon selects it and deselects first
    • Mouse up does nothing
    • Repeat ad nauseum  
  • While holding CTRL: 
    • Mouse down on icon selects it
    • Mouse up does nothing
    • Mouse down on another icon selects it and maintains selection of first
    • Mouse up does nothing
    • Mouse down on either of selected does nothing
    • Mouse up deselects it
Once I had all of that determined, it was easier to figure out how to make my code behave the same.

The other main difference is that this approach uses a static class and string-based scopes to track the selected items.  This provided several benefits:

  1. Tracking selection of almost any object 
  2. Tracking selection of multiple types of objects within a single scope 
  3. Track multiple scopes without multiple instances of SelectionManager
The only drawback that I've been able to discern is that getting a selection set cannot be performed via properties since a scope must be specified. 

Using the code 

The code provided is an extremely simple example that can and should be expanded upon. 

To use the code you should have a thorough understanding of most of the principle behind WPF, including:


  1. Data contexts 
  2. Binding 
  3. Data templates
  4. MVVM 

Points of Interest 

The ISelectable interface and Selectable<T> class remain the same from the previous post, so I won't repost them here. 

As I mentioned, the SelectionManager class has been updated to a static class.  Also, the functionality has been expanded somewhat to include selecting multiple items at once.  There are four attached properties defined. 

  • ManageSelection - Indicates that a given item should have its selection managed. 
  • Scope - Identifies the scope of the selection set.  Scope is not limited to any particular type or location within the application.  A single scope will even extend between windows. 
  • Target - Identifies the object to be tracked.  If this is not set, the control where ManageSelection is set is used. 
  • IsSelected - Set by the SelectionManager class to indicate that an object is selected.  This attached property is only to be set by the SelectionManager

The ManageSelection attached property has a change handler to hook into the MouseDown and MouseUp events for the control to which it is attached.

C#
private static void OnManageSelectionChanged(DependencyObject d, 
                                             DependencyPropertyChangedEventArgs e)
{
    var ui = d as UIElement;
    if (ui == null) return;
    if ((bool)e.NewValue)
    {
        ui.MouseDown += ElementMouseDown;
        ui.MouseUp += ElementMouseUp;
    }
    else
    {
        ui.MouseDown -= ElementMouseDown;
        ui.MouseUp -= ElementMouseUp;
    }
} 

In order to track scope, the SelectionManager maintains an internal Dictionary<string, List<object>>.  So for each scope, there is a separate selection set.

Two methods exist for getting selected items: GetSelectedItem, which gets the most recently selected item for a given scope; and GetSelectedItems, which gets all of the selected items for a given scope.  Both of these methods take the scope string as their only parameter. 

C#
public static object GetSelectedItem(string scope)
{
    if (!_selectedItems.ContainsKey(scope)) return null;
    else return _selectedItems[scope].LastOrDefault();
}
public static IEnumerable<object> GetSelectedItems(string scope)
{
    return _selectedItems.ContainsKey(scope)
        ? _selectedItems[scope].AsReadOnly()
        : (new List<object>()).AsReadOnly();
}  

The SelectionManager would not be very useful if it did not include a way to select items in code.  The following performs this task.

C#
public static void Select(string scope, ISelectable obj)
{
    if (!_selectedItems.ContainsKey(scope) || (_selectedItems[scope] == null))
        _selectedItems.Add(scope, new List<object>());
    _selectedItems[scope].ForEach(t => ((ISelectable)t).IsSelected = false);
    _selectedItems[scope].Clear();
    if (obj == null)
    {
        OnSelectionChanged(new SelectionChangedEventArgs(scope));
        return;
    }
    _alreadySelected = obj.IsSelected;
    _selectedItems[scope].Add(obj);
    obj.IsSelected = true;
}
public static void Select(string scope, UIElement obj)
{
    if (!_selectedItems.ContainsKey(scope) || (_selectedItems[scope] == null))
        _selectedItems.Add(scope, new List<object>());
    _selectedItems[scope].ForEach(el => SetIsSelected((DependencyObject)el, false));
    _selectedItems[scope].Clear();
    if (obj == null)
    {
        OnSelectionChanged(new SelectionChangedEventArgs(scope));
        return;
    }
    _alreadySelected = GetIsSelected(obj);
    _selectedItems[scope].Add(obj);
    SetIsSelected(obj, true);
} 

The specific behavior described above is implemented in the handlers for the MouseDown and MouseUp events we hooked into with the ManageSelection change handler.

C#
private static void ElementMouseDown(object sender, MouseButtonEventArgs e)
{
    var ui = (UIElement) sender;
    var scope = GetScope(ui);
    if (!_selectedItems.ContainsKey(scope) || (_selectedItems[scope] == null))
        _selectedItems.Add(scope, new List<object>());
    var target = GetTarget(ui);
    if (e.ClickCount != 1) return;
    if ((Keyboard.Modifiers & ModifierKeys.Control) == 0)
    {
        if (target == null)
            _selectedItems[scope].ForEach(el => SetIsSelected((DependencyObject) el, false));
        else
            _selectedItems[scope].ForEach(t => ((ISelectable) t).IsSelected = false);
        _selectedItems[scope].Clear();
    }
    if (target == null)
    {
        _alreadySelected = GetIsSelected(ui);
        _selectedItems[scope].Add(ui);
        SetIsSelected(ui, true);
    }
    else
    {
        _alreadySelected = target.IsSelected;
        _selectedItems[scope].Add(target);
        target.IsSelected = true;
    }
    OnSelectionChanged(new SelectionChangedEventArgs(scope));
    e.Handled = true;
}
private static void ElementMouseUp(object sender, MouseButtonEventArgs e)
{
    var ui = (UIElement)sender;
    var scope = GetScope(ui);
    var target = GetTarget(ui);
    if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return;
    if (!_alreadySelected) return;
    if (target == null)
    {
        _selectedItems[scope].Remove(ui);
        SetIsSelected(ui, false);
    }
    else
    {
        _selectedItems[scope].Remove(target);
        target.IsSelected = false;
    }
    OnSelectionChanged(new SelectionChangedEventArgs(scope));
    e.Handled = true;
} 

Finally, an event is provided to notify when the selection has changed as well as a common method to raise it.

C#
public static event EventHandler<SelectionChangedEventArgs> SelectionChanged;
private static void OnSelectionChanged(SelectionChangedEventArgs e)
{
    if (SelectionChanged != null)
        SelectionChanged(null, e);
} 

The SelectionChangedEventArgs class simply derives from EventArgs and adds a Scope property to signify which selection set has changed.

Now that all that is set up, we can finally build a small app to use the SelectionManager.  Consider a window with three ListBoxes side by side.  The first contains only ints, the second only DateTimes, and the last only strings.  Further, we would like to track the ints and DateTimes as a single selection set while allowing the strings to be selected separately.  Below is how you would declare this.

XML
<Window x:Class="SingleSelection.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:SingleSelection"
        Title="MainWindow" Height="350" Width="525">
    <Window.Resources>
        <DataTemplate x:Key="IntsAndDatesTemplate" DataType="{x:Type local:ISelectable}">
            <Border x:Name="Border" Background="Transparent"
                    local:SelectionManager.ManageSelection="True"
                    local:SelectionManager.Scope="IntsAndDates"
                    local:SelectionManager.Target="{Binding}">
                <TextBlock x:Name="Content" Text="{Binding Value}"/>
            </Border>
            <DataTemplate.Triggers>
                <DataTrigger Binding="{Binding IsSelected}" Value="True">
                    <Setter TargetName="Border" Property="Background"
                            Value="{StaticResource {x:Static SystemColors.HighlightBrushKey}}"/>
                    <Setter TargetName="Content" Property="Foreground"
                            Value="{StaticResource {x:Static SystemColors.HighlightTextBrushKey}}"/>
                </DataTrigger>
            </DataTemplate.Triggers>
        </DataTemplate>
        <DataTemplate x:Key="StringsTemplate" DataType="{x:Type local:ISelectable}">
            <Border x:Name="Border" Background="Transparent"
                    local:SelectionManager.ManageSelection="True"
                    local:SelectionManager.Scope="Strings"
                    local:SelectionManager.Target="{Binding}">
                <TextBlock x:Name="Content" Text="{Binding Value}"/>
            </Border>
            <DataTemplate.Triggers>
                <DataTrigger Binding="{Binding IsSelected}" Value="True">
                    <Setter TargetName="Border" Property="Background"
                            Value="{StaticResource {x:Static SystemColors.HighlightBrushKey}}"/>
                    <Setter TargetName="Content" Property="Foreground"
                            Value="{StaticResource {x:Static SystemColors.HighlightTextBrushKey}}"/>
                </DataTrigger>
            </DataTemplate.Triggers>
        </DataTemplate>
    </Window.Resources>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <ListBox ItemsSource="{Binding Numbers}" HorizontalContentAlignment="Stretch"
                 ItemTemplate="{StaticResource IntsAndDatesTemplate}" />
        <ListBox Grid.Column="1" ItemsSource="{Binding Dates}"
                 ItemTemplate="{StaticResource IntsAndDatesTemplate}"
                 HorizontalContentAlignment="Stretch" />
        <ListBox Grid.Column="3" ItemsSource="{Binding Strings}"
                 ItemTemplate="{StaticResource StringsTemplate}"
                 HorizontalContentAlignment="Stretch" />
    </Grid>
</Window>

There are two DataTemplates for the ISelectable type.  Each one specifies a different scope.  Each ListBox must indicate which DataTemplate it should use.

Compared to the previous version, the XAML is longer (due to the extra DataTemplate), but much less complex as the InputBindings have been removed.  The view model is also simplified since it doesn't need the ICommand properties and implementation. 

History 

  • 2012/10/25 - Published tip 

License

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


Written By
Software Developer
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --