-[Danny Chen]- Blog of an ASP.NET QA tester

Tips and info about Site Navigation, ImageMap, Menu and other cool ASP.NET v2.0 features.

October 2005 - Posts

Displaying Sibling Nodes in a Tree or Menu

I got an email with this question and a request that I post the solution on my blog so here it is:

Consider a sitemap with the following structure: 

The nodes "c", "e", and "h" (highlighted in yellow) are considered sibling nodes because they have the same parent.

Q: Can I display all the sibling nodes of the current node in a TreeView or Menu?

The answer is yes, and it's easy: 

<asp:SiteMapDataSource ID="SiteMapDataSource1" runat="server" ShowStartingNode="False" StartFromCurrentNode="True" StartingNodeOffset="-1" />

However, a more challenging question is:  Can I display all the nodes at the same depth as the current node (lets assume this is node "c")?  This would include the nodes "c", "e", "h" but also "r", and "u" (all the ones highlighted in green).

The answer is yes (of course) but it has to be done after-the-fact in the Tree or Menu.  There are a couple "tricks", however.  Here's the code for a TreeView:

    ' Databound event handler for a TreeView   
    Protected Sub TreeView_DataBound(ByVal sender As Object, ByVal e As System.EventArgs)
        Dim Tree As TreeView = sender
        ' Only prune the tree if a node is selected
        If Tree.SelectedNode IsNot Nothing Then
            Dim childNodes As New Generic.List(Of TreeNode)()
            Dim childNodeCollection As New TreeNodeCollection()
            FindNodesAtDepth(Tree.Nodes, Tree.SelectedNode.Depth, childNodes)
            ' Copy from List(of TreeNode) to TreeNodeCollection
            For Each node As TreeNode In childNodes
            ' Clear the TreeView nodes and add the nodes we found
            CloneTreeNodeHierarchyRecursive(childNodeCollection, Tree.Nodes)
        End If
    End Sub

  This function, conceptually, is pretty straight forward.  Collect a list of the Nodes at the depth of the selected node, clear the nodes in the tree and add them back into the Tree.  However, in practice, the helpers needed make it a bit more complicated. 


    ' Recursively searches the tree for all nodes at a given depth
    ' and adds them to a list of TreeNodes
    Private Sub FindNodesAtDepth(ByVal collection As TreeNodeCollection, _
                                 ByVal targetDepth As Integer, _
                                 ByVal targetNodes As Generic.List(Of TreeNode))
        For Each node As TreeNode In collection
            If node.Depth = targetDepth Then
            ElseIf node.Depth < targetDepth Then
                FindNodesAtDepth(node.ChildNodes, targetDepth, targetNodes)
            End If
    End Sub

There's not much to say about this function, it's pretty simple.  It's a depth-first recursive function that creates a list of all the nodes with Depth=targetDepth.


    ' Recursively creates a clone of a given TreeNode Hierarchy   
    Private Sub CloneTreeNodeHierarchyRecursive(ByVal inCollection As TreeNodeCollection, _
                                                ByVal outCollection As TreeNodeCollection)
        For Each node As TreeNode In inCollection
            Dim clonedNode As TreeNode = CType(node, ICloneable).Clone()           
            If node.ChildNodes.Count > 0 Then
                CloneTreeNodeHierarchyRecursive(node.ChildNodes, clonedNode.ChildNodes)
            End If                       
    End Sub

This function is interesting and I had some fun writing it.  It's also a depth first recursive function.  It's job is to create a copy of the hierarchy. 

Some quick Q&A about this solution:

Q: Why did I copy all the nodes into a list and then to a collection, why not just pass the collection into FindNodesAtDepth()?

A: TreeNodes (and MenuItems) can only live in one collection at a time.  When they are added to a collection, they are removed from any collection they previously were in.  This causes the initial collection the Node was in to change and .NET does not allow a collection to change while it's being enumerated.

Q: Why did I go through all the business of recursively cloning all the nodes instead of adding them directly to the Tree?

A: This is because, currently, TreeNodes (and MenuItems) cache the Depth property once it is calculated.  At this time, there is no straight forward way to tell an entire hierarchy to recalculate their depth except to create a new node which hasn't calculated it's depth.  If I hadn't done this, the TreeNodes would actually render themselves deeper than expected. 

Link to VB Code Listing
Link to C# Code Listing

Adding items to the end of a SiteMapPath

This question came up on the ASP.NET forums and I thought it was worth mentioning here.  There are some instances when it would be useful to add an extra bit of detail to the end of a SiteMapPath.  For example, lets say there is a MultiView on the page and you'd like the Path to look like:

Home >> Parent Page >> Current Page >> Current View

A SiteMapResolve event handler wouldn't really work here since we need intimate knowledge about the current page's controls.  Here's a couple solutions.

