Make Explorer-type Apps a Snap

by Francesco Balena

Reprinted with permission from Visual Basic Programmer's Journal, 5/98, Volume 8, Issue 6, Copyright 1998, Fawcette Technical Publications, Palo Alto, CA, USA. To subscribe, call 1-800-848-5523, 650-833-7100, visit www.vbpj.com, or visit The Development Exchange

Write smart classes that show their data with almost no code in your form modules.

When you have to display data hierarchically, the natural solution is to write an application that looks like Windows Explorer, with a TreeView control on the left and a ListView control on the right. This organization looks familiar to users and makes for a gentler learning curve.

In this article I'll show how to organize such a program and I'll introduce a class module that encapsulates most of logic behind it. In the process, I'll also demonstrate a few interesting features of VB classes that you'll find useful in other situations.

Explorer-like applications can manage databases with different kinds of data—orders, customers, invoices, cars, animals—in a word, anything. Yet all of them share a few common patterns. In all cases you load the TreeView control with "object names," and when the user clicks on some item, you show the corresponding properties in the ListView control. You do all that by writing code in a few key event procedures: Form_Load, where you load the TreeView control with data, and TreeView_NodeClick, where you show a set of values in the companion ListView control. If you want to add children to a node only when the user expands it, you also need to write code in the TreeView's Expand event.

Data hierarchies can be recursive or nonrecursive, depending on how many times the same object appears in the tree. The hierarchy of the directories on your hard disk, as shown by Windows Explorer, is an example of a recursive hierarchy because a directory can contain subdirectories. The Windows Explorer hierarchy is recursive in another, subtler sense: if you go down from the Desktop root to the C:\Windows directory, you find the Desktop subdirectory, which contains the same objects as in those contained in the root node. If you don't take precautions, you might count the same object twice when scanning the tree.

Conversely, the Project Explorer in the VB IDE shows a nonrecursive hierarchy of projects and modules. In a sense, recursive hierarchies are simpler because you have to manage only one kind of object. This article shows how to create and manage a recursive hierarchy of CDirectory objects, each one representing a disk directory. But you can expand the concepts exposed here to work with nonrecursive hierarchies.

A typical Explorer-like program has to maintain a correspondence between each node in the TreeView control and the real object it refers to, so that when the user clicks on the node, the program can show its details in the ListView control. In a few cases, you can retrieve the object using the FullPath property of an individual node—for instance, when showing a directory tree—but this strategy doesn't work when you're not sure sibling nodes have unique names, such as when two coworkers in the same department have the same name.

Therefore, you generally need to maintain an array of records that holds the correspondence between nodes and objects:

Type TNodeData
   Node As ComCtlLib.Node
   Item As Variant
End Type
Dim data() As TNodeData

The Item element is a piece of information that lets you access either the object—such as the bookmark in a recordset—or a reference to the object. An array of records is only one of many possible data structures you can use to store such data; you might also use collections. But the important point is that it's up to you to maintain the structure, and update it when nodes are added to and removed from the TreeView control. You do this by trapping user actions in event procedures.

The problem with event procedures is that they make code reuse difficult. When it's time to write another Explorer-like application, you have to copy and paste all the involved routines, then change the names of the controls and the statements that show data in the ListView controls. You also have to copy and paste the declarations of the data structures and adapt them to the new program's logic.

This is not the most efficient way to write code today—at least not when you can encapsulate your programming logic into a class module.

Be sharp, use a class

You can take advantage of VB's class modules to greatly streamline the design and implementation of your applications. I'll show you how to use a class I call CExplorer to encapsulate the logic behind a typical Explorer-like application, so that you never need to write the same code twice. The CExplorer class works as a wrapper class around a TreeView and a ListView control. The main program interacts with the class instead of the actual controls, and lets the class react to the events raised by the controls.

The trick that makes all this possible is the WithEvents keyword. The CExplorer class holds a reference to the TreeView and ListView controls in two private variables declared using WithEvents, so the class can intercept the events raised by the control. What astonishes me is that the official VB documentation shows how to use WithEvents only to catch events raised by your own objects or external objects, but doesn't hint that you can also use it to trap events coming from standard VB's forms and controls. This is an important detail, and the CExplorer class is heavily based on it.

