Using cascading ListBoxes to display hierarchical data in Windows 8

When you're dealing with hierarchical data (a tree-like data format such as folders that have sub-folders that have subfolders, etc.) you have to come up with an organized way of displaying it. You could create a tree view, similar to how Windows Explorer displays folders, but I wanted to try something different in Windows 8 - cascading ListBoxes. The user selects an items in a ListBox, and then a new ListBox appears to the right showing the sub-categories. This continues as long as there's data left to drill into.

For a data source, I'll use the eBay category list API that I blogged about previously.

Begin by creating a Windows Store project - a Blank App. Add a Basic Page called ChooseCategory.xaml, and delete the MainPage.xaml file that was created with the app. (For this demo you could get away with the blank page, but it's good to have the Grid and the VisualStateGroup automatically added) Open up App.xaml.cs and change this line:

if (!rootFrame.Navigate(typeof(MainPage), args.Arguments))

to call ChooseCategory instead of MainPage. Finally, in App.xaml change the Theme to Light by adding RequestedTheme="Light" to the Application tag. You could leave it dark by default, but then your ListBoxes will look a bit out of place unless you modify their style.

Next I'll add a class file to the project to hold a List that can be bound to each ListBox, as well as hold the method to make the call to the eBay API. This is almost identical to the previous post, with a few minor tweaks.

Here are the two classes to hold the List:

public class Categories
{
    private List<CategoryDetails> _Items = new List<CategoryDetails>();
    public List<CategoryDetails> Items
    {
        get
        {
            return this._Items;
        }
        set
        {
            this._Items = value;
        }
    }
}

public class CategoryDetails
{
    public string categoryName { get; set; }
    public int categoryId { get; set; }
}

You could have more data in these if you wanted, but since this is a ListBox demo I just wanted some simple name/value pairs.

And here is the method that makes the call to the eBay API:

public async Task<Categories> CallGetCategoryInfo(int CategoryID)
{
    HttpClient httpClient = new HttpClient();
    string api_key = "MyKey";
    string searchUrl = "http://open.api.ebay.com/Shopping?callname=GetCategoryInfo";

    string requestUrl = searchUrl + "&appid=" + api_key + "&version=679&siteid=0&CategoryID=" + CategoryID.ToString() + "&IncludeSelector=ChildCategories";

    HttpResponseMessage response = await httpClient.GetAsync(requestUrl);
    System.Xml.Linq.XDocument doc = System.Xml.Linq.XDocument.Load(await response.Content.ReadAsStreamAsync());

    XNamespace ns = "urn:ebay:apis:eBLBaseComponents";

    var query = from categories in doc.Descendants(ns + "Category")
        select new
        {
            CategoryID = categories.Element(ns + "CategoryID").Value,
            CategoryParentID = categories.Element(ns + "CategoryParentID").Value,
            CategoryName = categories.Element(ns + "CategoryName").Value,
            LeafCategory = categories.Element(ns + "LeafCategory").Value,
        };

    List<CategoryDetails> catList = new List<CategoryDetails>();

    Categories c = new Categories();

    foreach (var element in query)
    {
        //The first category returned in the xml will always be either root, or the parent level category
        //I don't want it in the list
        if (element.CategoryID != "-1" && element.CategoryID != CategoryID.ToString())
        {
            CategoryDetails cd = new CategoryDetails();
            cd.categoryId = Convert.ToInt32(element.CategoryID);
            cd.categoryName = (Convert.ToBoolean(element.LeafCategory) ? element.CategoryName : element.CategoryName + " ->");

            catList.Add(cd);
        }
    }

    c.Items = catList;

    return c;
}

Now let's modify ChooseCategory.xaml. Change the AppName:

<x:String x:Key="AppName">Choose Category</x:String>

And add a StackPanel right below the grid that hold the pageTitle TextBlock:

<StackPanel x:Name="pnlMain" HorizontalAlignment="Left" Margin="20,10" Grid.Row="2" VerticalAlignment="Top" Orientation="Horizontal"/>

This is the panel that we'll add the ListBoxes to.

Now for the code-behind in ChooseCategory.xaml.cs. Declare a few variables in the class:

private int _categoryID = -1;
private string _categoryName = "";
private List<CategoryDetails> _cbp = null;
private int _listBoxCount = 0;

Add a method to retrieve the data from eBay and call the method that adds a new ListBox:

private async void CallGetCategoryInfo(int CategoryID)
{
    CategoriesDataSource cds = new CategoriesDataSource();
    Categories c = await cds.CallGetCategoryInfo(CategoryID);
    _cbp = c.Items;

    AddListBox();
}

Now we add a new ListBox if we had some data returned from eBay. We set the DisplayMemberPath and SelectedValuePath to the public properties of the CategoryDetails object, give the ListBox a distinct name (we'll need it later), wire up an event handler, and add the ListBox to the StackPanel:

private void AddListBox()
{
    //If the List count is 0, that means we have drilled all the way down
    if (_cbp.Count > 0)
    {
        _listBoxCount += 1;

        ListBox listBox1 = new ListBox();
        listBox1.DisplayMemberPath = "categoryName";
        listBox1.SelectedValuePath = "categoryId";
        listBox1.ItemsSource = _cbp;
        listBox1.Width = 250;
        listBox1.Name = "CategoryLevel" + _listBoxCount.ToString();
        listBox1.SelectionChanged += ComboBox_SelectionChanged;
        pnlMain.Children.Add(listBox1);
    }
}

Finally we're going to handle selecting an item in the ListBox:

private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    Selector list = sender as Selector;

    // Delete listboxes to the right if we click back a level
    int clickedCategoryLevel = Convert.ToInt32(list.Name.Replace("CategoryLevel", ""));
    for (int x = _listBoxCount; x > clickedCategoryLevel; x--)
    {
        pnlMain.Children.RemoveAt(x - 1);
    }

    _listBoxCount = clickedCategoryLevel;
    _categoryID = Convert.ToInt32(list.SelectedValue);

    CallGetCategoryInfo(_categoryID);
}

If the user backs up a level (i.e. they have drilled down a few levels, but now want to click something at a higher level and start over instead of drilling down further), then we need to delete any ListBoxes to the right of the one that was just clicked. Then we call CallGetCategoryInfo again to retrieve the data and add a new ListBox.

Finally, add a call to CallGetCategoryInfo in the constructor to start the whole process off:

public ChooseCategory()
{
    this.InitializeComponent();

    CallGetCategoryInfo(_categoryID);
}

Fire up the app and try drilling down and back up. If everything works correctly, you should get results looking similar to this:

CategoryChooser

Notice that some of the lines end with "->". In the CallGetCategoryInfo I check to see whether the category being added to the List is a "Leaf" category - the lowest level of category (you can't drill down any further). If it is, I add that text to give a visual indication that there are sub-categories.

Notice that I didn't do anything with the various VisualStates, so this will really only look good in FullScreenPortrait mode. I haven't really thought about how other modes would work (especially snapped!); this was more of a proof-of-concept.

You can download the entire source code at: http://dl.dropbox.com/u/22528394/ListBoxDrillDown.zip

Technorati Tags: ,,