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.

1 comment:

Anonymous said...

Hey, I was searching for something in the Adobe Forums and came across your post.

I actually had to do the exact same thing... however, you seem to have gone about it a different way... because you have a lot more code than I did. Unfortunately, I did this for a client on their computer and I don't have the file anymore.

A few tips (things that I needed to do):

1) It might be smart to load in the subdirectories as well... not just the immediate directories... that way there will be no lag there when the user expands a directory... you could have it load in all of the directories, display them to the user and then go and fetch the subdirectories for each of the main directories.

2) I just simply had it make a call to the server on each open branch request... and then appended to the XML by using something like addChild.

3) If you plan to go further with this, like being able to move directories in and out of each other, etc. (i.e. drag and drop)... then you're in for a treat. Drag and Drop with Trees is a real pain... however, I have spent a lot of time on a custom component which is a modification of the tree, just updatable... I worked out how to make it so that you could actually use drag and drop on a tree successfully... if you're interested, let me know.

Oh, what the hell.... here it is:

Just save it as a custom component in your Flex Library and use it as you wish. If you have questions, just ask me because there is no real documentation (Although I did comment it)...

package tk{
import mx.controls.Tree;
import mx.events.FlexEvent;
import mx.events.*;
import flash.events.Event;
import mx.events.ListEvent;
import mx.controls.TextInput;
import mx.rpc.http.HTTPService;
import mx.rpc.events.ResultEvent;
import mx.controls.Alert;
import mx.collections.XMLListCollection;

public dynamic class uTree extends Tree{

/////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Created: 9-Aug-2006
// Last Modified: 23-Jan-2007
// Creator: Taka-aki Kojima
//
// Written by Taka Kojima. This should be pretty straightforward. uTree is an Updatable Tree.
// Basically, you can have this Tree interact with an HTTPService and do real-time updates.
// Items are updatable on double-clicks and you can add and remove nodes as you please.
// Hope this can be of value to you, it was to me.
//
// ******************* UPDATE: 13-Aug-2006 **************************************
//
// Okay, now this is getting a bit crazy. Talk about a custom component and extended functionality.
// Now, drag and drop support is enabled. Basically, you can drag and drop as you please and it will
// send requests back to the httpService with these REQUEST vars: rearrange (true) item(the dragged item)
// item_before(the id of the item before the dragged item) and item_after(the id of the item after the
// dragged item). You can also now have objects with different types of children and nodes with different names.
//
// Oh, and addNode and removeNode also receive an argument of "type", which get passed to the httpService as
// REQUEST var "type".
//
// I'm shutting up now.
//
// - Taka Kojima
//
/////////////////////////////////////////////////////////////////////////////////////////////////////

internal static var version:String = "1.3";

public function uTree(){
super.showRoot = false;

super.addEventListener(Event.CHANGE, itemChange);
super.addEventListener(ListEvent.ITEM_CLICK, itemClick);
super.addEventListener(ListEvent.ITEM_EDIT_END, itemEditEnd);
super.addEventListener(FlexEvent.CREATION_COMPLETE, sendInitialRequest);
super.addEventListener("updateComplete", updateTree);
super.addEventListener(DragEvent.DRAG_DROP, dragDrop);
}

protected function itemChange(event:Event):void{super.editable=false;}
protected function itemClick(event:ListEvent):void{if(_editable==true || super.selectedItem.@[_editField] == true){_origName=super.selectedItem[super.labelField];super.editable=true;};setSelected();}
protected function itemEditEnd(event:ListEvent):void{if(_editable==true || super.selectedItem.@[_editField] == true){super.editable=false;updateNode();};}
protected function sendInitialRequest(event:FlexEvent):void{sendRequest();loadErrors();}
protected function dragDrop(event:DragEvent):void{if(DragnDrop==true){rearrangeNodes();};}

////////////////////////////////// VARIABLES /////////////////////////////////

/// 1. VARIABLES WHICH ARE SET BY THE USER //////

private var _checkAttribute:String = "";
private var _deleteParent:Boolean = false;
private var _draggedItem:Object = new Object();
private var _DragnDrop:Boolean = false;
private var _editable:Boolean = false;
private var _editField:String = "editable";
private var _errorMessages:Array = new Array();
private var _httpService:HTTPService = new HTTPService();
private var _idField:String = "id";
private var _newName:String = "_New";
private var _origName:String = new String();
private var _request:Object = new Object();
private var _reselect:Boolean = true;
private var _selected:String = "";
private var _selectedLabel:String = new String();
private var _TreeXML:XML = new XML();

/////// checkAttribute /////

public function set checkAttribute(value:String):void{_checkAttribute = value;}
public function get checkAttribute():String{return _checkAttribute;}

/////// deleteParent /////

public function set deleteParent(value:Boolean):void{_deleteParent = value;}
public function get deleteParent():Boolean{return _deleteParent;}

/////// DragnDrop /////

public function set DragnDrop(value:Boolean):void{
_DragnDrop = value;
super.dragEnabled=value;
super.dropEnabled=value;
super.dragMoveEnabled=value;
}
public function get DragnDrop():Boolean{return _DragnDrop;}

/////// errorMessages /////

public function set errorMessages(value:Array):void{_errorMessages = value;}
public function get errorMessages():Array{return _errorMessages;}

/////// httpService /////

public function set httpService(value:Object):void{
if(typeof(value) == "string"){_httpService.url = value.toString();}
else{_httpService = value as HTTPService;}

_httpService.resultFormat="e4x";
_httpService.method="POST";
_httpService.useProxy=false;
_httpService.makeObjectsBindable=true;

_httpService.addEventListener(ResultEvent.RESULT, httpServiceResult);
}

public function get httpService():Object{return _httpService as HTTPService;}

internal function httpServiceResult(e:ResultEvent):void{
_TreeXML = new XML(_httpService.lastResult.toString());
super.dataProvider=_TreeXML;
if(_TreeXML.children().length() == 0){_TreeXML = new XML();super.dataProvider=null;}
updateItems = true;

// If there's an error message, then show it....
if(_TreeXML.@error != undefined){Alert.show(_TreeXML.@error);}
}

/////// idField /////

public function set idField(value:String):void{_idField = value;}
public function get idField():String{return _idField;}

/////// isEditable /////

public function set isEditable(value:Boolean):void{_editable = value;}
public function get isEditable():Boolean{return _editable;}

/////// newName /////

public function set newName(value:String):void{_newName = value;}
public function get newName():String{return _newName;}

/////// request /////

public function set request(value:Object):void{_request = value;}
public function get request():Object{return _request;}

/////// reselect /////

public function set reselect(value:Boolean):void{_reselect = value;}
public function get reselect():Boolean{return _reselect;}

/////// selected /////

public function set selected(value:String):void{_selected = value;}
public function get selected():String{return _selected;}

/////// selectedLabel /////

public function set selectedLabel(value:String):void{_selectedLabel = value;}
public function get selectedLabel():String{return _selectedLabel;}

/// 2. ERROR MESSAGES ///

internal function loadErrors():void{
var e:Array = new Array();

/*
Ahh!!! Error CODES. Yep, well, here's what they all mean.

0 = Unexpected. What the fu$!#?
1 = Error message if _deleteParent is set to false (default), and the node being deleted has children;
2 = Error message if they try to delete a node that has _checkAttribute set to true.
3 = Error message if they receive an error response from the HTTPService.
4 = Error message in adding a node.
5 = Error message in changing a node's name.
6 = Error message in removing a node.
7 = No item selected.

What's below are the DEFAULTS. By passing an errorMessages as an attribute in the MXML, you can override
these. It will override whatever isn't set. So, if you don't set an error message for a certain code,
it'll use the default for that one, but yours for the ones you specify.

*/

e[0] = "Unexpected Error.";
e[1] = "You can't delete an item that has children.";
e[2] = "Unable to delete item. See your administrator for details.";
e[3] = "Error in sending/receiving data from server.";
e[4] = "Cannot add node.";
e[5] = "Cannot change node's name.";
e[6] = "Cannot delete node.";
e[7] = "Please select an item.";

for(var x:String in e){if(_errorMessages[x] == null){_errorMessages[x] = e[x];};}
}

/// 3. VARIABLES FOR INTERNAL USE

internal var AlreadyOpened:Array = new Array();
internal var doUpdate:Boolean = true;
internal var HScroll:Number = 0;
internal var VScroll:Number = 0;
internal var Opened:Array = new Array();
internal var updateItems:Boolean = false;

//////////////////////////////////// END OF VARIABLES /////////////////////////////////

//////////////////////////////////// OVERRIDE FUNCTIONS /////////////////////////////////



//////////////////////////////////// END OF OVERRIDE FUNCTIONS /////////////////////////////////

////////////////////////////////// CUSTOM FUNCTIONS //////////////////////////////////////////

public function addNode(type:String = "default"):void{
if(updateItems === false){
captureTreeSettings();
var r:Object = _request;
try{
r.selected = super.selectedItem.@[_idField];
var parent:XML = _TreeXML.descendants().(@[_idField]==super.selectedItem.@[_idField]).parent();
var parents:Array = new Array();

while(parent.parent() != null){
parents.push(parent.@[_idField]);
parent = parent.parent();
}
r.parents = parents.join(".");
}
catch(e:Error){}

r.add = true;
r.new_name = _newName;
r.type=type;
sendRequest(r);
r.add = null;r.selected = null;r.new_name = null;r.type=null;r.parents=null;
}
}

internal function captureTreeSettings():void{
Opened = new Array();
for(var x:String in super.openItems){Opened.push(super.openItems[x].@[_idField]);}
HScroll = super.horizontalScrollPosition;
VScroll = super.verticalScrollPosition;
}

internal function in_array(a:Array,v:String):Boolean{
var r:Boolean = false;
for(var x:String in a){if(a[x] == v){r = true;};}
return r;
}

public function rearrangeNodes(go:Boolean=false):void{
if(updateItems === false){
if(go == false){
_draggedItem = super.selectedItem;
callLater(rearrangeNodes,[true]);
//super.invalidateDisplayList();
}
else{
captureTreeSettings();
var r:Object = _request;
var before:String = new String();
var after:String = new String();


var parent:XML = _TreeXML.descendants().(@[_idField]==_draggedItem.@[_idField]).parent();
var parents:Array = new Array();

while(parent.parent() != null){
parents.push(parent.@[_idField]);
parent = parent.parent();
}

try{before = _TreeXML.descendants().(@[_idField]==_draggedItem.@[_idField]).parent().children()[_TreeXML.descendants().(@[_idField]==_draggedItem.@[_idField]).childIndex() - 1].@[_idField];}
catch(e:Error){before = "";}

try{after = _TreeXML.descendants().(@[_idField]==_draggedItem.@[_idField]).parent().children()[_TreeXML.descendants().(@[_idField]==_draggedItem.@[_idField]).childIndex() + 1].@[_idField];}
catch(e:Error){after = "";}

r.before = before;
r.after = after;
r.selected = _draggedItem.@[_idField];
r.type = _draggedItem.name();
r.parents = parents.reverse().join(".");
r.rearrange = true;
sendRequest(r);

// UNSET ALL THE non-DEFAULT REQUEST VARS
r.before=null; r.after=null; r.node_type=null; r.rearrange=null;
}
}
}

public function removeNode(type:String = "default"):void{
if(updateItems === false){
var r:Object = _request;
// Just make sure there is an item selected first...
if(super.selectedItem == null){showError(7);}

// If this is a branch, not a leaf...
else if(super.selectedItem.children().length() > 0){
// If deleteParent is set to true, delete it. The server-side script is expected to delete the children of it if so desired.
if(deleteParent === true){
captureTreeSettings();
r.selected = super.selectedItem.@[_idField];
r.remove = true;
r.type = type;

var parent:XML = _TreeXML.descendants().(@[_idField]==super.selectedItem.@[_idField]).parent();
var parents:Array = new Array();

while(parent.parent() != null){
parents.push(parent.@[_idField]);
parent = parent.parent();
}
r.parents = parents.join(".");

sendRequest(r);
_selected = "";
r.remove = null;r.selected = null;r.type=null;r.parents = null;
}
else{showError(1);}
}

else if(checkAttribute == "" || super.selectedItem.@[checkAttribute] != true){
captureTreeSettings();
r.selected = super.selectedItem.@[_idField];
r.remove = true;

var parent:XML = _TreeXML.descendants().(@[_idField]==super.selectedItem.@[_idField]).parent();
var parents:Array = new Array();

while(parent.parent() != null){
parents.push(parent.@[_idField]);
parent = parent.parent();
}
r.parents = parents.join(".");

sendRequest(r);
_selected = "";
r.remove = null;r.selected = null;r.parents=null;r.type=null;
}
else{showError(2);}
}
}

public function sendRequest(o:Object = null):void{
if(o == null){o = _request;}
_httpService.send(o);
}

internal function setSelected(x:Boolean = true):void{
if(x == true){_selected = super.selectedItem.@[_idField];}
_selectedLabel = _TreeXML.descendants().(@[_idField]==_selected)[super.labelField];
var parent:XML = _TreeXML.descendants().(@[_idField]==_selected).parent();
while(parent.parent() != null){
_selectedLabel = parent[super.labelField] + " > " + _selectedLabel;
parent = parent.parent();
}
}

internal function showError(x:int):void{Alert.show(_errorMessages[x]);}

internal function updateNode():void{
if(updateItems === false && doUpdate === true && TextInput(super.itemEditorInstance).text != "" && _origName != TextInput(super.itemEditorInstance).text){
doUpdate = false;
captureTreeSettings();
var r:Object = _request;
r.selected = super.selectedItem.@[_idField];
r.new_name = TextInput(super.itemEditorInstance).text;
r.type = super.selectedItem.name();
sendRequest(r);
r.new_name = null;r.selected = null; r.type=null;
}
else if(TextInput(super.itemEditorInstance).text == ""){TextInput(super.itemEditorInstance).text = _origName;}
}

internal function updateTree(e:Event):void{
if(updateItems === true){
updateItems = false;
super.invalidateDisplayList();
AlreadyOpened = new Array();

// We don't store the categories parents and everything necessarily, so we need to find our way back up the XML and open all of the categories parent folders.
if(_selected != "" && _reselect != false){
var parent:XML = _TreeXML.descendants().(@[_idField]==_selected).parent();
while(parent.parent() != null){
if(in_array(Opened,parent.@[_idField]) === false){Opened.push(parent.@[_idField]);}
parent = parent.parent();
}
}

for(var x:String in Opened){
if(in_array(AlreadyOpened,Opened[x]) === false){
super.expandItem(_TreeXML.descendants().(@[_idField]==Opened[x]).parent().children()[_TreeXML.descendants().(@[_idField]==Opened[x]).childIndex()],true,false,false);
}
}

// We can't set selectedItem to the node if it's parents aren't opened, so we have to do it here in another "if" statement.
if(_selected != "" && _reselect != false){
super.selectedItem = _TreeXML.descendants().(@[_idField]==_selected).parent().children()[_TreeXML.descendants().(@[_idField]==_selected).childIndex()];
setSelected(false);
}

super.validateDisplayList();
super.verticalScrollPosition = VScroll;
super.horizontalScrollPosition = HScroll;
doUpdate = true;
}
}

//////////////////////////////////// END OF FUNCTIONS /////////////////////////////////
}
}