Introduction
This article presents the minimal code necessary to display a hierarchical tree using Popups
.
Inside the Popup
of each tree level, there are displayed:
- details of a bound object
- a
ListBox
with ToggleButtons
that can be checked, and again, another Popup
will appear
Background
In this solution, nuget package PropertyChanged.Fody is used.
It has been developed using VisualStudio 2010, .NET Framework 4.0.
Using the Code
First, the mock-up model:
[ImplementPropertyChanged]
public class DataObject
{
public int ID { get; set; }
public string Name { get; set; }
public DataObject(int ID)
{
this.ID = ID;
this.Name = "Node " + this.ID;
}
}
... and the view model of a single tree node:
[ImplementPropertyChanged]
public class VM_Node
{
public bool IsChildNodesLoaded { get; set; }
public DataObject NodeData { get; set; }
public ViewableCollection <VM_Node> ChildNodes { get; set ; }
public VM_Node(DataObject data)
{
this.ChildNodes = new ViewableCollection<VM_Node>();
this.ChildNodes.View.CurrentChanged += new EventHandler(ChildNodes_View_CurrentChanged);
this.NodeData = data;
}
public void LoadChildren()
{
if (!this.IsChildNodesLoaded)
{
this.ChildNodes.ReplaceItems(DataAccessMockup.GetNodeChildren(this.NodeData.ID)
.Select(a => new VM_Node(a)));
this.IsChildNodesLoaded = true ;
}
this.ChildNodes.CurrentItem = null ;
}
void ChildNodes_View_CurrentChanged(object sender, EventArgs e)
{
if (this.ChildNodes.CurrentItem != null)
{
this.ChildNodes.CurrentItem.LoadChildren();
}
}
}
Child nodes here are loaded lazily: only when when the Popup
is opened for the first time.
The class ViewableCollection
is explained in the section Points of Interest of this article. I'm using it here to easier manage the selection in the ListBox
form the view model: When the CurrentItem
for ChildNodes
changes, the CurrentItem
's children are loaded if necessary and the selection in the child collection is then cleared. The clearing is necessary because:
- by default, the first item is set as current when the items are first loaded. So, without setting
ChildNodes.CurrentItem
to null
when the parent selection is changed, at the start all the popups from the first branch of the tree will be open from root to leaf. - this ensures that every time the
Popup
is opened, the previous selection is cleared.
The GUI of MainWindow
consists of a Button
and a Popup
that appears when we click the Button
. Popup
's content is bound to the root of the tree:
<Grid>
<Button Content="Open popup"
Command ="{Binding Path=OpenPopup, Mode=OneTime}">
<Button.CommandParameter>
<sys:Int32>0</sys:Int32>
</Button.CommandParameter>
</Button>
<Popup IsOpen="{Binding Path=IsRootPopupOpen, Mode =TwoWay}"
AllowsTransparency="True" Placement ="MousePoint"
StaysOpen="False" PopupAnimation="Fade">
<Popup.Resources >
....
</Popup.Resources>
<ContentControl Content ="{Binding Path=PopupRoot}" />
</Popup>
</Grid>
The popup's IsOpen
property is bound to IsRootPopupOpen
property of the DataContext
(of type VM_Main
). This property is set to true
when the Button
is clicked and OpenPopup
command is executed. Here, the command takes a (static) node ID as parameter and loads root node data from a mock-up data source. Because StaysOpen
is false
on the root popup, the entire popup "tree" is closed when user clicks outside of it or the parent window is deactivated.
This is the view model set as DataSource
of the main window:
[ImplementPropertyChanged]
public class VM_Main
{
public VM_Node PopupRoot { get; set; }
public bool IsRootPopupOpen { get; set; }
public VM_Main() { }
private ICommand _OpenPopup;
public ICommand OpenPopup
{
get { return _OpenPopup ??
(_OpenPopup = new DelegateCommand(a => OpenPopupCommand(a))); }
}
private void OpenPopupCommand(object item)
{
if (item == null || !(item is int)) return;
int nodeID = (int)item;
this.PopupRoot = new VM_Node(DataAccessMockup.GetNodeData(nodeID));
if (this.PopupRoot != null)
{
this.PopupRoot.LoadChildren();
this.IsRootPopupOpen = true ;
}
}
}
The layout of the popup is managed by a DataTemplate
for type VM_Node
set in popup's Resources
. Both ContentControl
inside the root popup and the one inside the child popup in the DataTemplate
itself will use this DataTemplate
(the second one "recursively"):
<Popup.Resources>
<DataTemplate DataType="{x:Type VM:VM_Node}">
<Border CornerRadius ="5" Background="AliceBlue"
BorderBrush="CornflowerBlue" BorderThickness="2" Padding ="3"
PreviewMouseDown="popup_Border_PreviewMouseDown">
<StackPanel Orientation ="Vertical" >
<TextBlock Text ="{Binding Path=NodeData.ID}"
Foreground="Black"/>
<TextBlock Text ="{Binding Path=NodeData.Name}"
Foreground="Black"/>
<Button Content ="My Button" Margin="5"/>
<ListBox ItemsSource ="{Binding Path=ChildNodes.View, Mode=OneWay}"
SelectionMode="Single"
Width="Auto" Height ="Auto"
Padding="-1" Margin="0"
BorderThickness="0"
IsSynchronizedWithCurrentItem="True"
VirtualizingStackPanel.VirtualizationMode="Standard"
VirtualizingStackPanel.IsVirtualizing="True"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation ="Vertical" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Style.Resources>
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}"
Color="Transparent" />
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}"
Color="Transparent"/>
</Style.Resources>
<Setter Property="HorizontalContentAlignment"
Value="Stretch"/>
<Setter Property="VerticalContentAlignment"
Value="Center"/>
<Setter Property="BorderBrush"
Value="MediumBlue" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<ToggleButton Name ="btnClickMe"
Height="20" Margin ="1"
Padding="3,1,3,1"
Content="{Binding Path=NodeData.Name}"
IsChecked="{Binding Path=IsSelected,
RelativeSource={RelativeSource
AncestorType=ListBoxItem}, Mode=TwoWay}"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center" />
<Popup AllowsTransparency="True" StaysOpen="True"
IsOpen="{Binding Path=IsSelected,
RelativeSource={RelativeSource
AncestorType=ListBoxItem},
Mode=OneWay}"
PlacementTarget="{Binding ElementName=btnClickMe}"
Placement="Bottom" PopupAnimation="Fade" >
<ContentControl Content ="{Binding}" />
</Popup>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
</DataTemplate >
</Popup.Resources>
StaysOpen
is true
for the Popup
in the DataTemplate
. That's because here opening/closing the Popup
is fully managed by the selection in the ListBox
. Or, more accurately, by the selection in the underlying ItemsSource
.
This is how it's done:
ListBox
is bound to the children of the current node:
ItemsSource="{Binding Path=ChildNodes.View, Mode=OneWay}"
It is vital to bind to the View
of the collection and not to the collection itself to take advantage of the functionality of the ViewableCollection
class (and avoid memory leaks).
To ensure that the ListBox
is synchronized with the CurrentItem
of the ChildNodes.View
:
SelectionMode="Single"
IsSynchronizedWithCurrentItem="True"
Popup
is opened/closed when the item in ListBox
is selected (here the binding is OneWay
):
IsOpen="{Binding Path=IsSelected,
RelativeSource={RelativeSource AncestorType=ListBoxItem}, Mode=OneWay}"
In turn, ListBoxItem
is selected when the user checkes the ToggleButton
(here the binding is TwoWay
):
IsChecked="{Binding Path=IsSelected,
RelativeSource={RelativeSource AncestorType=ListBoxItem}, Mode=TwoWay}"
>
When the ToggleButton
is checked, ListBoxItem
is selected, then next level Popup
opens and CurrentItem
changes in ChildNodes.View
, then this event handler in VM_Node
is invoked and the child nodes are loaded:
void ChildNodes_View_CurrentChanged(object sender, EventArgs e)
{
if (this.ChildNodes.CurrentItem != null)
{
this.ChildNodes.CurrentItem.LoadChildren();
}
}
Now, if we want the popups of level 4 and 3 to close when we click the Popup
of level 2:
- if there is no interactive content such as another
Button
on the template, catching MouseDown
event and clearing selection will suffice:
private void popup_Border_MouseDown(object sender, MouseButtonEventArgs e)
{
((VM_Node)((Border)sender).DataContext).ChildNodes.CurrentItem = null;
}
- if there is interactive content such as another
Button
on the template (as in this case), we will have to catch PreviewMouseDown
event, as the button's Click
event will intercept the MouseDown
event and handle it (the popups won't close):
private void popup_Border_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
var toggle = (ToggleButton)
(e.OriginalSource as DependencyObject).TryFindParentBefore<ToggleButton , Popup>();
if (toggle == null )
{
var parentPopupOrgSource = e.OriginalSource is Popup ? e.OriginalSource as Popup :
(e.OriginalSource as DependencyObject).TryFindParent<Popup>();
var parentPopupSender = sender is Popup ? sender as Popup :
(sender as DependencyObject).TryFindParent<Popup>();
if (parentPopupOrgSource != parentPopupSender) return;
((VM_Node)((Border)sender).DataContext).ChildNodes.CurrentItem = null;
}
}
If a space within some ToggleButton
was clicked, do nothing and return. Then, if not, as the PreviewMouseDown
event will bubble and be invoked multiple times, we will have to make sure we're in a correct Popup
, that is that the Popup
of a sender
is the same as that of the OriginalSource
. Only then, we clear the selection.
Points of Interest
The ViewableCollection
class used here inherits from ObservableCollection
and is a wrapper around ListCollectionView
. It gives us some advantages over a native implementation of ObservableCollection
:
- It can load items in bulk (using the method
ReplaceItems
that doesn't send multiple CollectionChanged
notifications) CurrentItem
property of ListCollectionView
can be used easily; if e.g. ListBox
has IsSynchronizedWithCurrentItem="True"
then managing selection can be done from the view model; e.g. for cleaning the selection on ListBox
:
MyViewableCollection.CurrentItem = null;
- It can be serialized to XML as
XmlArray
(here: not used):
[XmlArray]
public ViewableCollection<MyType> MyViewableCollection { get; set; }
- Sorting from the view model (here: not used)
this.MyViewableCollection.View.SortDescriptions.Add
(new SortDescription("MySortProperty", ListSortDirection.Ascending));
- Grouping from the view model (here: not used)
this.MyViewableCollection.View.GroupDescriptions.Add
(new PropertyGroupDescription("MyGroupProperty"));
- Filtering from the view model (here: not used)
this.MyViewableCollection.View.Filter = Name_FilterMethod;
private bool Name_FilterMethod (object item)
{
VM_MyObject obj = item as VM_MyObject ;
if (!obj.Name.Contains(this.FilterByName)) return true;
return false;
}
ListCollectionView
implements IEditableCollectionView
which makes modifying (and refreshing the view after modification) of the collection/objects easier (here: not used). Why it is useful is nicely explained here.
myItem.SomeProperty=newValue;
MyViewableCollection.EditableView.EditItem(myItem);
MyViewableCollection.EditableView.CommitEdit();
The idea for explicitly using ListCollectionView
for managing collections/views is taken from here to prevent memory leaks described here by explicitly associating a single ListCollectionView
with a single ObservableCollection
.