Friday, June 22, 2007

ActionScript E4X: Example Sorting Hierarchical XML Data for Tree Control

I’ve been able to find examples of how to sort XMLListCollection objects which are useful data providers for controls like the DataGrid. But I wanted to sort hierarchical data for a Tree control. XMLListCollection objects sort top level elements but apparently don’t sort children, grandchildren, etc. At least I couldn’t figure out how to do it.

I needed the tree sort for an AIR application. I built a sort function for XML objects and plugged an XML object into the Tree’s data provider. It works. I decided this function would be a good post by itself so I put together a Flex 2 example with only one mxml file which is provided at the bottom of the post.

Tree node labels are specified with the property “labelField” and normally you would want to sort on these labels.

Actually, you might want to first sort on the node type and then the labels, but I haven’t gotten far enough in my application to need that yet. I know I’ll need it in the future, so stay tuned.

I set the Tree’s “labelField” property to one of the attribute names of the elements in the XML object. The example uses the “name” attribute preceded by the “@” character which is the E4X designator for attributes.

<mx:Tree dataProvider="{mvXml}" labelField="@name"/>
The data provider variable “mvXml” is defined as:

var mvXml:XML =
    <Country name= "USA">
     <State name="Texas">
       <City name="Corpus Christi"/>
       <City name="austin"/>
       <City name="San Antonio"/>
     </State>
     <State name="Michigan">
       <City name="Ypsilanti"/>
       <City name="lansing"/>
       <City name="Ann Arbor"/>
       <City name="Kalamazoo"/>
       <City name="Allen Park"/>
     </State>
   </Country>
Now the fine print

It is not unusual to have alphanumeric, numeric, currency and date nodes in a single tree. Of course all of the tree node labels can be sorted as simple text, but if you have numbers in some of the labels then 10 would come before 2. It is important to understand we are actually sorting the Tree’s data provider, an XML object, instead of the Tree nodes directly. My function sorts on only one attribute and it must be in every element of the XML object.


Lower expectations and raise false hope
Still a lot of trees will look fine if the labels are all sorted the same way. Also, I put the labels in an Array object to sort and allow you to pass in Array sorting arguments. So you may do descending, numeric, case insensitive and custom sorts.

If you are really desperate to sort on multiple types of labels, you could write a custom sort that would analyze the field and determine if it were alphanumeric, numeric, currency or a date before sorting.

There is another way to handle unusual situations. You could add an XML attribute to be used for sorting and another attribute for the label. The sorting attribute would never be seen, but would be used by my sorting function. This way you can sort numbers as alphanumeric by right aligning them and dates by first converting them to numbers.

Or you could wait because I know I will have to deal with this soon.



The example...
I have buttons for 5 different test sorts for the tree and the button click events are as follows:

Button "Sort Ascending" Click Event:
sortXml( mvXml, "name" );
expandAllTreeNodes( Tree1 );
Button "Sort Case Insensitive" Click Event:
sortXml( mvXml, "name", Array.CASEINSENSITIVE );
expandAllTreeNodes( Tree1 );
Button "Sort Descending" Click Event:
sortXml( mvXml,
         "name",
         Array.CASEINSENSITIVE | Array.DESCENDING );
expandAllTreeNodes( Tree1 );
Button "Sort Reverse" Click Event:
sortXml( mvXml, "name", sortReverseText );
expandAllTreeNodes( Tree1 );
Button "Sort Reverse Descending" Click Event:
sortXml( mvXml, 
         "name",
         sortReverseText,
         Array.DESCENDING );
expandAllTreeNodes( Tree1 );
You can pass the Array sort options in the optional 3rd and 4th function arguments. The last 2 click events use the custom sort function, "sortReverseText", which can be seen in the complete listing at the bottom of the post.

Note that it is necessary to expand the trees after each sort. Of course, this only applies to the example. In a real application you would want to restore the expanded states to their original settings. This presents a whole different problem.

  Bug   Feature

The "name" argument refers to the name of the attribute to be sorted in each element. This attribute must be in every element. Otherwise the element, and descendents, will be lost after a sort.

Actually this could be a feature. Our product not only sorts, it automatically permanently filters out elements that don't have the required attribute.

