Sunday, January 21, 2007

Incremental Loading of a Tree in Flex 2

In my last post I started building a remote file explorer in Flex 2. Implementing the list pane was simple. The next task was to implement the tree pane on the left side of the explorer window.

The requirements for the tree pane were:

  1. The tree must load incrementally. When the explorer first loads it should only retrieve the top-level directories from the server. The explorer should retrieve additional subdirectories one parent directory at a time, when the user expands a tree node for the first time.
  2. The explorer must accept the path to a nested subdirectory and automatically expand to the tree down to that subdirectory when the explorer loads.

I couldn't find any good examples to meet these requirements. After some trial and error with XMLListCollection, XMLList and XML data providers, I came up with a solution that seems to work.

First I added an HTTPService, data provider and Tree component to my initial MXML file:

<?xml version="1.0"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml"
  creationComplete="initApp();">

  <mx:Script source="explorer.as"/>

  <mx:HTTPService id="folderService" url="/flex/folders.jsp" 
    resultFormat="e4x" showBusyCursor="true"
    result="addFolders(event)"
    fault="handleFault(event)">
  </mx:HTTPService>

  <mx:XML id="folderData"/>

  <mx:HTTPService id="itemService" url="/flex/items.jsp" 
    resultFormat="e4x" showBusyCursor="true"
    fault="handleFault(event)">
  </mx:HTTPService>

  <mx:XMLListCollection id="itemData" 
      source="{itemService.lastResult.item}"/>

  <mx:HDividedBox width="100%" height="100%">

    <mx:Tree id="folderTree" 
      width="40%" height="100%" 
      labelField="@label"
      showRoot="false" 
      dataProvider="{folderData}" 
      folderOpenIcon="@Embed(source='assets/folder.png')"
      folderClosedIcon="@Embed(source='assets/folder_closed.png')"
      defaultLeafIcon="@Embed(source='assets/folder_closed.png')"
      itemOpening="loadChildFolders(event)"
      change="loadList(event)"/>

    <mx:DataGrid id="itemGrid" width="60%" height="100%" 
      dataProvider="{itemData}">
      <mx:columns> 
 <mx:DataGridColumn dataField="title" headerText="Title"/>
 <mx:DataGridColumn dataField="size" headerText="Size"/>
 <mx:DataGridColumn dataField="type" headerText="Type"/>
 <mx:DataGridColumn dataField="lastModified" headerText="Modified"/>
      </mx:columns>
    </mx:DataGrid>

  </mx:HDividedBox>

</mx:Application>

Note a couple significant differences between the data provider for the Tree and the DataGrid components:

  1. The data provider for the list pane is automatically bound to the corresponding HTTPService. Each time the itemService is called, the DataGrid clears and displays the last data received from the server. Since the Tree needs to load its data incrementally, it seemed that Flex requires manual handling of the folderService response using a result event handler.
  2. Because the directory tree represents a single hierarchy, the data provider can be a simple XML object rather than a XMLList or XMLListCollection. I was more or less able to get the tree to work using the latter two types of data providers as well, but the code was messier. I also could not convince the root node to hide itself that way, in spite of the showRoot attribute being set to false.

The event handlers for the tree pane required much more ActionScript than the list pane (comments inline):

import mx.controls.Alert;
import mx.events.TreeEvent;
import mx.rpc.events.FaultEvent;
import mx.rpc.events.ResultEvent;

/**
 * Handles the creationComplete event for the application.
 *
 * Expects a comma-separated list of folder IDs representing the
 * initial expansion path for the explorer.
 **/
private function initApp():void {
  
  var expandIDs:Array = Application.application.parameters.folderID.split(",");

  var lastIndex:Number = expandIDs.length - 1;

  var parameters:Object = {folderID:expandIDs[lastIndex]};
  itemService.send(parameters); 

  parameters.folderID = expandIDs.shift();
  var token:Object = folderService.send(parameters);
  token.expandIDs = expandIDs;
}

