April 1999
Code for this article: April99CQA.exe (31KB)
Paul DiLascia is the author of Windows ++: Writing Reusable Code in C++ (Addison-Wesley, 1992) and a freelance consultant and
writer-at-large. He can be reached at askpd@pobox.com
or http://pobox.com/~askpd.
|
Q How do I implement tabs like those in Microsoft® Visual Studio® (Build, Debug, Find in Files, and Results) or the worksheet tabs in Microsoft Excel (see Figure 1)? George Poulose and many other readers A So many people have asked me this question that it has to be in my all-time top five Q&A hit list. (That's hit as in pop song.) My answer is always the same: just write the code! I mean, hey, MFC doesn't do everything. Sometimes you actually have to roll up your sleeves and do a little programming. One of the problems with MFC is that the GUI stuff has gotten so good that people are dismayed to learn that every nifty new UI component in finished products doesn't actually come prepackaged in an MFC class called CNiftyNewUIComponent. |
Figure 1 Tabs in Microsoft Excel |
So what would it take to build a control like the Microsoft Excel tabs in Figure 1? You could probably do it as a special kind of custom-draw tab control, but experience has shown me that it's often easier and more reliable to implement a control from scratch than to modify an existing control. By the time you figure out how to adapt the existing control to your own purposes, you could have been finished already. I'm a big fan of reusability, but the sad fact is: we're not really there yet. Plus, by having total control you're impervious to changes that may come down the road. Evidently, the folks in Redmond agree; if you look at Microsoft Excel or Visual Studio with Spy++, you'll discover that their folder tabs are not SysTabControl32s, but specialized controls. So let's see if I can build one too.
Figure 2 FLDRTAB |
Figure 2 show a program I wrote, FLDRTAB, that uses a CFolderTabCtrl class to implement a Microsoft Excel-style folder tab control. Figure 3 and Figure 4 show the source code. Before I show you how CFolderTabCtrl works, let me show you how FLDRTAB uses it.
When FLDRTAB's InitInstance function gets control, it creates an instance of the main dialog, CMyDialog, and runs it.
BOOL CApp::InitInstance()
{
CMyDialog dlg;
m_pMainWnd = &dlg;
dlg.DoModal();
return FALSE;
}
CMyDialog contains two controls: m_wndStaticInfo and m_wndFolderTab. As you can probably guess from their names, the first control is the static text window that displays the tab selected, and the second one is the folder tab control itself, an instance of CFolderTabCtrl. CMyDialog::OnInitDialog subclasses the static text in the usual manner by calling SubclassDlgItem. Unfortunately, it can't subclass the folder tab control because the dialog doesn't have one. There's no way I'm going to bother to COM-enable my folder tab control as a custom control with design-time interfaces. Instead, I'll design my dialog with a static text control where I want the folder tab to go (see Figure 5); at runtime, OnDialogInit replaces the static text control with a folder tab control by calling a special function:
m_wndFolderTab.CreateFromStatic(IDC_FOLDERTAB, this);
CFolderTabCtrl::CreateFromStatic creates a folder tab in the same place as the static text control, then deletes the static control. This is a standard trick I use to create special dialog controls. CreateFromStatic calls CFolderTab::GetDesiredHeight to get the height of the control, ignoring the height of the static text control, before calling Create. In a non-dialog app, you shouldn't call CreateFromStatic; rather, you should call CFolderTab::Create directly.
Figure 5 Locating the Folder Tab |
However you create the folder tab control, the next thing you have to do is set the tab names. CMyDialog does it by calling a handy Load function.
m_wndFolderTab.Load(IDR_FOLDERTABS);
IDR_FOLDERTABS is the ID of a string resource that contains the newline-separated names of the folder tabs, ("Breakfast\nLunch\n..."). Once you've created the control and called Load, your folder tab control is completely happy and looks like the one in Figure 2. Of course, so far it doesn't do anything. For that, you have to handle notifications. When the user clicks on one of the tabs, CFolderTab sends a WM_NOTIFY message with the special code FTN_TABCHANGED. CMyDialog handles the notification by displaying a message.
void CMyDialog::OnChangedTab(NMFOLDERTAB* nmtab,
LRESULT* pRes)
{
CString s;
s.Format(_T("Selected item %d: %s"),
nmtab->iItem,
nmtab->pItem->GetText());
m_wndStaticInfo.SetWindowText(s);
}
The NMFOLDERTAB struct is defined in FTab.h.
struct NMFOLDERTAB : public NMHDR {
int iItem; // item index
const CFolderTab* pItem; // folder tab
};
In addition to the stuff in NMHDR, it contains the index of the item and a pointer to the current folder tab, CFolderTab, which is different from CFolderTabCtrl. From CFolderTab you can get the tab's text. That's how you use CFolderTabCtrl. Now let me show you how it works. I already described CreateFromStatic, so what about CFolderTabCtrl::Load? This function loads the newline-separated list of tab names, extracts the individual substrings, and calls CFolderTabCtrl::AddItem to add each tab.
int CFolderTabCtrl::AddItem(LPCTSTR lpszText)
{
m_lsTabs.AddTail(new CFolderTab(lpszText));
return m_lsTabs.GetCount() - 1;
}
What could be simpler? Create a new CFolderTab object and add it to a list. And while I'm at it, how about a RemoveItem function? Consider it done; the code is in FTab.cpp. AddItem and RemoveItem let you add and remove folder tabs dynamically, instead of using a resource string. Naturally, there's a GetItem to get the nth CFolderTab (zero offset, of course) and a GetItemCount that returns m_lsTabs.GetCount. And, as you'd expect, each CFolderTab has a CString m_sText to hold the name of the tab, with GetText and SetText methods getting and setting the tab name. Hey, you could write this code in your sleep!
Well, not quite. At some point, you have to do something. The first interesting thing the folder tabs have to do is paint themselves. CFolderTabCtrl::OnPaint loops through all the tabs, calling CFolderTab::Draw for each one. That is, the folder tabs paint themselves; the control doesn't paint them. There's just one trick and one catch. The trick is that the control must paint the current selected tab (m_iCurItem) last, so it appears on top of the others. The catch is that a folder tab can't paint itself until it knows where it isthat is, the coordinates of the trapezoid that defines the tab. This is where things start to get interesting.
CFolderTabCtrl has a RecomputeLayout function that computes the positions of all the tabs. You must call it any time you do something that would change the layout of the control, such as adding or removing a tab or changing the name of a tab (which changes its size). RecomputeLayout goes more or less like this:
int x = 0;
for (int i=0; i<GetItemCount(); i++) {
CFolderTab* pTab = GetTab(i);
if (pTab) x += pTab->ComputeRgn(dc, x) - CXOFFSET;
}
RecomputeLayout calls a CFolderTab function, ComputeRgn, for each folder tab. ComputeRgn computes the trapezoid for the tab and returns the width calculated, which RecomputeLayout adds to its ongoing x coordinate counter before passing it as the starting x coordinate for the next tab, minus a fudge factor CXOFFSET to make the tabs appear overlapped. It's done this way because a given folder tab is only capable of determining its size, not its absolute position, which requires more information in the form of an x coordinate fed to it from above. Once ComputeRgn has the x coordinate, it computes a trapezoid just large enough to contain its text name, with some margins added in so the text doesn't look cramped. It calls CDC::DrawText with DT_CALCRECT to calculate the text rectangle, then computes the trapezoid using the result. A private function, GetTrapezoid, computes the trapezoid required to hold a given text rectangle. Determining the exact pixel-twiddling algorithm is largely a matter of trial and error, and quite a headache, so I won't pain you with it here. Once CFolderTab::ComputeRgn computes the coordinates of the trapezoid, it casts them in bronze by calling CRgn::CreatePolygonRgn to create a polygonal region.
int CFolderTab::ComputeRgn(CDC& dc, int x)
{
CRect& rc = m_rect;
dc.DrawText(m_sText, &rc, DT_CALCRECT);
// tweak rc to add margins
.
.
.
CPoint pts[4];
GetTrapezoid(rc, pts);
m_rgn.CreatePolygonRgn(pts, 4, WINDING);
return rc.Width();
}
Once the tabs have computed their regions, painting a folder tab is easythough you're in for another heavy dose of painstaking pixels. CFolderTab::Draw selects the appropriate selected and deselected colors and font, then
does its thing. Since the folder tab stores its trapezoid as a CRgn, all it has to do is call CDC::FillRgn to paint the tab. Afterward, it does a bunch of MoveTos and LineTos to paint the lines in the proper colors. Finally, it calls DrawText to draw the text.
Figure 6 Selected Tab |
Figure 6 shows what selected and unselected tabs look like magnified. Note that some lines are black and others are grey to give a subtle 3D shading (this isn't my design; I borrowed it from Microsoft Excel). A selected tab is white (COLOR_WINDOW) and doesn't have a black line along the top edge. This is so it will blend with the window above it. FLDRTAB has no such window (it's just a stupid test program), but a real app is expected to have a view above the folder tab so the top of the selected tab blends with the view, as in Microsoft Excel or Visual Studio. Another thing that's different for selected tabs (this I borrowed from Visual Studio) is that they have a smaller font. No big deal, just do it. But speaking of fonts, which one should you use? By default, CFolderTabCtrl uses Arial, but since I'd never force any particular font on you, I naturally provided a function to change it: CFolderTabCtrl::SetFonts.
So much for drawing. Now for some action, as in events. Fortunately, this is easy now that you've already done the hard work of computing the tab regions. And by the way, let this be a moral to you: whenever you design some control or system, begin by asking yourself, "What data structures would I need to make everything easy?" Then, figure out how to create those data structures. Usually, there should be only one place where you do all the hard work, and everything else becomes trivial. At least, that's what you should hope for.
When CFolderTabCtrl gets an OnLButtonDown event, it calls a function, HitTest, to find out which tab, if any, contains the mouse. HitTest loops over each CFolderTab, calling a function by the same name, CFolderTab::HitTest, which in turn calls CRgn::PtInRegion with the trapezoidal region already computed. This is where CRgn really earns its keep. If any HitTest returns TRUE, CFolderTabCtrl::HitTest returns that tab's index, in which case OnLButtonDown calls another new function, CFolderTabCtrl::SelectItem, to select the tab. SelectItem is trivial: it changes m_iCurItem to the new item index and invalidates the old and new selected tabs so they get repainted. Easy. After calling SelectItem, OnLButtonDown creates an NMFOLDERTAB struct, fills it with information, and sends a WM_NOTIFY message to the parent window. How else would the dialog or app know what happened?
Well, there you have it. A crude but functional folder-style tab control. There are many features I've left out. For example, there's no way to change tabs using the keyboard (this could be done as an accelerator in your main app), and there's no way to prevent the user from selecting a tab. If your app needs these features, by all means implement them! That's the whole point: implement what you need, and nothing more. The next step will be to integrate CFolderTabCtrl into a real, live MFC app, not just a baby test program. I'll show you how next month. Until then, happy programming!
Have a question about programming in C or C++? Send it to Paul DiLascia at askpd@pobox.com
From the April 1999 issue of Microsoft Systems Journal
|