The XML Sort Function
Here is a breakdown of the sort function, "sortXml", which is a recursive static function.

 1 public static function sortXml
 2     ( avXml                :XML,
 3       avAttributeName      :String,
 4       avArraySortArgument0 :* = 0,
 5       avArraySortArgument1 :* = 0 )
 6     :void
 7 {
 8     var lvChildrenCount:int = avXml.children().length();
 9     
10     if( lvChildrenCount == 0 )
11         return;
12 
13     if( lvChildrenCount > 1 )
14     {
15         var lvAttributeValue:String;
16 
17         var lvArray:Array = new Array();
18         avXml.children().
19         (
20          lvAttributeValue = attribute( avAttributeName ),
21          lvArray.indexOf( lvAttributeValue ) == -1
22              ? lvArray.push( lvAttributeValue ) : null
23         );
24         
25         lvArray.sort
26         (
27             avArraySortArgument0,
28             avArraySortArgument1
29         );
30         
31         var lvXmlList:XMLList = new XMLList();
32         for each( lvAttributeValue in lvArray )
33         {
34             lvXmlList += avXml.children().
35             (
36                 attribute( avAttributeName )
37                 ==
38                 lvAttributeValue
39             );
40         } // for each
41         
42         avXml.setChildren( lvXmlList );
43         
44     } // if             
45 
46     for each( var lvXmlChild:XML in avXml.children() )
47     {
48         sortXml
49         (
50             lvXmlChild,
51             avAttributeName,
52             avArraySortArgument0,
53             avArraySortArgument1
54         );
55     } // for each
56     
57 } // sortXml
Lines 17 - 29
I know you won't believe me, but I did not think I would ever want to use the exact code I used in the last example of my preceding post. The purpose is to get all unique attribute values into an Array and sort them.

Lines 31 - 40
The purpose of the code is to build a new XMLList object to contain all of the child elements in the sorted order. This code loops through each of the unique attribute values in the sorted Array, but the lines 34 thru 39 are a little more complicated. These lines are one statement using E4X syntax. Lines 36 thru 38 act a filter. It returns all child elements where the attribute value is equal to the loop's current sorted attribute value. It can return more than one element in the case where more than one element has the same attribute value. The resulting XmlList variable, "lvXmlList", ends up containing the same child elements of the XML input argument "avXml".

Line 42
We are fortunate the XML class has the "setChildren" function. With this function, the code replaces the existing children with the same, but sorted, elements in the XMLList object.

Lines 46 - 55
This is just the standard recursive stuff that calls this same function on all of the child elements so that their children, and so on, are sorted. I tried to build an E4X statement to do the same thing. Maybe a fresher mind, mine or yours can come up the code.

Complete Source Code for Example:

