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:
- Tracking selection of almost any object
- Tracking selection of multiple types of objects within a single scope
- 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:
- Data contexts
- Binding
- Data templates
- 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.
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.
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.
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.
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.
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 ListBox
es side by side. The first contains only ints, the second only DateTime
s, and the last only strings. Further, we would like to track the int
s and DateTime
s as a single selection set while allowing the string
s to be selected separately. Below is how you would declare this.
<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 DataTemplate
s 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 InputBinding
s 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