private function handleFault(e:FaultEvent):void {
  Alert.show("Failed to contact the server.");
}

/**
 * Handles the change event (selection of a directory) for the
 * explorer tree.
 *
 * Expands the selected directory to show subdirectories.  Initiates
 * retrieval a list of subdirectories from the server if user is
 * selecting this directory for the first time.
 *
 * Also retrieves a list of directory files for display in the list
 * pane.
 **/
private function loadList(e:Event):void {

  var node:XML = Tree(e.target).selectedItem as XML;

  if (loadChildFolderNode(node, e.type) && ! folderTree.isItemOpen(node)) {
    var open:Boolean = true;
    var animate:Boolean = true;
    folderTree.expandItem(folderTree.selectedItem, open, animate);
  }

  var parameters:Object = {folderID:node.@id};
  
  itemService.send(parameters);
}

/**
 * Handles the itemOpening event (clicking the expand arrow next to a
 * directory) for the explorer tree.
 *
 * Initiates retrieval of a list of subdirectories from the server and
 * cancels immediate opening if user is selecting this directory for
 * the first time.  The explorer then opens the directory after
 * receiving the server response and adding the subdirectories to the
 * tree.
 **/
private function loadChildFolders(e:TreeEvent):void {

  var node:XML = e.item as XML;

  if (! loadChildFolderNode(node, e.type)) {
    e.stopPropagation();
    e.preventDefault()
  }
}

/**
 * Checks if a tree node is loaded and initiates retrieval if not.
 **/
private function loadChildFolderNode(node:XML, eventType:String):Boolean {

  if (node.@isBranch && node.children().length() == 0) {

    var parameters:Object = {folderID:node.@id};
    var token:Object = folderService.send(parameters);
    token.parent = node;
    token.eventType = eventType;
    return false;

  } else {

    return true;
  }
}

/**
 * Handles the result event for folderService by inserting
 * subdirectories into the tree under the parent node and then
 * expanding the tree to display the newly inserted nodes.
 *
 * Initiates retrieval of the next level in the starting expansion
 * path if appropriate.
 **/
private function addFolders(e:ResultEvent):void {

  var node:XML = e.result as XML;
  var parent:XML = e.token.parent;

  if (! parent) {
    // insert root node here to work around apparent problem with
    // showRoot property of the Tree component
    parent = folderData = ;
  }

  // add nodes from server response
  for each (var child:XML in node.children()) {
    parent.appendChild(child);    
  }

  folderTree.expandItem(parent, true, true);
  // only update tree selection for change events
  if (folderTree.selectedItem != parent && 
      e.token.eventType == "change") {
    folderTree.selectedItem = parent;
  }

  // expandIDs tracks the expansion path
  var expandIDs:Array = e.token.expandIDs;
  if (expandIDs != null && expandIDs.length > 0) {

    var folderID:String = expandIDs.shift();

    var list:XMLList = parent.folder.(@id == folderID);

    if (list.length() > 0) {
      var parameters:Object = {folderID:folderID};
      var token:Object = folderService.send(parameters);
      token.expandIDs = expandIDs;
      token.parent = list[0];
      token.eventType = (expandIDs.length > 1) ? "init" : "change";
    }
  }
}

I took advantage of the Asynchronous Completion Token (ACT) pattern to keep track of the parent directory when retrieving its subdirectories from the server, as well as for completing the initial expansion of the tree over a series of retrievals. It seems a little odd to set the token properties after making the request, but that's the way it seems to be done.

On the server side, I wrote a simple JSP that accepts a directory ID and returns a list of subdirectories in the form:

<folder id="parent ID">
  <folder id="first child ID" label="first child label" isBranch="true or false"/>
  <folder id="second child ID" label="second child label" isBranch="true or false"/>
  ...
</folder>

Maybe there's an easier way to do it, but this is still a very small amount of code and markup compared to what would be required to implement anything close to the equivalent functionality using DHTML.