TreeSortExample.mxml
<?xml version="1.0" encoding="utf-8"?>
<mx:Application
    xmlns:mx="http://www.adobe.com/2006/mxml"
    layout="absolute"
    creationComplete="onCreationComplete()"
    >
    <mx:VBox
        height="100%"
        width="100%"
        x="50"
        y="5"
        >
        <mx:Panel
            width="90%"
            height="50%"
            horizontalAlign="center"
            title="Jim Freer's E4X Sorting for Trees"
            >
            <mx:HBox
                height="100%"
                width="100%"
                >
                <mx:VBox>
                    <mx:Button
                        label="Sort Ascending"
                        width="200"
                        click="onClickButtonSortTree1Ascending()"
                        />
                    <mx:Button
                        label="Sort Case Insensitive"
                        width="200"
                        click="onClickButtonSortTree1CaseInsensitive()"
                        />
                    <mx:Button
                        label="Sort Descending"
                        width="200"
                        click="onClickButtonSortTree1Descending()"
                        />
                    <mx:Button
                        label="Sort Reverse"
                        width="200"
                        click="onClickButtonSortTree1Reverse()"
                        />
                    <mx:Button
                        label="Sort Reverse Descending"
                        width="200"
                        click="onClickButtonSortTree1ReverseDescending()"
                        />
                </mx:VBox>
                <mx:Tree
                    id="Tree1"
                    height="100%"
                    width="100%"
                    dataProvider="{mvXml}"
                    labelField="@name"
                    />
            </mx:HBox>
        </mx:Panel>
        <mx:Label
            text="*State capitals are in lowercase :) to demonstrate case insensitive sorting."
            />

    </mx:VBox>

    <mx:Script>
        <![CDATA[
        
        // ---------------------------------------------------------------------
        // Private Member Variables
        // ---------------------------------------------------------------------

        [Bindable]
        private var mvXml:XML =
            <Country name= "USA">
              <State name="Texas">
                <City name="Corpus Christi"/>
                <City name="austin"/>
                <City name="San Antonio"/>
              </State>
              <State name="Michigan">
                <City name="Ypsilanti"/>
                <City name="lansing"/>
                <City name="Ann Arbor"/>
                <City name="Kalamazoo"/>
                <City name="Allen Park"/>
              </State>
            </Country>;

        // ---------------------------------------------------------------------
        // expandAllTreeNodes
        // ---------------------------------------------------------------------
        
        private function expandAllTreeNodes
            ( avTree :Tree )
            :void
        {
            avTree.selectedIndex = 0;
            avTree.expandChildrenOf( avTree.selectedItem, true );
            
        } // expandAllTreeNodes     

        // ---------------------------------------------------------------------
        // onClickButtonSortTree1Ascending
        // ---------------------------------------------------------------------

        private function onClickButtonSortTree1Ascending
            ()
            :void
        {
            sortXml( mvXml, "name" );

            expandAllTreeNodes( Tree1 );
            
        } // onClickButtonSortTree1Ascending

        // ---------------------------------------------------------------------
        // onClickButtonSortTree1Descending
        // ---------------------------------------------------------------------
            
        private function onClickButtonSortTree1Descending
            ()
            :void
        {
            sortXml
                ( mvXml, "name", Array.CASEINSENSITIVE | Array.DESCENDING );

            expandAllTreeNodes( Tree1 );
            
        } // onClickButtonSortTree1Descending
        
        // ---------------------------------------------------------------------
        // onClickButtonSortTree1CaseInsensitive
        // ---------------------------------------------------------------------
        
        private function onClickButtonSortTree1CaseInsensitive
            ()
            :void
        {
            sortXml( mvXml, "name", Array.CASEINSENSITIVE );

            expandAllTreeNodes( Tree1 );
            
        } // onClickButtonSortTree1CaseInsensitive

        // ---------------------------------------------------------------------
        // onClickButtonSortTree1Reverse
        // ---------------------------------------------------------------------
            
        private function onClickButtonSortTree1Reverse
            ()
            :void
        {
            sortXml( mvXml, "name", sortReverseText );
            
            expandAllTreeNodes( Tree1 );
            
        } // onClickButtonSortTree1Reverse
        
        // ---------------------------------------------------------------------
        // onClickButtonSortTree1ReverseDescending
        // ---------------------------------------------------------------------
            
        private function onClickButtonSortTree1ReverseDescending
            ()
            :void
        {
            sortXml( mvXml, "name", sortReverseText, Array.DESCENDING );
            
            expandAllTreeNodes( Tree1 );
            
        } // onClickButtonSortTree1ReverseDescending
        
        // ---------------------------------------------------------------------
        // onCreationComplete
        // ---------------------------------------------------------------------

        private function onCreationComplete
            ()
            :void
        {
            expandAllTreeNodes( Tree1 );

        } // onCreationComplete
            
        // ---------------------------------------------------------------------
        // sortReverseText
        //
        // Reverses the strings before comparing.
        // For example, Kalamazoo is oozamalaK.
        // A lame example, but shows how to use a custom sort.
        // ---------------------------------------------------------------------

        private function sortReverseText
            ( avStringA :String,
              avStringB :String )
            :Number
        {
            var lvArray:Array;
            
            lvArray = avStringA.split( "" );
            lvArray = lvArray.reverse()
            var lvStringA:String = lvArray.join( "" );
            
            lvArray = avStringB.split( "" );
            lvArray = lvArray.reverse()
            var lvStringB:String = lvArray.join( "" );
            
            if( lvStringA > lvStringB )
            {
                return 1;
            } // if
            else if( lvStringA < lvStringB )
            {
                return -1;
            } // else if
            else
            {
                return 0;
            } // else
            
        } // sortReverseText

        // ---------------------------------------------------------------------
        // sortXml
        //
        // Warning: All elements in avXml must have an attribute with the name
        // in avAttributeName or the element will be deleted along with any
        // descendants.
        // ---------------------------------------------------------------------

        public static function sortXml
            ( avXml                :XML,
              avAttributeName      :String,
              avArraySortArgument0 :* = 0,
              avArraySortArgument1 :* = 0 )
            :void
        {
            var lvChildrenCount:int = avXml.children().length();
            
            if( lvChildrenCount == 0 )
                return;

            if( lvChildrenCount > 1 )
            {
                var lvAttributeValue:String;
    
                var lvArray:Array = new Array();
                avXml.children().
                (
                    lvAttributeValue = attribute( avAttributeName ),
                    lvArray.indexOf( lvAttributeValue ) == -1
                        ? lvArray.push( lvAttributeValue ) : null
                );
                
                lvArray.sort( avArraySortArgument0, avArraySortArgument1 )
                
                var lvXmlList:XMLList = new XMLList();
                for each( lvAttributeValue in lvArray )
                {
                    lvXmlList += avXml.children().
                    (
                        attribute( avAttributeName ) == lvAttributeValue
                    );
                } // for each
                
                avXml.setChildren( lvXmlList );
                
            } // if             

            for each( var lvXmlChild:XML in avXml.children() )
            {
                sortXml
                (
                    lvXmlChild,
                    avAttributeName,
                    avArraySortArgument0,
                    avArraySortArgument1
                );
            } // for each
            
        } // sortXml
        ]]>
    </mx:Script>
        
</mx:Application>


  Shameful   Shameless Solicitation

One more thing, I am looking for work (this time for money). Any advice would be appreciated.

1 comment:

Unknown said...

Thanks for the example. I found it very helpful. The E4x lines are still confusing to me however.