When you write large and complex forms, you probably end up with many controls of the same type (text boxes, command buttons, and so forth), like properties and similar event procedures. An example that is very common in all business applications is text fields that restrict their input to numbers only.
It is not difficult to create such text boxes. Simply write some code in their KeyPress event procedures to filter out any non-digit key. But you must write the same code over and over, which surely contrasts with the RAD (Rapid Application Development) nature of Visual Basic.
To help you in these programming chores, Visual Basic offers the capability to build a control array--a group of controls of the same type that share a single set of event procedures. Although the items in a control array have the same name and the same base type, no other restrictions are imposed on them. For instance, the same control array may host both regular and multi-select list boxes; each item may have a different value for its ForeColor, BackColor, or Sorted properties, and so on. However, it is not possible for this array to include controls of different types. For instance, you cannot add combo box controls to an array based on list boxes, even if these types of controls share most of their properties, methods, and events.
Control arrays are very handy on many occasions. Not only do they simplify the implementation of common functionality among multiple controls, but they also permit you to dynamically create new controls at run time, which is not otherwise possible in Visual Basic.
You can create a control array in a number of ways. As different as they may appear, they ultimately deliver identical results. Therefore, you can freely choose any of the following approaches, according to your preferences and mood.
Say you have two TextBox controls on your form, Text1 and Text2. You realize that they should be part of the same control array, named txtArray. Simply go to the Properties window and type the txtArray string for the Name property of both of them.
NOTE: If you're not sure whether you want or need to use control arrays, skip ahead to the "The Benefits of Control Arrays" section later in the chapter. Convinced, you then can return to this material and get about the business of creating your arrays.
As soon as you confirm the string for the second control, Visual Basic realizes that you might want to create a control array and shows the message box in Figure 12.1. Just press Y to confirm, and you'll have a brand new array of controls.
FIG. 12.1
This message box informs that you are about to create a control array.
This procedure is most appropriate when you are creating a control array that includes two or more controls that already exist on the form. If you know in advance that you want to create a control array, other procedures might be preferable.
CAUTION: You should always make your decisions about control arrays before writing the code that references the controls themselves. In fact, you must use a different syntax when referring to an item of a control array; therefore, if you gather two or more existing controls into an array, you are forced to revise all the code that references them before you are able to run the program without syntax errors. You could do some of this work by using a simple Search-and-Replace command, but you would have to rewrite all the relevant event procedures.Worse yet, even if the program runs without any syntax errors, it probably still conceals a number of subtle bugs that will not appear until later in the development stage. Therefore, you are compelled to re-test every single block of statements that reference the controls.
You can avoid these troubles if you create your control arrays before writing the code that uses the individual controls.
Using the Clipboard is the most common way to create control arrays. Say you place a text box on your form. If you want it to be the first item of your brand new control array, you can just follow this simple procedure:
When you copy a control to the Clipboard and then paste it back to the form, you are implicitly asking Visual Basic to create a replica of the original control. The new control inherits all the properties of the original one, including its name. When Visual Basic detects an attempt to create a control with the same name as another control on the same form, it assumes that you mean to create a control array and asks for a confirmation.
CAUTION: After you press Ctrl+C to copy the selected control to the Clipboard, the control is no longer selected. The selection handles move to the parent form instead. This happens because VB anticipates your intention to paste the copy on the form surface, which is exactly what you mean to do most of the time. In fact, the control in the Clipboard is pasted on the object--form or control--that is currently selected.However, if you need to place the new control within another container control--a PictureBox or Frame control, for example--you need to select the container before pressing Ctrl+V.
If you reply No to the message box, VB creates a new control of the same type as the original; the new control inherits all the original properties except the name. Two controls may share a common name only if they belong to the same control array.
TIP: Experienced Visual Basic programmers often take advantage of this feature to create controls that are of the same type and share similar attributes, even if they don't belong to a control array. For instance, suppose that you must create several multi-lined TextBox controls of the same size, with their Text property set to a null string and their ScrollBars property set to 3 - Both. You may save a lot of typing and mouse activity by creating the "prototype" TextBox control, setting its properties as required, and then following the preceding procedure up to step 3. Then you answer No when the message box appears.This procedure even works across forms. In fact, you can copy controls from one form to another by copying them to the Clipboard and pasting them wherever you need them. VB won't even try to create a control array (unless, of course, the target form already includes a control with the same name). This procedure even works with groups of controls, which you can copy and paste using a single action.
This procedure is most appropriate if you are placing new controls on the form, and you know in advance that they must belong to a control array. (You may sometimes decide that a control is part of a control array only after you have placed it on the form.) You cannot use this Clipboard procedure if you are building a control array that initially includes only one item.
If you followed one of these suggested procedures, you may have noticed that Visual Basic automatically assigns a value to the Index property when you confirm the creation of a control array. This value is the numeric index that identifies an individual item belonging to a control array. Usually the first control of the array is assigned a zero index, the second control corresponds to Index = 1, and so on. However, indexes may assume any positive integer value, and they don't need to be consecutive (even if in most cases they are). The only obvious restriction is that two distinct controls in the same array can't receive the same index.
When you create a regular control that doesn't belong to a control array, its Index property is left blank. But if you go to the Properties window and manually change it to an integer value, you may turn it into an element of a control array. No message box appears in this case because Visual Basic assumes that you know what you're doing when you play with the Index property.
Setting the Index property is the only procedure that permits you to build control arrays consisting of a single element. Even if an array with only one element seems pretty useless, such arrays are very common if you plan to add items dynamically at run time.
See "The Load and Unload Commands" section in this chapter, for more information about creating controls at run time.
After you have created a control array, you may add as many items as you need. You can add an existing control to the array by changing its Name property, or you can apply the copy-and-paste procedure outlined previously. The latter technique is most effective when you are adding many items. Just copy a control to the Clipboard once, and then paste it repeatedly for the appropriate number of times.
Whatever procedure you are following, Visual Basic creates the control array and won't show the same warning again after you reply Yes to the first message box.
NOTE: When you paste a control from the Clipboard to your forms, its Index property is set to the lowest unused value, starting with 0. This does not pose a particular problem unless your program expects the lowest index of the control array to be 1 or any other non-null value.If you need to create a one-based control array with N items, use the copy-and-paste procedure previously described to create N+1 items--with indexes ranging from zero to N--and then delete the first item.
When the first element of a control array has a non-null index, you may have problems when you later decide to add one or more controls to the control array. In fact, the usual copy-and-paste approach creates a zero-indexed element. You have to remember to manually change its index to a correct value. In most cases, using zero-based control arrays is preferable and tends to prevent a number of elusive bugs. If your programming logic expects a control array with a different initial index, at least add a remark in code.
If you want to remove an item from a control array and turn it into a regular, stand-alone control, Visual Basic doesn't permit you simply to reset the control's Index property to the null value (see Figure 12.2). Instead, you have to change its name first, and then blank its Index property value.
FIG. 12.2
This figure demonstrates what happens when you try to blank the Index
property value of a control array item that is not the only element of the array.
However, Visual Basic does let you blank the Index property value without first changing the Name property if the current control is the only control in the array. This procedure lets you undo an accidental creation of a control array that you don't plan to use.
After you have created a control array, you can manipulate its elements from your code. This section explains how to do so and how to avoid a number of potential pitfalls.
You use an index to reference in code a control that belongs to a control array, just as you do for an item of a regular array:
` set a property of a control in an array txtFields(0).Text = "" ` invoke a method of a control in an array txtFields(0).Move 100, 200
However, unlike regular arrays, you cannot DIMension a control array, nor can you Erase it. Above all, you cannot create multi-dimensional control arrays: all control arrays are mono-dimensional.
You can have the Visual Basic editor create an empty event procedure by using the same technique that you use for a regular control: you double-click the control (or select its name in the combo box in the upper-left corner of the Code window) and then click the event name in the combo box on the right. Here, for instance, is what you get if you double-click a text box that belongs to a control array:
Private Sub Text1_Change(index As Integer) End Sub
As you see, this event procedure is passed an additional argument, which is the index of the element that is raising the event. This added argument always precedes any other value passed to the event:
Private Sub Text1_KeyPress(index As Integer, KeyAscii As Integer) End Sub
Because you have the index, it is very easy to address the properties of the control that raised the event or to invoke one of its methods:
Private Sub Text1_KeyPress(index As Integer, KeyAscii As Integer) ` reject the spacebar if the textbox is currently empty If Text1(index).Text = "" And KeyAscii = 32 Then KeyAscii = 0 End If End Sub
The capability to gather all the code related to a group of controls in one place is one of the key features of control arrays. This capability is discussed in more in depth in a moment.
Even though it is not clear to many VB programmers, even to a few experts, control arrays are objects in themselves, and they expose a number of methods (see Figure 12.3):
Method | What It Returns |
Count | The number of items in the control array |
LBound | The index of the first element in the array |
UBound | The index of the last element in the array |
Item(n) | The Nth element in the control array |
FIG. 12.3
If the List Data Member option is on, you can have the VB IDE suggest all
the methods of the Control Array object.
The List Data Member option is found by choosing Tools, Options, and then the Editor tab. The LBound and UBound methods are very similar to the functions with the same names that you use with arrays, but their syntax is different:
` show lower and upper indexes for a regular array Print LBound(arr), UBound(arr) ` show lower and upper indexes for a control array Print Command1.LBound, Command1.UBound
At a first glance, it might seem that the Count method is superfluous. With regular arrays you can evaluate the number of items by using LBound and UBound functions. However, always keep in mind that control arrays are not required to contain all the elements with indexes from LBound to UBound; therefore, you can only evaluate the number of elements in a control array by using the provided Count property:
` the number of items in a regular array Print UBound(arr) - LBound(arr) + 1 ` the number of items in a control array Print Command1.Count
The Item method is almost always omitted, which is possible because it is the default method for the control array object. When you omit it, the usual syntax that we've been using so far results:
` an example of using an explicit Item method Print Command1.Item(0).Caption ` the same statement, omitting the Item method Print Command1(0).Caption
One of the biggest advantages of control arrays is that you can iterate on their items by using a regular For...Next loop. For instance, you can clear the contents of all the text boxes in a control array with just three lines even if the array itself contains dozens of controls:
For i = txtFields.LBound To txtFields.UBound txtFields(i).Text = "" Next
However, the above code fragment generates an error if there are "holes" in the index numbering. If you try this loop on a control array of three elements that have indexes of 1, 2, and 4, for example, an error is raised: Control array element `3' doesn't exist. Fortunately, Visual Basic provides the For Each...Next variant, which works even in such a situation. Here is the correct way to perform the same loop:
Dim ctrl As Control For Each ctrl In txtFields ctrl.Text = "" Next
You may also declare the object variable used in the loop to be of the same type as the controls in the array (TextBox, in this case), which speeds up the loop:
Dim tb As TextBox For Each tb In txtFields tb.Text = "" Next
Up to this point, this chapter has discussed what are often called static control arrays. The adjective static is used because the array does not change in size during the life of the program. However, a control array can actually grow at run time, in the sense that new controls can be added by code, without having the programmer create them at design time.
You can create new controls in code by using the Load command:
Load txtFields(4)
It is very important that the index between the brackets does not refer to a control that already exists. If it does, a runtime error occurs: Object already loaded, error code 360. If you are not sure about the first available index, you may resort to the UBound method:
Load txtFields(txtFields.UBound + 1)
If you dynamically created an element at run time you could also destroy it by using the Unload command:
Unload txtFields(4)
Keep in mind that the Unload command works exclusively with controls that were created by code earlier in the program. If you attempt to unload a control that appears on the form at design time, you get the error Can't unload controls created at design time - error code 362. And, of course, an error occurs if you try to unload a control that is not loaded at all.
The capability to dynamically create and destroy controls at run time is maybe the most interesting feature of control arrays. This feature offers an elegant and concise way to solve a number of programming problems that would otherwise be very difficult to face. This important topic is discussed later in the chapter in the section "Using Dynamic Control Arrays."
Now that you understand the basics of control arrays, you learn why and when you should make use of control arrays. You are also introduced to a number of useful routines for centralized input data validation, which you can easily reuse in your future applications.
Earlier you saw that one of the most common uses for control arrays is to create groups of controls of the same type that share similar properties. This capability is especially useful with text box controls, which often require a great deal of code in their event procedures to accomplish a particular behavior.
Say you have a number of fields and you need to automatically convert to uppercase whatever the user types in them. If you gather your textboxes in a control array, you have to write only one event procedure instead of many:
Private Text1_KeyPress(index As Integer, KeyAscii As Integer) ` convert the key to upper case KeyAscii = Asc(UCase$(Chr$(KeyAscii))) End Sub
This approach enables you to save many lines of code. You can even add new fields to this control array, which automatically inherit the capability to convert their input to uppercase. Text boxes that reject non-numerical keys are another common example of this approach:
Private Text1_KeyPress(index As Integer, KeyAscii As Integer) ` accept only digits If KeyAscii >= 32 Then ` don't trap control keys like BackSpace and Enter (code < 32) If KeyAscii < 48 Or KeyAscii > 57 Then KeyAscii = 0 End If End Sub
The preceding routine works well for fields that are supposed to receive positive integer values. You should modify the code as shown in Listing 12.1 if you need to also take negative integers into account:
Private Text1_KeyPress(index As Integer, KeyAscii As Integer) ` accept only positive or negative integer values Select Case Case Is < 32 ` it's a control key, do nothing Case 48 To 57 ` it's a digit, do nothing Case 45 ` = Asc("-") ` it's the minus sign - accept only if it is the first ` character in the textbox, and only if there are no other ` minus signs If Text1(index).SelStart > 0 Or InStr(Text1(index).Text, _ "-") > 0 Then KeyAscii = 0 End If Case Else ` reject any other character KeyAscii = 0 End Select End Sub
You can easily expand the code in Listing 12.1 to accommodate text boxes that accept numbers with decimal digits. For more information on this topic (which control arrays play a very marginal role in), see the following sidebar "Robust Data Validation in TextBox Controls."
Robust Data Validation in TextBox Controls
Useful code snippets explaining how to perform simple data validation in a textbox were provided in this chapter to illustrate how you can manage that validation code in a single place. In a real-world commercial application, you should follow a different, more robust approach to this problem.Control arrays enable you to write one event procedure for similar fields on a given form. But this approach cannot be extended to multiple forms. You end up writing similar event routines in each of your application's forms.
To cope with this situation, you should prepare a number of generic procedures that validate the contents of a text box and call them from within the KeyPress or Change event of each field. A simple example of this routine is shown in Listing 12.2.
Then you can validate any textbox in this simple way:
Private Sub txtLeftMargin_KeyPress(KeyAscii As Integer) ValidateInteger txtLeftMargin, KeyAscii End SubOf course, you can do the same if you want to validate the contents of a control in a control array:
Private Sub txtPaperSize_KeyPress(Index As Integer, KeyAscii As Integer) ValidateInteger txtPaperSize(Index), KeyAscii End SubNote that you must pass a reference to the particular textbox to the ValidateInteger routine. Otherwise, the routine is unable to understand where it is called from and cannot access any property of the textbox control.
However, the previous routine is still insufficient for really robust applications. A user could still accidentally (or maliciously) insert an invalid value in the field--for instance, by pasting a value from the Clipboard, which does not cause a KeyPress event. In addition, a few particular strings pass the preceding test and still don't form a valid input to the field (a single minus sign, for instance).
It doesn't take long to realize that input validation is a complex matter. Usually you need to place code in more than one event. You should probably monitor the KeyPress, KeyDown, Change, and LostFocus events, which should cooperate to build up a reliable input validation routine.
To simplify the coding, many programmers prefer to perform all of the validation chores when the user clicks the OK or Save buttons or when the form is closed. This approach--called record-level or form-level validation--is usually simpler but has a number of drawbacks. One major drawback is that the user must be requested to go back to the offending field and change its contents if a value is found to be invalid. The record-level approach differs from key-level validation (in which you immediately reject invalid keys) and field-level validation (in which you validate the contents of a field when the user moves the input focus onto another control).
The key to truly robust data validation is adopting a mixture of all three forms of validation:
- Use the KeyPress and KeyDown events to perform key-level validation and reject the keys that are incompatible with the nature of the field. For instance, you can reject any non-digit keys in a ZIP code field.
You can also use key-level validation to ensure that a field contains no more characters than allowed. Use the MaxLength property of textbox controls to reach this result.
- Use the LostFocus event to perform field-level validation and perform all the necessary tests on the whole contents of the field. You can impose this kind of validation for credit card numbers or for values that are restricted to a given [min, max] range.
You can also use field-level validation to ensure that a field contains exactly N characters--for instance, two-character abbreviations for U.S. states. Or you can use field-level validation to ensure that a field contains at least a given number of characters, which is often required the first time your user types a password.
- Finally, use record-level validation when you must compare a field to another field on the same form and are unsure about the order in which the user will visit them. For instance, you must use this approach to ensure that the contents of a StartDate field and a FinishDate field are consistent.
In some cases, record-level validation is also used instead of field-level validation for those fields whose validation would take too long, and would therefore have the user wait when moving from one field to the next one. Suppose that you are validating a ZIP field using a lookup search into a database to ensure that the code entered by the user is a valid one. If you do a field-level validation, the database search--which is a relatively slow process--would occur anytime the user visits the ZIP code field and would slow down the program to an unacceptable degree.
Sub ValidateInteger(Txt As TextBox, KeyAscii As Integer)
` accept only positive or negative integer values Select Case Case Is < 32 ` it's a control key, do nothing Case 48 To 57 ` it's a digit, do nothing Case 45 ` = Asc("-") ` it's the minus sign - accept only if it is the first ` character in the textbox, and only if there are no other ` minus signs If Text1(index).SelStart > 0 Or InStr(Text1(index).Text, _ "-") > 0 Then KeyAscii = 0 End If Case Else ` reject any other character KeyAscii = 0 End Select End Sub
You have already seen that control arrays tend to reduce the amount of code that you must write to manage a group of homogeneous controls. Let me show what I mean with another example.
Many programmers like to highlight the contents of a text box as soon as the field gets the input focus. Besides offering a visual clue, this approach lets the user edit the current value by pressing a cursor key or replace it with a new string by pressing an alphanumerical key. If all of the text boxes on the form are grouped in one control array, you only have to write one line in their common GotFocus event procedure:
Private Sub Text1_GotFocus(index As Integer) ` highlight the field contents when it gets the input focus Text1(index).SelStart = 0 Text1(index).SelLength = Len(Text1(index).Text) End Sub
Here is another little, but useful, example. If you use a lot of ComboBox controls and want to automatically open the controls' list portion when they get the input focus (thus saving your users the trouble to do this activity themselves), just gather the controls in a control array and add this simple procedure:
Private Sub Combo1_GotFocus(index As Integer) ` send an Alt-Down key combination SendKeys "%{DOWN}" End Sub
Although it may not be immediately apparent, control arrays also enable you to build shorter executable files and save memory during execution. To prove this point, I built a form with 11 text boxes on it and no code at all. I then compiled this form to native code, and obtained an EXE file of 10,752 bytes. I replaced the controls with a control array of 11 text boxes, recompiled the program, and the file shortened to 9,216 bytes. That's about 1.5K less then the original EXE, or 140 fewer bytes for each control. Not really impressive, I admit, when the average system now has 16 or 32 megabytes of RAM.
But what happens if you add some code in the event procedures related to the controls? Suppose that you wish to convert each key to uppercase. If you are dealing with regular TextBox controls, you have to write 11 distinct KeyPress event routines, such as the following:
Private Sub Text1_KeyPress(KeyAscii As Integer) KeyAscii = Asc(UCase$(Chr$(KeyAscii))) End Sub
Of course, if you adopt the control array approach, you only need a single event routine. When compiled to native code, the program based on control arrays is about 5K shorter than the program that uses regular controls (9,728 versus 14,848 bytes), which is about 500 bytes per control.
With a real-world program consisting of dozens of forms and hundreds of controls, you can reasonably expect to save several hundreds of kilobytes, or even more, depending on how complex the code in shared event procedures is. Saving these kilobytes means fewer installation disks, faster download from the Internet, and--above all--more free memory at run time. If your application has a smaller memory footprint, it usually runs faster, especially on lowly machines that only have 8 or 16 megabytes of RAM. The reduced size of your memory footprint means that your application has a larger market--you may sell more of them and make more money.
OK, I got carried away. You probably won't get rich just because you decided to use control arrays. But I think you get the point: control arrays help you deliver smaller and more efficient applications--a fact that you should never overlook.
Because they can help you gather in a few places the code associated with multiple controls, control arrays are a very useful means to reduce the huge number of event procedures often necessary in complex Visual Basic programs. Fewer procedures mean easier navigation through the program code, allowing the whole application to be more comfortably maintained.
Many programmers, especially beginners, tend to underestimate the importance of elegance in code and do not put any effort into adding a good amount of remarks, correctly indenting If blocks and loops, and so on. While the "quick and dirty" approach works well for prototypes and small-sized programs and utilities, you should not follow it for commercial quality applications, especially if you plan to maintain and update them for a number of years.
Control arrays give you cleaner code with fewer procedures, so they indirectly increase the readability of your listings. Of course, you still need a disciplined approach to code writing. You are requested to follow a (relatively small) number of guidelines and conventions, but at least you don't have to struggle with hundreds of distinct event procedures.
On the other hand, control arrays do have a readability defect, which is explained in the next section.
This is probably the wrong section for this discussion, but I thought it would be fair to balance the many advantages of control arrays with at least one defect. After all, nothing is perfect, and control arrays are no exception.
Meaningless Names for Controls If you gather more unrelated controls in one control array, you obviously lose the opportunity to assign a meaningful name to each individual control. This trait reduces the overall readability of the program because names like txtQuantity and txtUnitPrice are clearly more descriptive than, say, Text1(1) and Text(2). If you are experiencing this kind of problem, you may reduce its extent by using symbolic constants for indexes, as in:
Const QUANTITY = 1 Const UNITPRICE = 2 Const TOTAL = 3 Private Sub Text1_Change(index As Integer) If Text1(QUANTITY).Text <> "" And Text1(UNITPRICE).Text <> "" Then Text1(TOTAL).Text = Format$(Val(Text1(QUANTITY).Text) * _ Val(Text1(UNITPRICE).Text)) End If End Sub
Experienced VB programmers know how to write concise code that relies on control arrays and still give controls meaningful names. To fully understand the technique they use, you need to know about object variables.
See "Implementing OOP with Classes in Visual Basic," Chapter 18 for more information about object variables.
If object variables are new to you, you should probably jump over the remainder of this section and come back after reading that chapter.
Improve the Readability of Code Among their many capabilities, object variables let you access controls--either regular controls or members of a control array--by using different, more descriptive names. In the previous example, you might declare three form-level object variables of type TextBox:
Dim txtQuantity As TextBox Dim txtUnitPrice As TextBox Dim txtTotal As TextBox
You could then assign the three text boxes to these variables. Be sure to perform the assignments before you reference the variables. The Form_Load event is a good place to do them:
Private Sub Form_Load() Set txtQuantity = Text1(0) Set txtUnitPrice = Text1(1) Set txtTotal = Text1(2) End Sub
At this point, txtQuantity and Text1(0) actually point to the same control, so you can use them interchangeably:
Private Sub Text1_Change(index As Integer) If txtQuantity.Text <> "" And txtUnitPrice.Text <> "" Then txtTotal.Text = Format$(Val(txtQuantity.Text) * _ Val(txtUnitPrice.Text)) End If End Sub
Using the numerical index is still more convenient in certain cases--when writing generic validation code, for instance:
Private Sub Text1_KeyPress(Index As Integer, KeyAscii As Integer) ` reject a decimal point if the field already has one If Chr$(KeyAscii) = "." Then If Instr(Text1(Index).Text, ".") > 0 Then KeyAscii = 0 End If End Sub
This section shows you a number of interesting uses for static control arrays.
NOTE: Please note that I use the terms Static and Dynamic control arrays only for didactical purposes, and to introduce advanced features in a gradual manner. Regardless of the method you use to create a control array and its elements, you use the same code to manipulate items created at design time and items created dynamically at run time.
Option buttons--also known as radio buttons--are used for mutually exclusive choices. Say you have seven option buttons --optSunday, optMonday,...optSaturday. The user can select one button and store the corresponding value into a database. Somewhere in your program you presumably have the following procedure:
Function SelectedWeekDay() If optSunday.Value = True Then SelectedWeekDay = 0 ElseIf optMonday.Value = True Then SelectedWeekDay = 1 ` ... ` .. all the way down to ... ` ... ElseIf optSaturday.Value = True Then SelectedWeekDay = 6 End If End Function
The only good thing you can say about this approach is that it works. It is neither concise nor efficient. You can save some typing if you group all the seven option buttons into an optWeekday control array:
Function SelectedWeekDay() As Integer Dim i As Integer For i = 0 To 6 If optWeekday(i).Value = True Then SelectedWeekDay = i Exit For End If Next End Function
You can even use a single routine for all the arrays of option buttons used in your program, saving even more code, as shown in Listing 12.3.
Function SelectedOption(optArray As Object) As Integer Dim opt As OptionButton SelectedOption = -1 For Each opt In optArray If opt.Value = True Then SelectedOption = opt.Index Exit For End If Next End Function
This second approach, which is based on the For...Each loops, is preferable because it also works with control arrays that have non-consecutive indexes. Note that the function returns -1 if no option button is currently selected, which should never occur in a well-designed program. You call the above procedure as follows:
chosenWeekDay = SelectedOption(optWeekday)
The SelectionOption routine saves you some development time and makes your programs shorter and easier to maintain. But it is as inefficient as the If...ElseIf approach used in the first version of the SelectedWeekDay routine because it has to loop on every item in the array of option buttons. You can avoid this overhead by writing a line of code in the Click event procedure:
` this is a form-level variable Dim SelectWeekday As Integer ` ... Private Sub optWeekday_Click(index As Integer) SelectWeekday = index End Function
You can then query the SelectWeekday variable to quickly learn which option button is currently highlighted and thus avoid a relatively inefficient loop through all the items in the array.
Container controls are controls that can contain other controls. For example, PictureBox and Frame controls may work as container controls, because you can place any number of other controls on their surfaces. In this case the PictureBox or Frame control is said to be the parent control, and the contained controls are said to be children of that parent control. PictureBox and Frame controls are by no means the only container controls available, but they are probably the most common because they are native VB controls, found right in the standard control Toolbox. However, many third-party vendors sell container controls of a different nature, and you can create ActiveX controls that work as containers right in Visual Basic 5.
Control arrays are very useful when used together with container controls. A common practice among VB programmers is to put a number of controls into a container control in order to change the controls' Visible or Enabled statuses easily. If you set the Visible property of your PictureBox or Frame control to False, its child controls become invisible. Similarly, you can disable several controls in a single operation by setting their container's Enabled property to False.
You need only a few statements to achieve this result:
Private Sub Form_Load() Option1(0).Value = 1 Frame1.Enabled = False End Sub Private Sub Option1_Click(Index As Integer) Frame1.Enabled = (Index = 1) End Sub
Note that you don't need to initialize the Option1(0).Value and Frame1.Enabled properties in the Form_Load event because you can initialize them in the Properties window at design time.
You can refine this approach and prepare a pile of frames (or other container controls), only one of which will be visible in a given moment. Suppose you are writing a backup utility and want to implement the capability to perform backup chores once per day, per week, or per month. One way to implement a correct user interface is using an array of option buttons and a parallel array of Frame controls. Each of these Frame controls contains the fields that are relevant to a particular backup option. The code in the option buttons' Click event makes only the relevant frame visible and hides all the others (together with their contained controls).
While this approach is not complex from a conceptual standpoint, preparing the user interface is a nuisance in practice. All of the frames must overlap, which makes them difficult to manage at design time. I suggest using a form that is larger than its size as seen by the end user at runtime and placing the frames as shown in Figure 12.4.
FIG. 12.4
The Backup utility is shown at design time; such layout makes it easier to
deal with individual frames.
You don't have to move all the frames--one frame over the other--at design time or set their Visible property in the Properties window. You can do everything with a few statements in the Form_Load event, as shown in Listing 12.4.
Option Explicit Private Sub Form_Load() ` pile the Frames one over the other and make ` them visible or invisible Dim i As Integer For i = 0 To 2 Frame1(i).Move Frame1(0).Left, Frame1(0).Top Frame1(i).Visible = optFrequency(i).Value ext ` init combo boxes cboTime.ListIndex = 0 cboWeekday.ListIndex = 0 cboDay.ListIndex = 0 End Sub Private Sub optFrequency_Click(Index As Integer) Dim i As Integer For i = 0 To 2 Frame1(i).Visible = (Index = i) Next End Sub
When you are sure about the final effect, you may shrink the form to a proper size. The neat final effect is shown in Figure 12.5.
FIG. 12.5
You can avoid screen clutter by showing only the choices that make sense for
the desired frequency of backup.
Containers such as Frames and PictureBox controls are very useful in conjunction with TabStrip controls. The TabStrip is a Windows 95 Common Control that is similar to the SSTab control. It too lets you build tabbed dialog, but with an important difference: The TabStrip control is not a container. This control only offers a way for the end user to select which tab should be shown in a given moment.
To create the illusion of multiple tabs, you should use an array of borderless frames and make only one of them visible in a given moment (see Figure 12.6):
Private Sub TabStrip1_Click() Dim i As Integer For i = 1 To TabStrip1.Tabs.Count Frame1(i).Visible = (i = TabStrip1.SelectedItem.Index) Next End Sub
Fig. 12.6
Arrays of borderless frames are very useful when used with TabStrip controls.
By "decorative controls" I mean those controls that are placed on a form exclusively for aesthetic reasons. They have no events associated to them and are never referenced in code. In this sense, many Label controls are decorative, even when they serve as captions for controls--tTextbBoxes, lListbBoxes, and ComboBoxes--that do not have captions of their own. Similarly, the only function of most Frame, Line, and Shape controls is to show themselves on the form.
I find it very convenient to create a number of control arrays on each form: one array for all the Label controls, another for all the Frame controls, and so on. This approach optimizes my applications' performance because control arrays consume less memory at run time. Another benefit of this approach is that when you open the Object drop-down list in the Code window, you don't have to scroll past dozens and dozens of controls that you will never use in code. As a result, the really important controls stand out more clearly.
You can create arrays of Menu controls too, but the procedure used to create them is completely different than the procedures used for other types of controls. In fact, you can only build an array of Menu controls from within the Menu Editor.
See "Creating a Menu Bar," Chapter 5
When creating an array of menu items, you simply use the same name for more than one item in a given menu and assign increasing values for the Index property. You don't need to use consecutive values (that is, you can leave "holes" in the numbering), and you don't need to start with a zero index. However, you must follow a couple of requirements:
You don't really need to remember these rules because Visual Basic promptly complains when you make a mistake. Please note that the Menu Editor--unlike the Properties window--won't automatically supply indexes for you.
What's the point in organizing your menu items in arrays? Well, doing so gives you cleaner code because you can gather the code of a number of menu commands in a single event procedure. For instance, I usually organize my File and Edit menus as outlined in Listing 12.5:
Private Sub mnuFileItem_Click(Index As Integer) Select Case Index Case 1 ` New Case 2 ` Open Case 3 ` Save Case 4 ` Save As Case 5 ` ------ Case 6 ` Print Case 7 ` Print Setup Case 8 ` ------- Case 9 ` Exit End Select End Sub Private Sub mnuEditItem_Click(Index As Integer) Select Case Index Case 1 ` Undo Case 2 ` ------ Case 3 ` Cut Case 4 ` Copy Case 5 ` Paste Case 6 ` Delete Case 7 ` Select All Case 8 ` ------- Case 9 ` Word Wrap End Select End Sub
I strongly suggest that you add descriptive remarks (as in the code in Listing 12.5) so that you don't have to go to the Menu Editor to find the correspondence between indexes and menu items (see Figure 12.7).
FIG. 12.7
Create arrays of menu items by explicitly assigning an increasing value to
their Index property.
Copying and Pasting Menus
The Visual Basic IDE does ot offer an easy way to copy and paste menus and their associated code from one project to another, which forces you to write everything from scratch when you start a new project.However, you can copy and paste menus, and even whole menu trees, from one form to another if you open both the source and the destination files using a regular ASCII text editor, such as Notepad. All of the menu entries are near the beginning of the file in a section that is invisible when the FRM file is loaded into the VB environment. Listing 12.6 shows what you see in a file that includes the File and Edit menus seen previously.
To copy the File menu and the associated event procedure to another form, follow these steps:
1. Open both the source file and the destination file, using two instances of the Notepad editor (or a word processor that lets you open multiple documents at the same time).
2. In the first instance of Notepad, select all the lines in the range that start with Begin VB.Menu mnuFile and end with the End directive that precedes the Begin VB.Menu mnuEdit line.
3. Press Ctrl+C and switch to the other instance of Notepad.
4. Move the cursor to the corresponding position in the code.
5. Press Ctrl+V to paste everything there.
6. Complete the copy of the File menu by copying the Sub mnuFile_Click event procedure from the source file and pasting it into the destination file.
The only mistake you can make while following this procedure is to paste the menu definition at the wrong point in the destination file. If the target file already contains one or more menus, understanding where the code should be pasted is rather simple. If the destination file does not contain any menu yet, just keep in mind that menu definitions always go after the definition of any other control on the form. If you make a mistake, you will lose some of the data in the original destination file; therefore, I strongly suggest preparing a backup copy of the destination FRM file before proceeding.
VERSION 5.00 Begin VB.Form Form1 Caption = "Form1" ClientHeight = 3990 ClientLeft = 60 ClientTop = 630 ClientWidth = 7275 LinkTopic = "Form1" ScaleHeight = 3990 ScaleWidth = 7275 StartUpPosition = 3 `Windows Default Begin VB.Menu mnuFile Caption = "&File" Begin VB.Menu mnuFileItem Caption = "&New" Index = 1 Shortcut = ^N End Begin VB.Menu mnuFileItem Caption = "&Open ..." Index = 2 Shortcut = ^O End Begin VB.Menu mnuFileItem Caption = "&Save" Index = 3 Shortcut = ^S End Begin VB.Menu mnuFileItem Caption = "Save &As ..." Index = 4 End Begin VB.Menu mnuFileItem Caption = "-" Index = 5 End Begin VB.Menu mnuFileItem Caption = "&Print" Index = 6 Shortcut = ^P End Begin VB.Menu mnuFileItem Caption = "Print Set&up" Index = 7 End Begin VB.Menu mnuFileItem Caption = "-" Index = 8 End Begin VB.Menu mnuFileItem Caption = "E&xit" Index = 9 End End Begin VB.Menu mnuEdit Caption = "&Edit" Begin VB.Menu mnuEditItem Caption = "&Undo" Index = 1 End Begin VB.Menu mnuEditItem Caption = "-" Index = 2 End Begin VB.Menu mnuEditItem Caption = "Cu&t" Index = 3 Shortcut = ^X End Begin VB.Menu mnuEditItem Caption = "&Copy" Index = 4 Shortcut = ^C End Begin VB.Menu mnuEditItem Caption = "&Paste" Index = 5 Shortcut = ^V End Begin VB.Menu mnuEditItem Caption = "&Delete" Index = 6 Shortcut = {DEL} End Begin VB.Menu mnuEditItem Caption = "Select &All" Index = 7 Shortcut = ^A End Begin VB.Menu mnuEditItem Caption = "-" Index = 8 End Begin VB.Menu mnuEditItem Caption = "&Word Wrap" Index = 9 End End End Attribute VB_Name = "Form1" Attribute VB_GlobalNameSpace = False Attribute VB_Creatable = False Attribute VB_PredeclaredId = True Attribute VB_Exposed = False Option Explicit Private Sub mnuFileItem_Click(Index As Integer) Select Case Index Case 1 ` New Case 2 ` Open Case 3 ` Save Case 4 ` Save As Case 5 ` ------ Case 6 ` Print Case 7 ` Print Setup Case 8 ` ------- Case 9 ` Exit End Select End Sub Private Sub mnuEditItem_Click(Index As Integer) Select Case Index Case 1 ` Undo Case 2 ` ------ Case 3 ` Cut Case 4 ` Copy Case 5 ` Paste Case 6 ` Delete Case 7 ` Select All Case 8 ` ------- Case 9 ` Word Wrap End Select End Sub
Control arrays are also frequently used to simulate a control that dynamically changes one or more properties that can't normally be modified at run time. Take TextBox controls, for example. Their MultiLine and Scrollbars properties can only be set at design time, so you can't transform a single-line TextBox control into a multiline one--or vice versa--while the program is executing. For the same reason, you cannot change the MultiSelect or the Sorted property of a ListBox control at run time.
You can use control arrays to work around this limit. The trick is to create an array of two (or more) instances of the same control, but with different property settings. At run time you can switch from one to the other by simply hiding the one(s) you are not interested in. Of course, you must ensure that the two controls actually contain the same data. Otherwise, the end user will find that something is not working properly.
It is rather easy to keep list boxes in sync because you usually load them in the Form_Load event and don't change their contents afterwards. To execute the sample code shown in Listing 12.7, create a form, and then add a CheckBox control labeled Multi-Select and an array of two ListBox controls named List1(0) and List1(1), respectively. Don't modify any property of the first list box and set the second list box's MultiSelect property to 2-Extended. Finally, add the following code:
Private Sub Form_Load() ` move the second listbox under the first one ` and make it invisible List1(1).Move List1(0).Left, List1(0).Top, _ List1(0).Width, _List1(0).Height List1(1).Visible = False ` fill the two listboxes with random data Dim i For i = 1 To 100 List1(0).AddItem "Item " & i List1(1).AddItem "Item " & i ext End Sub Private Sub chkMultiSelect_Click() Dim index As Integer, i As Integer ` this is the index of the visible listbox index = Check1.Value ` keep ListIndex and TopIndex in sync List1(index).TopIndex = List1(1 - index).TopIndex List1(index).ListIndex = List1(1 - index).ListIndex ` if the multiselect listbox is going to become visible ` clear all selected items in List1(1), except the current one If index = 1 Then For i = 0 To List1(1).ListCount - 1 List1(1).Selected(i) = (i = List1(1).ListIndex) ext End If ` hide and show the listboxes as needed List1(index).Visible = True List1(1 - index).Visible = False End Sub
The TextBox controls' contents are modified by the user. For that reason, you must take additional precautions to ensure that the two controls in the control array always contain the same text (see Listing 12.8). You can use a menu item to switch from a wp-like multiline text box (with ScrollBars set to 1-Vertical, which wraps lines longer than the control's width) to an editor-like text box (which causes longer lines to be wrapped at the control's right margin with ScrollBars=3-Both, which does not wrap long lines), as shown in Figure 12.8:
Private Sub mnuEditItem_Click(Index As Integer) Select Case Index Case 1 ` Undo Case 2 ` ------ Case 3 ` Cut Case 4 ` Copy Case 5 ` Paste Case 6 ` Delete Case 7 ` Select All Case 8 ` ------- Case 9 ` Word Wrap Dim index As Integer ` this is the index of the visible textbox index = Check1.Value ` copy the text from the text that is ` currently visible to the other one Text1(index).Text = Text1(1 - index).Text Text1(index).SelStart = Text1(1 - index).SelStart Text1(index).SelLength = Text1(1 - index).SelLength ` hide and show the textbox as needed Text1(index).Visible = True Text1(1 - index).Visible = False End Select End Sub
Dynamic control arrays are control arrays that grow in size during execution. At design time there is no a real distinction between static and dynamic control arrays because you create them in exactly the same way. If you have a control array, you can dynamically create new items by issuing a Load command, as in:
Load Text1(n)
where n is the index of the element to be created. You should never attempt to create an element that already exists. If you do, a runtime error occurs. You can usually avoid this problem by keeping a form-level counter variable or by resorting to the UBound method of the control array object, as in:
n = Text1.UBound + 1 Load Text1(n)
New controls have the same size and position as the first element of the array but are invisible. You can move them where you need them and change their dimensions and other attributes before making them "magically" appear on the form:
` move the created control in the bottom right corner of the form Text1(n).Move 0, ScaleHeight - Text1(n).Height Text1(n).Visible = True
FIG. 12.8
Control arrays let you simulate ListBox and TextBox controls that change their
read-only attributes at run time.
Apart from the Index and the Visible properties, controls that are created dynamically have the same attributes as the first control of the array. You should always keep this in mind because you won't be able to change attributes that are read-only at run time. For instance, if the first item of an array of TextBox controls is single-lined (in other words, its Multiline property is set to False), you cannot create a multiline text box out of it. If you need to dynamically create both kinds of TextBox controls, you have to prepare two distinct control arrays. If you also need to create wp-like multiline text boxes (for example, with ScrollBars set to 1-Vertical) and editor-like multiline text boxes (with ScrollBars set to 3-Both), you have to prepare three control arrays because the ScrollBars property can't be changed at run time.
You can build dynamic arrays of menu items in the same exact manner that you build static arrays of menu items. Just create one array item with a non-blank Index property using the Menu Editor. All of the dynamically created items are inserted after this one.
You can use this technique in many different situations. It is typically used to build a list of the files that have recently been opened by the application. Many commercial programs offer this list, including Microsoft Word, Microsoft Excel, and Visual Basic itself. Adding this feature to your programs will surely make your users happy. It saves them from having to navigate in their hard disk just to retrieve the files they were working with during the most recent session (or sessions).
To correctly implement a list of recent files, you need to write a number of cooperating procedures. However, this task will probably be more difficult than you anticipate. Therefore, I have prepared a complete program, which is also moderately useful in itself. This simple application is an image viewer built upon a single PictureBox control. The application only has two menus: File and Edit. You can load an image from disk, copy that image to the Clipboard, or paste the contents of the clipboard into the picture box. The simplicity of this application allows you to concentrate on the correct implementation of the list of the most recently opened files.
This list usually appears near the bottom of the File menu (just above the Exit command). Bars separate the list from the other sections of the menu. I created a mnuFileList(0) item that works as a separator. It is followed by a regular menu item, mnuFileBar, which keeps the Exit command apart from all of the preceding commands. If the list is empty, the program should make the mnuFileBar(0) item invisible so that the user doesn't see two consecutive separating bars (see Figure 12.9).
FIG. 12.9
This is the Image Viewer at design time, when all of the necessary menu items
have already been prepared.
Use a form-level array of strings to hold the names of the most recently used files. This array is loaded when the application starts (typically in the Form_Load event of the main form) and saved to disk when the execution ends (in the Form_Unload event).
I suggest that the number of the files in the list be less or equal to nine, which helps in assigning a unique hotkey to each entry in the menu. The ReadRecentFiles and WriteRecentFiles routines have similar structures, as shown in Listing 12.9.
Option Explicit ` max length of the recent files list (should be <= 9) Const RECENTFILES_MAX = 9 Dim recentFiles(RECENTFILES_MAX) As String Private Sub Form_Load() ` on entry, load the list of recent files ReadRecentFiles End Sub Private Sub Form_Unload(Cancel As Integer) ` on exit, save the list of recent files to disk WriteRecentFiles End Sub Private Sub ReadRecentFiles() ` read the list of recent files, and update the File menu Dim fnum As Integer Dim fileIsOpened As Boolean Dim Index As Integer Dim item As String On Error GoTo ReadRecentFiles_Err fnum = FreeFile() Open RecentFilePath For Input As #fnum fileIsOpened = True Do Until EOF(fnum) Line Input #fnum, item ` only store non-null strings If item <> "" Then Index = Index + 1 recentFiles(Index) = item End If Loop ReadRecentFiles_Err: If fileIsOpened Then Close #fnum ` build the menu UpdateRecentFileMenu End Sub Private Sub WriteRecentFiles() ` write the list of recent files Dim fnum As Integer Dim fileIsOpened As Boolean Dim Index As Integer On Error GoTo WriteRecentFiles_Err fnum = FreeFile() Open RecentFilePath For Output As #fnum fileIsOpened = True For Index = 1 To RECENTFILES_MAX ` only store non-blank items If recentFiles(Index) <> "" Then Print #fnum, recentFiles(Index) End If ext WriteRecentFiles_Err: If fileIsOpened Then Close #fnum End Sub Private Function RecentFilePath() As String ` return the path of the text file that holds the list ` of most recently opened files RecentFilePath = App.Path & IIf(Right$(App.Path, 1) <> _ "\", "\", "") & App.EXEName & ".mru" End Function Private Sub UpdateRecentFileMenu() ` update the menu with the list of recent files Dim Index As Integer ` unload any loaded items ` except the first one (index=0) that is a static element Do While mnuFileList.UBound > 0 Unload mnuFileList(mnuFileList.UBound) Loop ` temporarily hide the separator at the ` beginning of the list mnuFileList(0).Visible = False ` load filenames into the menu array For Index = 1 To RECENTFILES_MAX ` take only non-null items into account If recentFiles(Index) = "" Then Exit For ` load the array item Load mnuFileList(Index) ` set its caption and hotkey mnuFileList(Index).Caption = "&" & Format$(Index) & _ ". " + recentFiles(Index) ` make it visible mnuFileList(Index).Visible = True ` if at least one item is visible, also the separator ` at the beginning of the list should be visible mnuFileList(0).Visible = True ext End Sub Private Sub AddToRecentFileList(ByVal filename As String) ` add a new file to the list of the recently opened files Dim found As Integer Dim Index As Integer Dim ercode As Integer ` do nothing if the file is already on top of the list If filename <> recentFiles(1) Then ` check if the file is already in the list ` if not found, use the last item of the list found = RECENTFILES_MAX For Index = 1 To RECENTFILES_MAX - 1 If recentFiles(Index) = filename Or _ recentFiles(Index) = "" Then found = Index Exit For End If ext ` move all items in the range [1, found] one ` position toward higher indexes For Index = found To 2 Step -1 recentFiles(Index) = recentFiles(Index - 1) ext ` store the file in the first position recentFiles(1) = filename ` update the menu UpdateRecentFileMenu End If End Sub
When working with files, you should always add an error handler because many things could go wrong: another application or user might be reading the file, or the file might have been deleted or corrupted. If an error occurs while reading the individual lines of the file, the error handling routine should close the opened file. If the open command itself has failed, the file does not need to be closed. The two routines ReadRecentFiles and WriteRecentFiles use the fileIsOpened variable to differentiate between the two cases. Note that even if no error occurs, the execution flows into the error handling routine and correctly closes the file.
Both the ReadRecentFiles and the WriteRecentFiles routines call the RecentFilePath function that returns the location of the file that holds the names of recently opened images. In a fully Windows 95-compliant application, you would probably store this information in the system Registry. Because this point is not central to our discussion, I have simply used a text file stored in the same directory as the main application.
NOTE: The code in the RecentFilePath function shows how to avoid a common error that can be made when building file paths. Many programmers, in fact, believe that they could build a complete file path using a simpler statement:RecentFilePath = App.Path & "\" & App.EXEName & ".mru"Unfortunately, the previous line of code won't work if the application was installed or copied to the root directory. In that case, the string returned by the App.Path property contains a trailing backslash. Therefore, you end up with two consecutive backslashes, and your file name is not valid.
The UpdateRecentFileMenu routine is where you finally deal with the array of menu items. This routine takes a defensive approach to control arrays, in that it first unloads all the items that were dynamically loaded and then rebuilds the array in its final form. You could probably optimize the code by not unloading the items that you plan to re-load soon afterwards. But this operation executes so quickly that it doesn't really affect the performance of the program.
You also need a routine that keeps the array in order and sorted, with the most recent file on the top and the least recent file on the bottom:
It's up to you to call the AddToRecentFileList routine whenever you open a file. In this sample application, this is done in the OpenFile routine (see Listing 12.10). If you plan to include these routines in full-featured programs, you should also call the AddToRecentFileList routine whenever you save a new file or change the name of a loaded file.
Private Sub OpenFile(filename As String) ` load the picture Picture1.Picture = LoadPicture(filename) ` update the recent file list AddToRecentFileList filename End Sub Private Sub mnuFileList_Click(Index As Integer) ` load a file from the list of recent files OpenFile recentFiles(Index) End Sub Private Sub Form_Resize() ` resize the picture box along with the form Picture1.Move 0, 0, ScaleWidth, ScaleHeight End Sub Private Sub mnuFileNew_Click() ` clear the picture box Set Picture1.Picture = Nothing End Sub Private Sub mnuFileOpen_Click(Index As Integer) ` query the user for a new picture With CommonDialog1 .Filter = "All Picture Files|*.bmp;*.dib:*.gif;" _ & "*.wmf;*.emf;*.jpg;*ico;*.cur|" _ & "Bitmaps (*.bmp;*.dib)|*.bmp;*.dib|" _ & "Icons (*.ico;*.cur)|*.ico;*.cur|" _ & "GIF images (*.gif)|*.gif|" _ & "JPEG images (*.jpg)|*.jpg|" _ & "Metafiles (*.wmf;*.emf)|*.wmf;*.emf" _ & "All Files|*.*" .Flags = cdlOFNFileMustExist + cdlOFNHideReadOnly .filename = "" .ShowOpen If .filename <> "" Then ` if the user didn't cancel the command, open the image OpenFile .filename End If End With End Sub Private Sub mnuFileExit_Click(Index As Integer) ` exit the program Unload Me End Sub Private Sub mnuEditCopy_Click() ` copy the current image to the clipboard Clipboard.SetData Picture1.Picture End Sub Private Sub mnuEditPaste_Click() ` paste the image currently in the clipboard Set Picture1.Picture = Clipboard.GetData End Sub
If the end user selects one menu item from the control array, the application must load the appropriate file. This is a trivial operation because the control array and the recentFile() string array are always in sync.
The rest of the code for the ImageViewer sample application is mainly for file and clipboard commands (see Figure 12.10). You should not need any further commentary to understand how each routine works.
FIG. 12.10
Here is the ImageViewer application at run time.
In this last example, I show a complete, non-trivial application of the control array concept: a customized form for entering all the data related to an invoice. However complex this code is, always keep in mind that it is just an example. Many pieces are missing--a tax evaluation, just to name one. But my goal is to illustrate how control arrays can be used in a business application, not how to build a commercial invoicing software.
All the programs of this type must allow the user to enter both customer data (name, address, and so on) and details on each item in the invoice. Data that fall into the latter category is usually presented in the form of a table or grid. Because Visual Basic 5.0 comes with two ActiveX grids--DBGrid and FlexGrid--you might wonder why you should use a different approach.
The problem with the grids provided in the VB package is that they are not powerful and flexible enough for many practical applications. The FlexGrid ActiveX control, for example, does not permit you to edit individual cells; therefore, it is not very useful for data entry forms. The DBGrid control is better, but it supports only textual cells and cannot be customized with other types of controls, such as combo boxes, check boxes, or images. If you want to write cool apps, you need to buy enhanced grids from third-party vendors or build your own. In the rest of this chapter, I will show you that building your own grids is probably less difficult than you think.
Let's have a look at Figure 12.11, which shows the running Invoices application.
FIG. 12.11
The Invoices application in action.
The Advantages of a Homemade Grid There are many interesting details of the grid used by this simple program:
The Grid at Design Time If you are wondering where the control arrays are, look at Figure 12.12, which shows the same form at design time.
FIG. 12.12
The Invoices app at design time. There are exactly six control arrays for
the grid, plus one for all the fields in the invoice header.
There are exactly nine control arrays in this form:
Array | What It Contains |
lblColumn | The six labels used as column headers. |
Label1 | Holds all the other descriptive labels on the form. |
txtHeader | Holds all the textbox controls used for the header of the invoice (invoice number and date, customer name, and so on). |
txtQty, cboProductID, | The six control arrays, one for each column of the grid. |
txtDescription, txtUnitPrice, lblTotal, and chkBackorder | These are the only dynamic arrays in the program. |
Initializing the Data in the Grid It's time to study the complete listing of the Invoices application. Information on categories of products is initialized in the Form_Load event and stored in a regular array of user-defined types (see Listing 12.11). This array is used to fill the Description and Unit Price fields when the end user selects a Product ID. In a real-world application, this data would probably be loaded from a file or a database table.
Option Explicit Private Type TProductInfo ID As String Description As String UnitPrice As Currency End Type ` max number of detail lines in the invoice Const LINES_MAX = 8 ` this array holds information on each product Const PRODUCT_NUM = 10 Dim ProductInfo() As TProductInfo ` this variable tracks which line the cursor is currently on ` zero means that it is on the upper portion of the form Dim currentLine As Integer Private Sub Form_Load() ` load ID, description and price unit for a bunch of products ` (in a real application this information would be loaded ` from a file or a database table) Dim i As Integer ReDim ProductInfo(1 To PRODUCT_NUM) As TProductInfo ProductInfo(1).ID = "Mouse/S" ProductInfo(1).Description = "Serial Mouse" ProductInfo(1).UnitPrice = 39.5 ProductInfo(2).ID = "Mouse/PS2" ProductInfo(2).Description = "Mouse with PS/2 connector" ProductInfo(2).UnitPrice = 49.99 ProductInfo(3).ID = "Modem /I" ProductInfo(3).Description = "Internal 28.800 baud modem" ProductInfo(3).UnitPrice = 105 ProductInfo(4).ID = "Modem/E" ProductInfo(4).Description = "Internal 28.800 baud modem" ProductInfo(4).UnitPrice = 105 ProductInfo(5).ID = "Video/V" ProductInfo(5).Description = "VGA Video Card" ProductInfo(5).UnitPrice = 45 ProductInfo(6).ID = "Video/SV" ProductInfo(6).Description = "Super-VGA Video Card" ProductInfo(6).UnitPrice = 75 ProductInfo(7).ID = "CdRom/6" ProductInfo(7).Description = "CDROM Driver 6x" ProductInfo(7).UnitPrice = 109 ProductInfo(8).ID = "CdRom/1 2" ProductInfo(8).Description = "CDROM Driver 12x" ProductInfo(8).UnitPrice = 225 ProductInfo(9).ID = "Cable/P" ProductInfo(9).Description = "Parallel Cable" ProductInfo(9).UnitPrice = 7.99 ProductInfo(10).ID = "Cable/S" ProductInfo(10).Description = "Serial Cable" ProductInfo(10).UnitPrice = 7.99 ` load product IDs into the combo box For i = 1 To PRODUCT_NUM cboProductID(1).AddItem ProductInfo(i).ID Next End Sub
Highlighting the Current Line The n e xt bunch of routines keeps track of the current line in the grid--that is, where the input caret currently is. This value is stored in the currentLine variable, which is set to zero when the input focus is outside of the grid (see Listing 12.12). It is very easy to update this variable because all the fields are gathered in control arrays:
Private Sub txtHeader_GotFocus(Index As Integer) ewCurrentLine 0 End Sub Private Sub txtQty_GotFocus(Index As Integer) ewCurrentLine Index End Sub Private Sub cboProductID_GotFocus(Index As Integer) ewCurrentLine Index End Sub Private Sub txtDescription_GotFocus(Index As Integer) ewCurrentLine Index End Sub Private Sub txtUnitPrice_GotFocus(Index As Integer) ewCurrentLine Index End Sub Private Sub chkBackorder_GotFocus(Index As Integer) ewCurrentLine Index End Sub Private Sub NewCurrentLine(newLine As Integer) ` set a yellow background for the controls on the ` current line, and white for all the others Dim Index As Integer Dim foColor As Long, bkColor As Long currentLine = newLine For Index = txtQty.LBound To txtQty.UBound If Index = currentLine Then foColor = vbHighlightText bkColor = vbHighlight Else foColor = vbWindowText bkColor = vbWindowBackground End If txtQty(Index).ForeColor = foColor txtQty(Index).BackColor = bkColor cboProductID(Index).ForeColor = foColor cboProductID(Index).BackColor = bkColor txtDescription(Index).ForeColor = foColor txtDescription(Index).BackColor = bkColor txtUnitPrice(Index).ForeColor = foColor txtUnitPrice(Index).BackColor = bkColor lblTotal(Index).ForeColor = foColor lblTotal(Index).BackColor = bkColor ` don't touch checkbox's colors Next End Sub
The NewCurrentLine procedure, as shown in Listing 12.12, does more than simply update the currentLine variable. It quickly scans all of the cells in the grid and sets their foreground and background colors so that they highlight the current line.
NOTE: The preceding code uses VB color constants instead of numerical values. vbHighlight is usually blue, and vbHighlightText is usually white. The default value for vbWindowsBackground is white, and the default value for vbWindowsText is black.The advantage of this approach is that it delivers coherent results even if the user changes the system colo r scheme, and the grid continues to be perceived as well integrated in the user interface. You should always use system colors when possible.
Adding and Removing Grid Lines The core routine of this sample program is the cmdAddItem_Click event procedure that adds a new line of invoice details (see Listing 12.13).
Private Sub cmdAddItem_Click() ` add a new line for Invoice details Dim newLine As Integer Dim lineTop As Single Dim i As Integer ewLine = txtQty.UBound + 1 ` exit if too many lines If newLine > LINES_MAX Then Exit Sub ` load all the controls that make up the row ` it is preferable to load all controls *before* acting ` on their properties, because otherwise a change event might ` rise an error since it would refer to a non existing control Load txtQty(newLine) Load cboProductID(newLine) Load txtDescription(newLine) Load txtUnitPrice(newLine) Load lblTotal(newLine) Load chkBackorder(newLine) ` then move controls in the correct position, make ` them visible and clear them lineTop = txtQty(newLine - 1).top + txtQty(newLine - 1).Height ` we don't need to modify the Left property, whose ` value is inherited by the control in the above line txtQty(newLine).top = lineTop txtQty(newLine).Visible = True txtQty(newLine).text = "" cboProductID(newLine).top = lineTop cboProductID(newLine).Visible = True cboProductID(newLine).text = "" txtDescription(newLine).top = lineTop txtDescription(newLine).Visible = True txtDescription(newLine).text = "" txtUnitPrice(newLine).top = lineTop txtUnitPrice(newLine).Visible = True txtUnitPrice(newLine).text = "" lblTotal(ne wLine).top = lineTop lblTotal(newLine).Visible = True lblTotal(newLine).Caption = "" chkBackorder(newLine).top = lineTop chkBackorder(newLine).Visible = True chkBackorder(newLine).Value = 0 ` load product IDs into the combo box cboProductID(newLine).Clear For i = 1 To PRODUCT_NUM cboProductID(newLine).AddItem ProductInfo(i).ID ext ` set input focus to the Qty textbox DoEvents txtQty(newLine).SetFocus End Sub
It is worth noting t h at the routine loads all the control arrays' items before acting on their properties. This order is necessary because as soon as you touch the Text property of the txtQty or txtUnitPrice controls, that control's Change event tries to update the contents of the lblTotal field on the same line. Of course, if the lblTotal control hasn't been created yet, a runtime error occurs.
Deleting the current line is a bit tricky because you have to move the contents of all the subsequent lines one position upward first. Only then you can use a set of Unload commands to safely delete the last line of controls, as shown in Listing 12.14. Note that you cannot delete the first line of the grid: it cannot be unloaded because it contains controls that were created at design time:
Private Sub cmdDeleteItem_Click() ` delete the current line Dim Index As Integer Dim lastLine As Integer lastLine = txtQty.UBound ` exit if the cursor is not on an invoice item or if there ` is only one line (these controls are created at design time ` and cannot be unloaded) If currentLine = 0 Or lastLine = 1 Then Exit Sub ` move all values up one row For Index = currentLine To lastLine - 1 txtQty(Index).text = txtQty(Index + 1).text cboProductID(Index).text = cboProductID(Index + 1).text txtDescription(Index).text = txtDescription(Index + 1).text txtUnitPrice(Index).text = txtUnitPrice(Index + 1).text lblTotal(Index).Caption = lblTotal(Index + 1).Caption chkBackorder(Index).Value = chkBackorder(Index + 1).Value Next ` clear the lblTotal value for the last line ` (this forces the evaluation of grand total) lblTotal(lastLine).Caption = "" ` if we are about to delete the control that has the ` input focus, move the focus elsewhere If currentLine = lastLine Then txtQty(lastLine - 1).SetFocus End If ` unload the last line of controls Unload txtQty(lastLine) Unload cboProductID(lastLine) Unload txtDescription(lastLine) Unload txtUnitPrice(lastLine) Unload lblTotal(lastLine) Unload chkBackorder(lastLine) End Sub
Customizing the Grid's Behavior The event routines that customize the behavior of your grid are next. As soon as the user selects a Product ID, the Description and Unit Price fields are filled with corresponding data, as illustrated in Listing 12.15.
Private Sub cboProductID_Click(Index As Integer) ` the user has selected a product Dim i As Integer i = cboProductID(Index).ListIndex If i >= 0 Then txtDescription(Index).text = _ ProductInfo(i + 1).Description txtUnitPrice(Index).text = _ Format$(ProductInfo(i + 1).UnitPrice, "###.00") End If End Sub
Similarly, when the user modifies the value in the Q.ty or Unit Price fields, the Total field is immediately updated to reflect the total value for that line. The code that implements this behavior is shown in Listing 12.16.
Private Sub txtQty_Change(Index As Integer) UpdateLineTotal Index End Sub Private Sub txtUnitPrice_Change(Index As Integer) UpdateLineTotal Index End Sub Private Sub UpdateLineTotal(Index As Integer) ` update the total value of current line If txtQty(Index).text <> "" And txtUnitPrice(Index).text <> "" Then lblTotal(Index).Caption = Format$(CCur(txtQty(Index).text) * _ CCur(txtUnitPrice(Index).text), "###,###.00") Else lblTotal(Index).Caption = "" End If End Sub Private Sub lblTotal_Change(Index As Integer) ` update the grand total Dim i As Integer, result As Currency For i = lblTotal.LBound To lblTotal.UBound If lblTotal(i).Caption <> "" Then result = result + CCur(lblTotal(i).Caption) End If ext lblGrandTotal.Caption = Format$(result, "###,###.00") End Sub
Whenever one of the Total fields is modified, its Change event updates the Grand Total field in the bottom-right corner of the form.
Listing 12.17 shows the routine that is executed when the user clicks the column headers:
Private Sub lblColumn_Click(Index As Integer) ` change the header of this column Dim newCaption As String ewCaption = InputBox$ _ ("Enter a new label for this column", "My Grid", _ lblColumn(Index).Caption) If newCaption <> "" Then lblColumn(Index).Caption = newCaption End If End Sub
Validating the Data Entered by the User The last group of routines implements a limited form of data validation on the many fields on the form, both within and outside the grid. Again, this is a rather simple task, thanks to control arrays (see Listing 12.18).
Private Sub txtHeader_KeyPress(Index As Integer, KeyAscii As Integer) ` ensure that numeric fields only get numeric keys If KeyAscii < 32 Then Exit Sub Select Case Index Case 0, 5 ` Invoice number & ZIP code If KeyAscii < 48 Or KeyAscii > 57 Then KeyAscii = 0 End If Case 1 ` invoice date If (KeyAscii < 48 Or KeyAscii > 57) Then If KeyAscii <> Asc("/") Then KeyAscii = 0 End If End Select ` protest loudly if necessary If KeyAscii = 0 Then Beep End Sub Private Sub txtQty_KeyPress(Index As Integer, KeyAscii As Integer) ` ignore non-numeric input If (KeyAscii < 48 Or KeyAscii > 57) And KeyAscii >= 32 Then KeyAscii = 0 Beep End If End Sub Private Sub txtUnitPrice_KeyPress(Index As Integer, KeyAscii As Integer) ` ignore non-numeric input, but accept decimal separator If (KeyAscii < 48 Or KeyAscii > 57) And KeyAscii >= 32 _ And KeyAscii <> Asc(".") Then KeyAscii = 0 Beep End If End Sub
Note that you need more robust routines for data validation in a commercial-quality program. For instance, you should check that the date of the invoice is valid, that the State name is correct, and so on. Now that you have the necessary tools, you can create routines that are more robust than the ones used in this example.
This chapter has shown you what control arrays are and you have seen a few possible control array applications. However, as you continue to read this book, you will surely find other ways to put them to good use. Here's a short list of chapters you might refer to for inspiration:
© Copyright, Macmillan Computer Publishing. All rights reserved.