Wicked Code
Three Cures for Common Site Map Ailments
Jeff Prosise
Code download available at:
WickedCode2006_06.exe
(390 KB)
Browse the Code Online

Contents
Data-driven site navigation is among the niftiest and most useful features in ASP.NET 2.0. To get it working, all you do is create an XML site map file (or a SQL site map if you're using the MSDN®Magazine SqlSiteMapProvider), add a SiteMapDataSource, and bind a TreeView or Menu to the SiteMapDataSource. Just like that, you have a data-driven navigation UI. If the structure of your site changes, simply update the site map and the navigation UI changes to match. For good measure, you can throw in a SiteMapPath control to implement a bread crumb element showing the path to the current page.
In many cases, building a navigation UI really is that easy. But it's also easy to run into snags using the ASP.NET 2.0 data-driven site navigation infrastructure and find yourself up against a wall searching for a way out. One of the most common ailments is TreeView branches that don't stay expanded or collapsed as you click around a site. Another is unmapped pages—pages that don't appear in the site map and therefore don't display in SiteMapPath controls (even though you want them to). A third involves pages that contain multiple navigation controls: a TreeView on the left and a Menu at the top, for example. It's easy enough to bind both controls to a SiteMapDataSource. But once you do, how do you configure them to show one set of links in the TreeView and another set in the Menu?
I chose these three problems to tackle because they seem to eat a lot of dev cycles. Fortunately, the solutions are easy. A little know-how coupled with an understanding of how site maps—and site map providers—work can go a long way, as the sample code (from the
MSDN Magazine Web site at
msdn.microsoft.com/msdnmag/code06.aspx) will show. Throw in a little Atlas to spice things up, and the results are nothing short of magical. But don't just take my word—read on.
Persistent TreeViews. Really?
The number-one problem that bedevils developers who combine site maps with expandable TreeView controls is the fact that the TreeView loses its expanded/collapsed state as you navigate among pages. The TreeView may be declared in a Master Page, but it's still instantiated for each and every request. The next TreeView knows nothing about the previous one. View state doesn't help here because view state preserves state across postbacks. When you click a link in a TreeView, you're not posting back; you're navigating to a whole new page. State pertaining to the previous page is lost.
One solution, which really isn't a solution at all if you want to support interactive expanding and collapsing, is to "fix" the TreeView problem by setting its ShowExpandCollapse property to false. Another approach is to serialize the TreeView's view state into session state and then deserialize it into the TreeView on the next page. But that's inefficient, because a TreeView stores a lot more in view state than simply a record of which branches are expanded and which aren't.
A better solution is the one exemplified in Figure 1. The code comes from the Master Page of the sample site named WickedSiteMap that accompanies this article. When the TreeView fires a DataBound event indicating it has been populated from the site map, the event handler (TreeView1_DataBound) checks to see if session state contains an item named "TreeViewState." If there is no such item, TreeView1_DataBound calls a helper method named SaveTreeViewState to create a List<> containing an entry for each expandable branch that's currently expanded. Then it saves the List<> in session state. If session state already contains a saved List<>, TreeView1_DataBound retrieves it and calls a helper method named RestoreTreeViewState to restore the TreeView to the state recorded in the List<>. RestoreTreeViewState walks the TreeView item by item and expands expandable branches recorded in the List<>, while collapsing expandable branches that are not in the list.

Figure 1 Site.master.cs
|
using System;
using System.Web.UI.WebControls;
using System.Collections.Generic;
public partial class Site_master : System.Web.UI.MasterPage {
protected void Page_Load(object sender, EventArgs e) {
// Disable ExpandDepth if the TreeView’s expanded/collapsed
// state is stored in session.
if (Session["TreeViewState"] != null)
TreeView1.ExpandDepth = -1;
}
protected void TreeView1_DataBound(object sender, EventArgs e)
{
if (Session["TreeViewState"] == null)
{
// Record the TreeView’s current expanded/collapsed state.
List<string> list = new List<string>(16);
SaveTreeViewState(TreeView1.Nodes, list);
Session["TreeViewState"] = list;
}
else
{
// Apply the recorded expanded/collapsed state to
// the TreeView.
List<string> list = (List<string>)Session["TreeViewState"];
RestoreTreeViewState(TreeView1.Nodes, list);
}
}
protected void TreeView1_TreeNodeCollapsed(
object sender, TreeNodeEventArgs e)
{
if (IsPostBack) {
List<string> list = new List<string>(16);
SaveTreeViewState(TreeView1.Nodes, list);
Session["TreeViewState"] = list;
}
}
protected void TreeView1_TreeNodeExpanded(
object sender, TreeNodeEventArgs e)
{
if (IsPostBack) {
List<string> list = new List<string>(16);
SaveTreeViewState(TreeView1.Nodes, list);
Session["TreeViewState"] = list;
}
}
private void SaveTreeViewState(
TreeNodeCollection nodes, List<string> list) {
// Recursively record all expanded nodes in the List.
foreach (TreeNode node in nodes)
{
if (node.ChildNodes != null && node.ChildNodes.Count != 0) {
if (node.Expanded.HasValue && node.Expanded == true &&
!String.IsNullOrEmpty(node.Text))
list.Add(node.Text);
SaveTreeViewState(node.ChildNodes, list);
}
}
}
private void RestoreTreeViewState(
TreeNodeCollection nodes, List<string> list) {
foreach (TreeNode node in nodes)
{
// Restore the state of one node.
if (list.Contains(node.Text)) {
if (node.ChildNodes != null &&
node.ChildNodes.Count != 0 &&
node.Expanded.HasValue &&
node.Expanded == false)
node.Expand();
}
else if (node.ChildNodes != null &&
node.ChildNodes.Count != 0 &&
node.Expanded.HasValue &&
node.Expanded == true)
{
node.Collapse();
}
// If the node has child nodes, restore their states, too.
if (node.ChildNodes != null && node.ChildNodes.Count != 0)
RestoreTreeViewState(node.ChildNodes, list);
}
}
}
|
The DataBound event handler ensures that when you click an item in the TreeView to go to another page, the TreeView in the receiving page assumes the configuration of the TreeView in the sending page. But it doesn't take into account the fact that the TreeView's state might have changed—that is, that the user might have expanded or collapsed some branches—while the page was displayed. That scenario is handled by the TreeView1_TreeNodeCollapsed and TreeView1_TreeNodeExpanded event handlers, which update the List<> stored in session state each time a branch is collapsed or expanded.
The final piece of the puzzle is the Site.master.cs Page_Load method. If the TreeView's ExpandDepth property is set, you want to honor that property the first time the TreeView is displayed, but override it with the saved state of the TreeView in subsequent requests. The Page_Load method in Figure 1 does just that. If session state contains a saved TreeView state, Page_Load sets the TreeView's ExpandDepth property to -1, effectively disabling any ExpandDepth property value specified in the <asp:TreeView> tag.
One implementational detail of interest is how SaveTreeViewState and RestoreTreeViewState identify the expanded TreeView nodes. When SaveTreeViewState finds an expanded node, it writes the value of the node's Text property into the List<>. Similarly, when RestoreTreeViewState traverses a TreeView, expanding nodes recorded in the List<>, it compares the Text properties of the nodes to the Text properties in the List<>. Consequently, a node won't retain its expanded state unless it has a non-empty Text property, and that property should be unique among all the nodes in the TreeView. (In other words, if two nodes are named "Foo" and one is expanded but the other is not, both will expand when you click a link and jump to another page.) If that's a problem, you can modify my code to apply additional criteria to node identification.
Don't Supersize—Atlasize!
The chief downside to processing a TreeView's TreeNodeCollapsed and TreeNodeExpanded events has to do with postbacks. An unfortunate side effect of registering handlers for these events is that the TreeView will now post back to the server each time a branch is expanded or collapsed. An elegant way to eliminate the postbacks while retaining the ability to record changes to the TreeView state on the server is to incorporate asynchronous XML-HTTP callbacks (a technology known as Asynchronous JavaScript and XML, or AJAX). The ASP.NET TreeView has XML-HTTP support built in, but rewiring it to convert expand/collapse postbacks into callbacks would require nontrivial changes to the control.
I considered making those changes, but elected not to for a simple reason: Atlas. Atlas is the code name for the forthcoming AJAX framework for ASP.NET developed by Microsoft. The Atlas UpdatePanel control makes it simple, almost trivial, to convert postbacks into asynchronous XML-HTTP callbacks. I didn't want to bring Atlas into the sample site because Atlas isn't a shipping product yet, and as of this writing, you're not allowed to use it in production Web apps. But the future has Atlas written all over it, and I'll have much more to say about it—and UpdatePanels in particular—in a future installment of Wicked Code.
The good news is that MSDN Magazine is in the business of showing you what the future holds. So I built a separate version of the site (included in the download, of course) named AtlasSiteMap that uses Atlas to convert the TreeView's expand and collapse postbacks into XML-HTTP callbacks. The result is magical. A few lines of markup (no additional code!) and expand/collapse postbacks simply disappear; you can verify this by running AtlasSiteMap in Internet Explorer® and observing that no progress bar appears in the browser's status bar when you expand or collapse a branch of the TreeView—a clear indication that the TreeView is no longer posting back to the server. Yet thanks to the brilliance of the UpdatePanel, your server-side event handlers are still being called, as evidenced by the fact that the TreeView retains its expanded/collapsed state as you click around the site.
Figure 2 shows the changes required to Site.master to Atlas-ize the TreeView. (Note, though, that no changes were made to Site.master.cs!) First I added an Atlas ScriptManager control. Among other things, that control downloads the JavaScript files needed to support Atlas on the client. Then I wrapped the TreeView in an Atlas UpdatePanel control. UpdatePanel effectively subsumes the TreeView's TreeNodeCollapsed and TreeNodeExpanded events and converts them into asynchronous XML-HTTP callbacks. The UpdatePanel <Triggers> element identifies the TreeView events I want to subsume.

Figure 2 Convert TreeView Postbacks into Callbacks
|
<head runat="server">
...
<atlas:ScriptManager ID="ScriptManager1"
EnablePartialRendering="true" runat="server" />
</head>
...
<atlas:UpdatePanel ID="UpdatePanel1" runat="server">
<Triggers>
<atlas:ControlEventTrigger ControlID="TreeView1"
EventName="TreeNodeCollapsed" />
<atlas:ControlEventTrigger ControlID="TreeView1"
EventName="TreeNodeExpanded" />
</Triggers>
<ContentTemplate>
<asp:TreeView ID="TreeView1" runat="server" ...>
...
</asp:TreeView>
</ContentTemplate>
</atlas:UpdatePanel>
|
AtlasSiteMap was built and tested against the January release of Atlas, better known as the January CTP (Community Technology Preview). Now the March CTP is available (see
microsoft.com/downloads/details.aspx?familyid=b01dc501-b3c1-4ec0-93f0-7dac68d2f787). AtlasSiteMap may or may not require modification to work with newer releases.
SiteMapPaths and Unmapped Pages
A second site map problem seeking a solution is exemplified by a situation I recently found myself in. I was building a site that presents newspaper-style content. Articles are displayed by passing an article ID in a query string to a page that fetches articles from a back-end database and renders them into HTML. That page doesn't appear in the site map because there's no good reason to go to that page without an article ID. Yet when the page is viewed, I want it to appear in the SiteMapPath, and I want the node text in the SiteMapPath to be the article headline.
The problem with this scenario is twofold. First, a SiteMapPath control doesn't know what to do with URLs that aren't included in the site map, so it displays nothing. Second, a hook is needed to modify—on the fly—the text displayed by SiteMapPath for the current node. While you can use a SiteMapPath control's ItemCreated and ItemDataBound events to modify node text on the fly, SiteMapPath doesn't fire these events for nodes that don't appear in the site map.
Enter the little-known SiteMap.ResolveSiteMap event. The documentation describes it this way: "Subscribers attach a SiteMapResolveEventHandler object to the static SiteMapResolve event to receive notification when the CurrentNode property is accessed. This enables the user to implement custom logic when creating a SiteMapNode representation of the currently executing page without requiring a custom provider implementation."
Behind the scenes, the SiteMapPath control reads the SiteMap.CurrentNode property to determine what to render out. Then SiteMap.CurrentNode, in turn, reads the default site map provider's CurrentNode property. In System.Web.SiteMapProvider, which is the base class for site map providers, CurrentNode's get accessor is implemented this way:
|
HttpContext context = HttpContext.Current;
SiteMapNode result = ResolveSiteMapNode(context);
if (result == null) result = FindSiteMapNode(context);
return ReturnNodeIfAccessible(result);
|
The helper method ResolveSiteMapNode fires a ResolveSiteMap event. By processing these events, you can add nodes to site maps on the fly and customize their properties to match the context of the current request—for example, set the node's Title property to the article headline that corresponds to the article ID in the query string. Furthermore, you don't have to remove these nodes when you're finished because ASP.NET considers them to be temporary nodes and they are not joined to the actual site map.
Figure 3 shows you how the sample site handles ResolveSiteMap events. The Global.asax Application_Start method, which executes when the application starts up, registers a static handler for ResolveSiteMap events. The handler is named HandleUnmappedNodes.

Figure 3 SiteMap.ResolveSiteMap Events in Global.asax
|
<%@ Application Language="C#" %>
<script runat="server">
void Application_Start(object sender, EventArgs e)
{
// Register a handler for SiteMap.SiteMapResolve events.
SiteMap.SiteMapResolve +=
new SiteMapResolveEventHandler(HandleUnmappedNodes);
}
public static SiteMapNode HandleUnmappedNodes(
object sender, SiteMapResolveEventArgs e)
{
HttpContext context = HttpContext.Current;
// Create a custom SiteMapNode for Unmapped.aspx.
if (context.Request.Path.ToLower().Contains("unmapped.aspx"))
{
string param = context.Request["param"];
string title = String.IsNullOrEmpty(param) ?
"Unmapped Page" : param;
SiteMapNode node = new SiteMapNode(SiteMap.Provider,
"~/Unmapped.aspx", "~/Unmapped.aspx", title);
node.ParentNode = SiteMap.Provider.RootNode;
return node;
}
return null; // Do nothing for other URLs.
}
</script>
|
The job of HandleUnmappedNodes is simple. If the current request targets a page named Unmapped.aspx, HandleUnmappedNodes creates a SiteMapNode representing the page and parents it to the site map's root node. In addition, the handler dynamically sets the SiteMapNode's Title property to "Unmapped page" if the request doesn't contain a query string parameter named param, or to the value of that parameter if it does. Therefore, if you request UnmappedPage.aspx, the SiteMapPath will show "Unmapped Page" for the current node. But if you request UnmappedPage.aspx?param=Microsoft, the SiteMapPath will show "Microsoft" for the current node.
You can see this for yourself in the sample site by typing some text into the TextBox in Default.aspx and clicking the "Go to UnmappedPage.aspx" button. Because the text you typed is passed to UnmappedPage.aspx in a query string, it will appear in the SiteMapPath control. On the other hand, if you click the button with the TextBox empty, there will be no query string and the SiteMapPath will simply show "Unmapped Page."
Multiple Site Maps
A third aspect of the sample site that you'll really be interested in if you want to get a handle on site maps is the manner in which it provides data to the two navigational elements: the TreeView on the left and the Menu across the top. Each presents the user with a set of links that is different from (and independent of) the other.
The secret to hosting two independent navigation controls in one page is to provide two site maps. By default, XmlSiteMapProvider looks for your site map in a file named Web.sitemap. You can change that by registering an independent instance of XmlSiteMapProvider and using the siteMapFile configuration attribute to identify the site map file. Figure 4 shows how. This snippet from web.config registers not one, but two instances of XmlSiteMapProvider, one that retrieves a site map from TreeView.sitemap, and another that retrieves a site map from Menu.sitemap. It also makes the former instance of XmlSiteMapProvider—the one named TreeViewSiteMapProvider—the default site map provider to tell the SiteMapPath control which site map to use. For good measure, it also deregisters the default instance of XmlSiteMapProvider since it won't be used anyway.

Figure 4 Registering Multiple Site Map Providers
|
<siteMap defaultProvider="TreeViewXmlSiteMapProvider">
<providers>
<add name="TreeViewXmlSiteMapProvider"
type="System.Web.XmlSiteMapProvider, System.Web,
Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b03f5f7f11d50a3a"
siteMapFile="TreeView.sitemap"/>
<add name="MenuXmlSiteMapProvider"
type="System.Web.XmlSiteMapProvider, System.Web,
Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b03f5f7f11d50a3a"
siteMapFile="Menu.sitemap"/>
<remove name="AspNetXmlSiteMapProvider" />
</providers>
</siteMap>
|
Now that the site map is split into two independent site map files, the next step is to bind the TreeView to one site map and the Menu to the other. That's accomplished with SiteMapDataSource's SiteMapProvider property. In the Site.master snippet shown here the TreeView control is bound to one SiteMapDataSource (TreeViewSiteMapDataSource) and the Menu control is bound to another (MenuSiteMapDataSource):
|
<asp:SiteMapDataSource ID="MenuSiteMapDataSource" runat="server"
SiteMapProvider="MenuXmlSiteMapProvider" ... />
<asp:Menu DataSourceID="MenuSiteMapDataSource" runat="server" ... />
...
<asp:SiteMapDataSource ID="TreeViewSiteMapDataSource" runat="server"
SiteMapProvider="TreeViewXmlSiteMapProvider" ... />
<asp:TreeView DataSourceID="TreeViewSiteMapDataSource"
runat="server" ... />
|
Each SiteMapDataSource is bound to one of the XmlSiteMapProvider instances declared in web.config. The TreeView control is bound to TreeViewSiteMapDataSource, which is bound to TreeViewXml-SiteMapProvider, which is bound to TreeView.sitemap. The Menu control is bound to MenuSiteMapDataSource, which is bound to MenuXmlSiteMapProvider, which is bound to Menu.sitemap. As a result, the TreeView gets its data from TreeView.sitemap, and site map data for the Menu comes from Menu.sitemap.
The technique of splitting the navigation UI into two or more independent site maps is powerful for a couple of reasons. One, it isn't limited to two controls: you can partition a site map into two site maps or 100 site maps (although admittedly, 100 site maps and 100 navigation controls would make for a very confusing user interface!). Two, it circumvents the fact that XmlSiteMapProvider requires every URL in a given site map to be unique. You can't partition a site map internally and divvy up different parts of it to different controls if one URL appears in both halves of the site map. But you can duplicate URLs all day long if the URLs live in different site maps. Although the sample site's TreeView and Menu don't duplicate any links, you could share as many URLs as you like between them and XmlSiteMapProvider wouldn't complain since TreeView.sitemap and Menu.sitemap are two different site maps.
Send your questions and comments for Jeff to wicked@microsoft.com.
Jeff Prosise is a contributing editor to
MSDN Magazine and the author of several books, including
Programming Microsoft .
NET (Microsoft Press, 2002). He's also a cofounder of
Wintellect, a software consulting and education firm specializing in Microsoft .NET.