Even if the CExplorer class intercepts the events coming from the TreeView and ListView controls, that doesn't prevent the programmer from also creating event procedures in the main form; the class and the form might respond to events raised by the same source. However, you can't assume anything about the order in which you receive the events, and you must be careful not to write code that depends on the sequence of event procedure activation.

The CExplorer class manages both the TreeView and ListView controls, but the bulk of its source code—available from the Registered Level of The Development Exchange—is devoted to the administration of the TreeView control, the more complex of the two. Initialize the class using CExplorer's Init method, which you usually do in the Form_Load event procedure:

Dim WithEvents Explorer As CExplorer

Private Sub Form_Load()
   Set Explorer = New CExplorer 
   ' the third argument is the index of 
   ' an image in the companion 
   ' ImageList control
   Explorer.Init TreeView1, _
      ListView1, 1
End Sub

The CExplorer class really shines when you use it to display a hierarchy of objects that correspond to class modules in your application. For example, take the CDirectory class (see Listing 1). You can display the directory tree of drive C using this code:

[Listing 1] VB5

Option Explicit

Public Path As String

' the "name" of the directory is simply the portion of
' the path that follows the last backslash, or the whole
' path if this is a root directory

Property Get Name() As String
   Dim i As Integer
   ' get the last backslash
   i = InstrLast(1, Path, "\")
   If i = Len(Path) Then
      Name = Path
   Else
      Name = Mid$(Path, i + 1)
   End If
End Property

Function Subdirs() As Collection
   Dim newDir As CDirectory, patb As String, _
      Dim dirPath As String
    
   Set Subdirs = New Collection
   ' add a backslash to the path if necessary
   patb = Path & IIf(Right$(Path, 1) <> "\", "\", "")
    
   dirPath = Dir$(patb & "*.*", vbDirectory)
   Do While Len(dirPath)
      If GetAttr(patb & dirPath) And vbDirectory Then
         ' it is a directory, now discard "." and ".."
         If Left$(dirPath, 1) <> "." Then
            Set newDir = New CDirectory
            newDir.Path = patb & dirPath
            Subdirs.Add newDir
         End If
      End If
      ' get the next item, exit if null string
      dirPath = Dir$()
   Loop
End Function

' returns the collection of files in the directory

Function Files() As Collection
   Dim patb As String, dirPath As String
   Set Files = New Collection
   ' add a backslash to the path if necessary
   patb = Path & IIf(Right$(Path, 1) <> "\", "\", "")

   dirPath = Dir$(patb & "*.*")
   Do While Len(dirPath)
      Files.Add dirPath
      ' get the next item, exit if null string
      dirPath = Dir$()
   Loop
End Function
 
' get the last occurrence of a string

Private Function InstrLast(ByVal Start As Long, _
   ByVal Source As String, ByVal Search As String, _
   Optional Cmp As VbCompareMethod) As Long
      Start = Start - 1
      Do
         Start = InStr(Start + 1, Source, Search, Cmp)
         If Start = 0 Then Exit Function
         InstrLast = Start
      Loop
End Function

Listing 1: The CDirectory Object Activates CExplorer. This simple class exposes only two properties and two methods, but that's enough to show the CExplorer class in action.

Dim root As New CDirectory
root.Path = "C:\"
Explorer.AddNode root.Name, root, True

The AddNode method adds a node to the TreeView control and associates it with the object passed as its second argument—root in this case. You don't need to store the relationship among nodes in the TreeView control and objects managed by your program, because the CExplorer class does it automatically. The third argument should be True if the node just added has children, and the class uses this information to display a "+" sign beside the item in the TreeView control.

When the user expands a directory node to show its subdirectories, the CExplorer class raises the GetChildren event in the parent form:

Private Sub Explorer_GetChildren( _
   data As Variant)
      Dim currDir As CDirectory
      Dim subdir As CDirectory
      Set currDir = data
      ' add all subdirectories
      For Each subdir In _
         currDir.Subdirs
         Explorer.AddNode subdir. _
            Name, subdir, 1, currDir
      Next
End Sub

The GetChildren event is functionally similar to the TreeView control's Expand event, but it passes the program a reference to the object being expanded: a CDirectory object, in this case. You therefore can reason in terms of directories instead of tree nodes, which is a clear advantage. The fourth argument of the AddNode method is the parent object; omit it when adding a root object.

Any Explorer-like program also must show the individual items in the rightmost pane (the ListView control) when the user clicks on an item in the TreeView window. Thanks to the CExplorer class, you can implement this behavior by writing a few lines of code in the GetItems event:

Private Sub Explorer_GetItems( _
   ByVal data As Variant)
      Dim currDir As CDirectory
      Dim file As Variant
      Set currDir = data
      ' the ClearItems method clears
      ' the ListView control
      Explorer.ClearItems
      For Each file In currDir.Files
         ' the AddItem method adds
         ' an item to the listview 
         ' control
         Explorer.AddItem CStr(file)
      Next
End Sub

Et voila, your first Explorer program is completed (see Listing 2 and Figure 1). It can't compete with Windows Explorer—it doesn't let you add, rename, or delete a directory, to name a few limitations—but it's OK when your customers need only to select one or more files. Even better, if you hide the ListView pane, you can use it as a dialog where your users can select a directory. This use goes beyond the capabilities of standard File common dialogs, and it's a good starting point for a more ambitious program.

Figure 1: Exploit This Working Class.

The CExplorer class intercepts the events raised by the TreeView and ListView controls, and asks the main program to expand nodes in the tree and show values in the list. The main application queries the object that actually contains the information—CDirectory in this case—and returns data to the CExplorer class. The application can directly access the TreeView and ListView controls if necessary, so the CExplorer class is not an obstacle if you want to access all the capabilities of these controls.

[Listing 2 ] VB5

Option Explicit

Dim WithEvents Explorer As CExplorer

Private Sub Form_Load()
   RefreshTree
End Sub

Private Sub Form_Resize()
   TreeView1.Move 0, 0, ScaleWidth / 2, ScaleHeight
   ListView1.Move ScaleWidth / 2, 0, ScaleWidth / 2, _
      ScaleHeight
End Sub

Sub RefreshTree()
   Set Explorer = New CExplorer
   Explorer.Init TreeView1, ListView1, 1, 2, 3
   ' add the "C:\" root
   Dim rootDir As New CDirectory
   rootDir.Path = "C:\"
   Explorer.AddNode rootDir.Name, rootDir, 1
End Sub

' expand a directory node

Private Sub Explorer_GetChildren(data As Variant)
   Dim currDir As CDirectory, subdir As CDirectory
   Set currDir = data
   For Each subdir In currDir.Subdirs
      Explorer.AddNode subdir.Name, subdir, 1, currDir
   Next
End Sub

' show files in the rightmost pane

Private Sub Explorer_GetItems(ByVal data As Variant)
   Dim currDir As CDirectory, file As Variant
   Set currDir = data
    
   Explorer.ClearItems
   For Each file In currDir.Files
      Explorer.AddItem CStr(file)
   Next
End Sub

Listing 2: Two Events Are All You Need. This is an example of a program that uses the CExplorer class. It mimics Windows Explorer, but allows browsing the C:\ path only and doesn't offer support for creating, renaming, and deleting subdirectories and files.

Exploring CExplorer

The source code of the CExplorer class includes a lot of remarks, so you shouldn't have trouble understanding how it works if you grasp the methods and properties it exposes (see Table 1). I intentionally left out several useful properties, methods, and events, and left them to you as an exercise.

[Table 1]

Property/Method/Event

Description

Sub Init(TreeView As TreeView, [ListView As ListView], [Image], [SelectedImage], [ItemImage])

Links the CExplorer class to a TreeView control and, optionally, to a ListView control. Image and SelectedImage are the indices of the default icons to be used for items in the leftmost pane, whereas ItemImage is the default icon for the items in the rightmost pane (that is, in the ListView control). The Init method must be invoked before any other method or property.

Function AddNode(text, [Data], [HasChildren], [Parent], [Image], [SelectedImage]) As ComctlLib.node

Adds a new node to the TreeView control. Text is the node's caption; Data is the object it represents; HasChildren is True if the node has any children (a plus sign will be drawn beside the node). Parent is the parent object in the hierarchy—if it's omitted, the node is considered to be the root of the hierarchy. Image and SelectedImage are the indices of the icons that represent the object in its normal and selected state, respectively (if omitted, their default values are used). This method returns a reference to the node that has been added to the TreeView control by the CExplorer class.

Sub ClearNodes

Clears the contents of the TreeView control.

Sub RemoveNode(item)

Removes an item from the TreeView control. Item can be either a TreeView control's node object or the data it represents.

Function GetNodeFromPath(FullPath) As ComctlLib.node

Returns a node object given its full path.

Property ItemData(item) As Variant

This property sets or returns the data associated with a given node; the item argument can be either the node object, its numerical index, or its string key.

Property AllowDelete As Boolean

If True, the user can delete an item by selecting it and then pressing the Del key.

Property AllowRename As Boolean

If True, the user is allowed to rename an item in the TreeView control.

Function AddItem(text, [subitem1, subitem2, ...]) As ComctlLib.ListItem

Adds a new item to the ListView control. The first argument is the item's text, and all other (optional) arguments are the subitems. This function returns a reference to the ListView's item just added, which can be used to assign additional attributes such as the item's icon, if different from the default icon specified in the Init method.

Sub ClearItems

Clears the contents of the ListView control.

Sub RemoveItem(index)

Removes an item in the ListView control; the argument is the index or the key of the ListItem object to be removed.

Sub SetColumns(title1, width1, title2, widtb, ...)

Sets the title and the width of the ListView control's column headers.

Event GetChildren(Data)

This event is raised when the user expands a TreeView control's node. The parent form should react to this event by adding all the children of the object passed as an argument.

Event GetItems(Data)

This event is raised when the user clicks on an item in the leftmost pane. The parent form should react to this event by filling the ListView control with the information related to the object passed as an argument.

Event RenameNode(Data, NewName, Cancel)

This event is raised after the user has renamed an item in the TreeView control; the Data argument is a reference to the object corresponding to the node being renamed, NewName is the string typed by the user, and Cancel can be set to True to cancel the operation. This event is not raised if the AllowRename property is set to False.

Table 1: Enlarging the CExplorer Interface. This is the list of properties, methods, and events exposed by the CExplorer class. Once you understand its inner workings, you can expand it at will. For instance, you can add a RenameItem event that fires when the user renames an item in the ListView control.

Interestingly, the CExplorer class is completely generic, and therefore works with any object. The programmer using the class can focus on the objects, which contain the real data, without paying attention to the actual controls, items, and nodes used to display it. After initializing the class, you need only to react to the events that the class raises in the parent form (see Figure 2).

Figure 2: The New Class Knows More.

This new version of the CExplorer class talks directly to the objects to be displayed in the TreeView and ListView controls. The main application is simpler, because all it does is assign one object to the Root property of the CExplorer class. You can reuse the CExplorer class readily with any other class—for instance, a class that explores the Registry or a class that represents an enterprise department—provided that it exposes the IExplorerItem secondary interface.

Even though the CExplorer class is generic, it's not necessarily inefficient. In fact, the class uses a clever technique for expanding nodes, which you could label "load on demand." Many Explorer-like applications fill the TreeView control with all the items before the form is made visible. The problem with this simple approach is that loading items in a TreeView control is a relatively slow operation. When you're dealing with hundreds or thousands of items, you need a more efficient approach.

The AddNode method of the CExplorer class includes an optional parameter—HasChildren. If you set this value to True, you inform the class that the item being added has one or more children, but you are not going to add them right away. The CExplorer class then shows a "+" symbol beside the node in the tree, and when the user expands the node, the class fires a GetChildren event in the parent form. That means you can display huge hierarchies without worrying about performance issues; you simply add the root node and let the form react to events raised by the class. I find this a pretty good way to exploit the WithEvents keyword and the event-driven programming paradigm, and I believe this kind of behavior should have been embedded in the TreeView control in the first place.

The CExplorer class implements the "load on demand" technique using an interesting trick. Whenever the program invokes the AddNode method with HasChildren set to True, the CExplorer class adds the desired node, then creates a "dummy" child node that makes the "+" symbol appear. The TreeView control that ships with the most recent versions of Internet Explorer allows it to show the plus sign using an API call and without adding such dummy children. But I use this technique because it makes my class independent of the version of Comctl32.dll installed in the target system.

When the user expands a node, the class deletes the dummy child, then raises a GetChildren event. If the program reacts to the event without adding any child nodes, the "+" sign simply disappears. Otherwise the node expands as usual and the user won't realize that the new items now visible were actually added on the fly. The GetChildren event is fired only the first time a node expands, so the program executes this process only once.

You can take advantage of other TreeView events not exposed by the CExplorer class—the Collapse event and all the drag-and-drop events, for example—but in that case you need a way to understand which CDirectory object relates to the node being collapsed or dragged. You can get that information using the ItemData property of the CExplorer class.

Here's a practical example: when the user places the mouse cursor over a node that represents a directory, you want to show its complete path and the number of files in that directory on a label control. Do this by trapping the TreeView control's MouseMove event:

Private Sub TreeView1_MouseMove( _
   Button As Integer, Shift As _
   Integer, x As Single, y As Single)
      Dim n As node, d As CDirectory
      Set n = TreeView1.HitTest(x, y)
      If Not (n Is Nothing) Then
         Set d = Explorer.ItemData(n)
         lblStatus = d.Path & " (" _
            & d.Files.Count & " _
            files)"
      End If
End Sub

CExplorer offers an interesting example of how you can wrap a class around two controls. You can add even more intelligence to it, but you must shift your perspective to take this additional step.

use the intelligence of Classes

The data in an Explorer-like application is nearly always organized as objects, so it's time to see things from an object-oriented point of view. A CDirectory object knows what its child objects (its subdirectories) and items (the files it contains) are. Similarly, a CDepartment object that represents an enterprise department knows that the TreeView's items beneath it represent its subdepartments and that the ListView control should show the employees that work in the department.

Because objects know about their children and their properties, you can let them communicate directly with the CExplorer class. Instead of writing code in each form that manipulates CDirectory objects, you can write code directly into the CDirectory class. Then the next time you create an Explorer application that deals with directories, you won't need to copy and paste code. In other words, you move the knowledge from form modules into class modules, which is where the knowledge really belongs. It's one way to take advantage of code reuse, the promise of object-oriented programming.

Here are the details. You implement an abstract class, IExplorerItem, which defines an interface that comprises one property and four methods:

' the text showed in the TreeView
Public text As String
Function HasChildren() As Boolean
   ' returns True if has any children
End Function
Function GetChildren() As Collection
   ' returns the collection of children
End Function
Sub ShowItems(Explorer As CExplorer)
   ' shows the properties
End Sub
Sub Delete()
   ' deletes the object
End Sub

Interfaces are contracts; if you build an object that implements the IExplorerItem interface, you agree to write code that behaves as described by the interface specifications. The Text property is the string shown in the TreeView control; HasChildren is a property that informs whether an object has one or more child objects, and that CExplorer uses to show the "+" symbol besides the node; GetChildren returns the collection of child objects; ShowItems shows the properties of the contained class in the ListView pane. Finally, the program invokes the Delete method when the user tries to delete an object using the Del key, or when the program executes a RemoveNode method.

The CDirectory class already includes all the routines that enumerate child objects and properties, so implementing the IExplorerItem interface requires only a few lines of code (see Listing 3). This code was originally contained in the parent form, so you haven't written more statements than necessary; you've just moved them elsewhere in the program. So where is the convenience? The advantage of using the IExplorerItem secondary interface is obvious when you look at what's left in the form:

Dim Explorer As CExplorer

Private Sub Form_Load()
   Set Explorer = New CExplorer2
   Explorer.Init TreeView1, ListView1
   ' add the "C:\" root
   Dim rootDir As New CDirectory2
   rootDir.Path = "C:\"
   Set Explorer.Root = rootDir
End Sub

That's it! You built a Windows Explorer clone in fewer than 10 lines of code! You simply create one CDirectory object and assign it to the Root property of the CExplorer class. Because I have defined this property as IExplorerItem, you can assign it only objects that expose the IExplorerItem interface. From that point on, your main program sits apart, and the CExplorer object talks directly to CDirectory objects through their IExplorerItem interfaces.

The CExplorer class is versatile, in that at run time it checks whether an object exposes the IExplorerItem secondary interface. If not, the CExplorer class communicates with the main program through events, as its first implementation did. Therefore, you can always choose the model that fits your particular programming needs. However, keep in mind that the approach based on secondary interfaces lets you build more robust and efficient applications. Events are inherently late-bound, and they can return values to the CExplorer class only through arguments, whereas you can implement methods as functions that return the value—as in the case of HasChildren and GetChildren.

The CExplorer class is an example of what you can do with advanced OOP features of VB5, namely the WithEvents and the Implements keywords. It's also a good example of a correct object-oriented approach to user interface programming, because it shows you how to take code out of forms and move it into class modules, where you can reuse it more easily. Some day VB might become a true OOP language, but you don't have to wait until then to use its object-oriented features. n

Download the code for this article here