Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

Multi-level WPF Popup Bound to a Hierarchical Tree

4.75/5 (3 votes)
17 Jun 2016CPOL4 min read 15.7K   304  
This article presents the minimal code necessary to display a hierarchical tree using Popups.

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:

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

C#
 [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 ; // clear selection

     }

     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:

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

C#
[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"):

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

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

C#
SelectionMode="Single"
IsSynchronizedWithCurrentItem="True"

Popup is opened/closed when the item in ListBox is selected (here the binding is OneWay):

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

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

C#
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:
    C#
    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):
    C#
    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:
    C#
    MyViewableCollection.CurrentItem = null;
  • It can be serialized to XML as XmlArray (here: not used):
    C#
    [XmlArray]
    public ViewableCollection<MyType> MyViewableCollection { get; set; }
  • Sorting from the view model (here: not used)
    C#
    this.MyViewableCollection.View.SortDescriptions.Add
    (new SortDescription("MySortProperty", ListSortDirection.Ascending));
  • Grouping from the view model (here: not used)
    C#
    this.MyViewableCollection.View.GroupDescriptions.Add
    (new PropertyGroupDescription("MyGroupProperty"));
  • Filtering from the view model (here: not used)
    C#
    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.
    C#
    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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)