The "cheater" way (easiest) :

        <asp:SiteMapPath ID="SiteMapPath2" runat="server" PathSeparator=" | " >
                <asp:Label ID="Label1" runat="server" Text='<%# Eval("Title") %>'></asp:Label>
                <asp:Label ID="Label3" runat="server" Text='<%# SiteMapPath2.PathSeparator %>'></asp:Label>
                <asp:Label ID="Label2" runat="server" Text='<%# MultiView1.GetActiveView().ID %>'></asp:Label>

The "hack" (minimum code) :

    Protected Sub SiteMapPath1_PreRender(ByVal sender As Object, ByVal e As System.EventArgs)
        Dim sep As New Literal()
        sep.Text = SiteMapPath1.PathSeparator
        Dim view As New Literal()
        view.Text = MultiView1.GetActiveView().ID
        SiteMapPath1.Controls.AddAt(-1, sep)
        SiteMapPath1.Controls.AddAt(-1, view)       
    End Sub

The "nearly complete" (most involved) :

    Protected Sub SiteMapPath1_PreRender(ByVal sender As Object, ByVal e As System.EventArgs)
        Dim sepItem As New SiteMapNodeItem(-1, SiteMapNodeItemType.PathSeparator)
        Dim sepTemplate As ITemplate = SiteMapPath1.PathSeparatorTemplate
        If sepTemplate Is Nothing Then
            Dim separator As New Literal()
            separator.Text = SiteMapPath1.PathSeparator
        End If
        Dim viewItem As New SiteMapNodeItem(-1, SiteMapNodeItemType.Current)
        Dim viewName As New Literal()
        viewName.Text = MultiView1.GetActiveView().ID
        SiteMapPath1.Controls.AddAt(-1, sepItem)
        SiteMapPath1.Controls.AddAt(-1, viewItem)
    End Sub

Each of these has some pros and cons.  The primary trade off's are complexity vs integration with the SiteMapPath.  Clearly the first two solutions don't take into account SeparatorTemplates.  They also make it difficult to apply the controls styles to the new elements.  However, even the nearly complete way won't be completely seamless with the SiteMapPath.  Primarily the SiteMapPath merges NodeStyle and CurrentNodeStyle to create the style that actually gets applied to the CurrentNode (I'm assuming here that we'll just apply CurrentNodeStyle again).  Since that  merged style is internal and generated on the fly in SiteMapPath, we can't access it.  However, this doesn't stop someone from applying their own styles through code and properties instead.

Two posts in one week!  I'm on a roll.

I wrote a custom provider and now autoselecting nodes in Menu and TreeView doesn't work?

Here's a question I get quite often.  The problem: Menu and TreeView won't autoselect the current node as you're navigating around the site.  It usually happens two ways.  First: someone decided to bind the Menu or TreeView through DataSource instead of DataSourceID or Second: Someone wrote a custom SiteMapProvider.  So what's happening?

Actually, there are two problems here.  In order for Menu/TreeView (here on lets just pretend it's for Menu since the code in TreeView is identical for all intensive purposes and this post applies to both equally) to autoselect a node it must satisfy two conditions: 

   1) Must be bound to a SiteMapDataSource through DataSourceID
   2) The url of the Current Node has to match the key of the item

Ok, so that 2nd one is really an oversight on our part (and should eventually get fixed) but it's important to know about it so that we can make sites work Today.  It so happens that XmlSiteMapProvider found it useful to set the key to the url so this problem is avoided.  Do we recommend it as a best practice?  Not necessarily, if your data comes from a Sql DB, for example, you might have an identity field that would be better suited for the key.

So what can you do?  
For Problem #1) Why are you using DataSource instead of DataSourceID for a SiteMapDataSource?  Add the DataSource to the page, give it an ID and use DataSourceID (and you can still do this all in code)
For problem #2) You could adjust your provider to mate the key and url and store whatever data was in Key in a custom attribute.
(what do you do if there is no url? come up with something unique, XmlSiteMapProvider uses a GUID)

And, if you really don't want to do either of these, then you can also write custom databinding code like this:

    private string currentKey = SiteMap.CurrentNode.Key;
    protected void Menu1_MenuItemDataBound(object sender, MenuEventArgs e)
        if( string.Equals(e.Item.DataPath, currentKey, StringComparison.OrdinalIgnoreCase ) )
            e.Item.Selected = true;

    Protected Sub Menu1_MenuItemDataBound(ByVal sender As Object, ByVal e As System.Web.UI.WebControls.MenuEventArgs)
        Static currentKey As String = SiteMap.CurrentNode.Key
        If String.Equals(e.Item.DataPath, currentKey, StringComparison.OrdinalIgnoreCase) Then
            e.Item.Selected = True
        End If
    End Sub


More Posts