Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / MFC

Group Combo Box

5.00/5 (20 votes)
19 Nov 2013Zlib18 min read 109.4K   8.7K  
Easily extensible owner-drawn combo box with items grouping and sorting

Image 1

Introduction

I needed a control similar to the font combo box in Microsoft Word products: to display a list of recently used items and a list of all available items in separate groups bounded by headers. Items recently used must be sorted chronologically, with the most recent item placed first. On the other hand, list of all available items must be sorted alphabetically. I have not found any implementation that would meet such requirements, so I decided to create one.

A prerequisite to draw headers that visually distinguish from other items imposed the use of owner-drawn combo box. However, there were several additional issues to solve (like header item must not be selectable). These issues will be discussed and solution for them proposed in this article.

Contents

Owner-Drawn Combo Boxes

Owner-drawn combo box allows distinct drawing of each individual item. Conceptually, a new class must be derived from CComboBox class and several methods overridden in the derived class. If combo box is created with CBS_OWNERDRAWFIXED style, heights of all items are equal and only DrawItem() method needs to be overridden. The DrawItem() method is invoked sequentially for each item displayed in the drop-down list. If control is created with CBS_SORTED flag set, it is necessary to override the CompareItem() method too, in order to provide correct sorting of items.

In this article and in attached source code samples, CGroupComboBox is the class derived from CComboBox in which issues discussed below are implemented.

It is worth noting that owner-drawn combo box control will be displayed taller than it is set in the dialog resource: default height of the combo box in the resource editor is 12 pixels, but in the running application it will be displayed 2 pixels taller, as visible from the screenshot below. Moreover, height of individual items in the drop-down list is increased by 2 pixels. Even some standard combo box controls are also displayed taller, as illustrated by the screenshot for CMFCComboBox control shown on the rightmost end.

Image 2

Inconsistent control and item heights are a consequence of incorrectly evaluated font height. At the moment, the control is painted for the first time, the actual font which will be used to display the content is not known, resulting with invalid metric. One possible workaround is to implement WM_MEASUREITEM message handler in the parent dialog and calculate the control height based on the font used on the dialog:

C++
void CParentDlg::OnMeasureItem(int nIDCtl, LPMEASUREITEMSTRUCT lpMeasureItemStruct)
{
    switch (nIDCtl)
    {
    case IDC_COMBO_OWNERDRAWN:
        CDC* dc = GetDC();
        CFont* font = GetFont();
        CFont* fontOld = dc->SelectObject(font);
 
        TEXTMETRIC tm;
        dc->GetTextMetrics(&tm);
        lpMeasureItemStruct->itemHeight = tm.tmHeight;
        // if invoked for control itself (i.e. for edit control),
        // must include control border
        if (lpMeasureItemStruct->itemID == -1)
        {
            int border = ::GetSystemMetrics(SM_CYBORDER);
            lpMeasureItemStruct->itemHeight += 2 * border;
        }
 
        dc->SelectObject(fontOld);
        ReleaseDC(dc);
        break;
    }
    CDialogEx::OnMeasureItem(nIDCtl, lpMeasureItemStruct);
}

This handler is invoked just before the control is painted for the first time, before WM_INITDIALOG message is sent to parent dialog. It is invoked twice for each combo box: the first time to capture the size of the control itself (more precisely, its edit control part) and the second time to capture the dimension of items in the drop-down list. Edit control size includes the border around the control, so two calls must be differentiated. In the above OnMeasureItem() handler, two calls are discriminated by if statement that checks the value of itemID member in MEASUREITEMSTRUCT structure. For edit control, this member has value of -1, while for items in drop-down list, it has value of corresponding item index. If calls were not distinguished, items would be shown taller than in the “intrinsic” combo box. This is visible when comparing the leftmost ordinary combo box to the owner-drawn combo box in the middle of the screenshot below. Since the handler is invoked for each control on the dialog, controls for which height must be corrected are filtered out by their IDs.

Image 3

A better alternative is if height is set by the control itself so no code change outside the combo box class is required. The height must be adjusted before control is displayed and one option to do this is to override PreSubclassWindow() function:

C++
void CGroupComboBox::PreSubclassWindow()
{
    CDC* dc = GetDC();
    CFont* font = GetFont();
    CFont* fontOld = dc->SelectObject(font);
    TEXTMETRIC tm;
    dc->GetTextMetrics(&tm);
    int border = ::GetSystemMetrics(SM_CYBORDER);
    // height of edit control
    SetItemHeight(-1, tm.tmHeight + 2 * border);
    // height of items in drop-down list
    SetItemHeight(0, tm.tmHeight);
    dc->SelectObject(fontOld);
    ReleaseDC(dc);
    CComboBox::PreSubclassWindow();
}

Combo Box Items Hierarchy

In order to make adding various items in the combo box simple, the responsibility to draw the item and provide its height has been handed to items themselves. Therefore, a hierarchy based on abstract CGroupComboBoxItem class with pure virtual methods Draw() and GetSize() has been created (see figure below). Derived item classes must implement both methods. CComboBox derived class holds pointers to item objects and will simply call their corresponding Draw() and GetSize() from overridden DrawItem() and MeasureItem() methods:

C++
void CGroupComboBox::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct)
{
    CDC dc;
    dc.Attach(lpDrawItemStruct->hDC);
    CGroupComboBoxItem* item = 
         reinterpret_cast<CGroupComboBoxItem*>(lpDrawItemStruct->itemData);
    item->Draw(&dc, lpDrawItemStruct);
    dc.Detach();
}  

The actual implementation in attached code differs a little bit since CMemDC class is utilized to reduce flickering.

Image 4

For a simplest combo box item, Draw() method implementation would be something like:

C++
void CGroupComboBoxSimpleItem::Draw(CDC* dc, LPDRAWITEMSTRUCT lpDrawItemStruct)
{
    COLORREF crOldTextColor = dc->GetTextColor();
    COLORREF crOldBkColor = dc->GetBkColor();
    RECT rect = lpDrawItemStruct->rcItem;
    if ((lpDrawItemStruct->itemAction | ODA_SELECT) &&
        (lpDrawItemStruct->itemState & ODS_SELECTED))
    {
        dc->SetTextColor(::GetSysColor(COLOR_HIGHLIGHTTEXT));
        dc->SetBkColor(::GetSysColor(COLOR_HIGHLIGHT));
        dc->FillSolidRect(&rect, ::GetSysColor(COLOR_HIGHLIGHT));
    }
    else
        dc->FillSolidRect(&rect, crOldBkColor);
    rect.left += GCB_TEXT_MARGIN;
    {
        CShellDlgFont dlgFont(dc);
        dc->DrawText(GetCaption(), &rect, DT_LEFT | DT_SINGLELINE | DT_VCENTER);
    }
    dc->SetTextColor(crOldTextColor);
    dc->SetBkColor(crOldBkColor);
} 

In the above code, CShellDlgFont is a utility class which automatically selects the default dialog font into device context and releases resources on scope exit:

C++
class CShellDlgFont
{
public:
    CShellDlgFont(CDC* dc, LONG fontWeight = FW_NORMAL) : m_dc(dc)
    {
        LOGFONT lf = { 0 };
        lf.lfHeight = -MulDiv(8, ::GetDeviceCaps(*dc, LOGPIXELSY), 72);
        lf.lfWidth = 0;
        lf.lfWeight = fontWeight;
        lf.lfCharSet = DEFAULT_CHARSET;
        lf.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE;
        _tcscpy_s(lf.lfFaceName, _T("MS Shell Dlg 2"));
        m_font.CreateFontIndirect(&lf);
        m_oldFont = dc->SelectObject(&m_font);
    }
 
    ~CShellDlgFont()
    {
        m_dc->SelectObject(m_oldFont);
        m_font.DeleteObject();
    }
 
private:
    CDC* m_dc;
    CFont m_font;
    CFont* m_oldFont;
};

Although the MSDN documentation states that instead of GetStockObject() function (with DEFAULT_GUI_FONT or SYSTEM_FONT argument passed), SystemParametersInfo() function with the SPI_GETNONCLIENTMETRICS should be used to retrieve the default dialog font, none of the NONCLIENTMETRICS structure members contains the correct font metric. Correct font metric is not only necessary for owner-drawn variable combo-box to calculate the accurate item height, but also for fixed height combo box if drop-down list width has to be accommodated to the widest item, as it is described later in the article. For clarification, left combo box on the screenshot below uses default font (as provided by client area device context) for item width calculation, while combo box on the right side uses above utility class. Even though items are drawn with correct font in both cases, default font results with wider drop-down list than actually required.

Image 5

Since only fixed owner-drawn combo box is currently considered, MeasureItem() method doesn’t have any effect and can be ignored for the time being.

CGroupComboBoxItem class also contains m_caption string member, pointer to its header item (used for a faster look-up only) and a method for case insensitive comparison:

C++
int CGroupComboBoxItem::CompareCaption(LPCWSTR lpStringOther, int cchCount) const
{
    return ::CompareString(LOCALE_USER_DEFAULT, NORM_IGNORECASE, 
                           m_caption, cchCount, lpStringOther, cchCount) - CSTR_EQUAL;
}

Please note how WinAPI CompareString() method has been used to ensure correct comparison for localized applications.

Header items are represented by CGroupComboBoxHeader class, also derived from CGroupComboBoxItem. Since header item cannot be selected, it is not drawn highlighted and the implementation of Draw() method is simpler:

C++
void CGroupComboBoxHeader::Draw(CDC* dc, LPDRAWITEMSTRUCT lpDrawItemStruct)
{
    COLORREF crOldTextColor = dc->GetTextColor();
    COLORREF crOldBkColor = dc->GetBkColor();
    RECT rect = lpDrawItemStruct->rcItem;
    dc->FillSolidRect(&rect, ::GetSysColor(COLOR_MENUBAR));
    rect.left += GCB_HEADER_INDENT;
    {
        CShellDlgFont dlgFont(dc, FW_BOLD);
        dc->DrawText(GetCaption(), &rect, DT_LEFT | DT_SINGLELINE | DT_VCENTER);
    }
    dc->SetTextColor(crOldTextColor);
    dc->SetBkColor(crOldBkColor);
} 

The above code will draw header in bold font on gray background.

You have to be aware that derived combo box holds pointers to item data (and not only corresponding captions), so it must be created without CBS_HASSTRINGS flag. The reason for this will be clarified later when we shall shift to variable owner-drawn combo box.

Items Sorting

As already mentioned, one of the requirements is that items within each group are sorted optionally and separately. CBS_SORT flag for combo-box must not be set; otherwise it calls overridden CompareItem() method which would be pretty complicated to implement in our case (remember that CBS_HASSTRINGS style must not be set for combo box control in order to store pointers to item objects). Since the sorting algorithm is specific for each group, it is logical to store it into header item data, making the header item responsible to display its sub-items sorted. The proposed solution is to include item comparer class instance into CGroupComboBoxHeader and use it to sort items. Thus CGroupComboBoxHeader class becomes responsible for displaying its items sorted. CComboBoxItemCompare class is the base class for item comparison and it compares item captions lexically. Consequently, items will be sorted alphabetically by default, if the sort flag is set to true in the corresponding header constructor.

To allow simple custom sorting for any group, CGroupComboBoxHeader class constructor has been overloaded with a version that accepts a reference to a comparer class. To apply custom sorting, it is necessary only to define a class derived from the CComboBoxItemCompare class, override the Compare() method and pass reference to the instance of the derived class to the CGroupComboBoxHeader class constructor. Constructor creates a copy of this class and stores it into its member for later use, so there is no danger of losing reference to the original object when it goes out of scope.

Group Combo Box Implementation

CGroupComboBox is derived from CComboBox class. Items are added to combo-box as pointers to CGroupComboBoxItem instances. CGroupComboBoxHeader items are added by AddGroup() method which appends the header to the internal list:

C++
int CGroupComboBox::AddGroup(CGroupComboBoxHeader* groupHeader)
{
    m_headers.push_back(groupHeader);
    return m_headers.size() - 1;
} 

Note that headers are always appended to the internal list so they must be added in the order they should appear. Group header cannot be removed explicitly – all headers are deleted in the destructor of control. If group contains no items, the corresponding header is not displayed. The AddGroup() method returns the index of the header which can be used later as a reference for new items to be added.

Items can be added by the AddItem() method which appends the item to the end of the corresponding group or inserts the item into the corresponding position according to the sorting defined by the header:

C++
int CGroupComboBox::AddItem(CGroupComboBoxItem* item, int groupIndex)
{
    CGroupComboBoxHeader* groupHeader = m_headers[groupIndex];
    groupHeader->AssignItem(item);
    // if group doesn't have items, it's header is not shown yet
    if (FindItem(groupHeader) == CB_ERR)
        ShowGroupHeader(groupHeader);
    CGroupBounds gb = GetGroupBounds(groupHeader);
    int index = (groupHeader->IsSorted()) ?
                    GetInsertionIndex(groupHeader, gb.FirstIndex, gb.GroupEnd, item) :
                    gb.GroupEnd;
    return SendMessage(CB_INSERTSTRING, index, LPARAM(item));
}

The InsertItem() method inserts item immediately below the corresponding header, regardless of the sorting defined for the header:

C++
int CGroupComboBox::InsertItem(CGroupComboBoxItem* item, int groupIndex)
{
    CGroupComboBoxHeader* groupHeader = m_headers[groupIndex];
    groupHeader->AssignItem(item);
    // if group doesn't have items, it's header is not shown yet
    if (FindItem(groupHeader) == CB_ERR)
        ShowGroupHeader(groupHeader);
    int insertIndex = FindItem(groupHeader) + 1;
    return SendMessage(CB_INSERTSTRING, insertIndex, LPARAM(item));
}

Both methods accept index of the corresponding CGroupComboBoxHeader to which items belong as a second argument. FindItem(), ShowGroupHeader(), GetGroupBounds() and GetInsertionIndex() appearing in above code snippets are utility methods defined in the CGroupComboBox class.

It is useful to notice that items are added to combo box by CB_INSERTSTRING message. Since combo box is defined without CBS_HASSTRINGS flag, pointer to item data is stored and combo box is responsible for its de-allocation when an item is deleted. This is done in a WM_DELETEITEM message handler:

C++
void CGroupComboBox::OnDeleteItem(int nIDCtl, LPDELETEITEMSTRUCT lpDeleteItemStruct)
{
    CGroupComboBoxItem* item = 
          reinterpret_cast<CGroupComboBoxItem*>(lpDeleteItemStruct->itemData);
    // only non-header items must be deallocated 
    // (headers are deallocated in the destructor of control)
    if (item->IsGroupHeader() == false)
        delete item;
    CComboBox::OnDeleteItem(nIDCtl, lpDeleteItemStruct);
}

Other Issues

Combo box with the above implementations still has some flaws. They are dealt with in the following sections.

Item Selection Issue

When an item is selected from the drop-down list (by mouse or keyboard), the selected item is not placed into the edit box part of the combo box. This is easily fixed by adding the CBN_SELCHANGE notification handler:

C++
void CGroupComboBox::OnCbnSelchange()
{
    int index = GetCurSel();
    if (index >= 0)
    {
        CGroupComboBoxItem* item = GetComboBoxItem(index);
        if (item->IsGroupHeader() == false)
        {
            SetWindowText(item->GetCaption());
            SetEditSel(0, -1);
        }
    }
}

Image 6

Skipping Headers

While scrolling through the drop-down list, it would be nice to skip header items. Although even with currently described implementation, headers cannot be selected and are not shown highlighted, it would be nicer to simply skip them while scrolling up or down the drop-down list. This can be achieved in the overridden PreTranslateMessage() method – if the next item highlighted is header item, just move the selection on:

C++
BOOL CGroupComboBox::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    case WM_KEYDOWN:
        // preprocess Up and Down arrow keys to avoid selection of group headers
        switch (pMsg->wParam)
        {
        case VK_UP:
            if (PreProcessVkUp())
                return TRUE;
            break;
        case VK_DOWN:
            if (PreprocessVkDown())
                return TRUE;
            break;
        }
        break;
    }
    return CComboBox::PreTranslateMessage(pMsg);
}

PreprocessVkUp() and PreprocessVkDown() methods check if the item to be selected next is the header and skip it in such an instance. If the first item in the topmost group is selected, the selection does not move upwards. Still, if the topmost header is not visible, the drop-down list box will scroll to reveal it.

PreprocessVkUp() and PreprocessVkDown() methods use SelectItem() and ChangeSelection() methods to avoid an excessive drop-down list scrolling:

C++
void CGroupComboBox::ChangeSelection(int newSelection, int top)
{
    COMBOBOXINFO cbi = { sizeof(COMBOBOXINFO) };
    GetComboBoxInfo(&cbi);
    ::SendMessage(cbi.hwndList, WM_SETREDRAW, FALSE, 0);
    SetCurSel(newSelection);
    SetTopIndex(top);
    ::SendMessage(cbi.hwndList, WM_SETREDRAW, TRUE, 0);
    ::RedrawWindow(cbi.hwndList, NULL, NULL,
                   RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN);
}
 
void CGroupComboBox::SelectItem(int index)
{
    if (GetDroppedState())
    {
        int top = GetTopIndex();
        if (top > index)
            top = index;
        else if (index > GetBottomForItem(top))
            top = GetTopForItem(index);
        ChangeSelection(index, top);
    }
    else
        SetCurSel(index);
} 

GetBottomForItem() and GetTopForItem() are utility methods that calculate indices of bottom and top items if the selection is positioned to the top or the bottom of the view, respectively. If the SetCurSel() method was used instead of SelectItem(), the drop-down list box would often scroll to position the selected item to the top of the view even when the item is already in the view.

It should be pointed out that FindString() and FindStringExact() methods must be redefined in CGroupComboBox class. Specifically, if combo box has not the CBS_HASSTRINGS style set, implementations of both methods as defined in CComboBox class do not search for text content but for the item data pointer. Redefined versions compare item captions, skipping header items:

C++
int CGroupComboBox::FindString(int nStartAfter, LPCTSTR lpszString) const
{
    int strLen = _tcslen(lpszString);
    int index = nStartAfter + 1;
    for (int i = 0; i < GetCount(); ++i)
    {
        CGroupComboBoxItem* item = GetComboBoxItem(index);
        if (item->IsGroupHeader() == false)
        {
            if (item->GetCaption().GetLength() >= strLen)
            {
                if (item->CompareCaption(lpszString, strLen) == 0)
                    return index;
            }
        }
        ++index;
        if (index >= GetCount())
            index = 0;
    }
    return CB_ERR;
}
 
int CGroupComboBox::FindStringExact(int nIndexStart, LPCTSTR lpszFind) const
{
    int index = nIndexStart + 1;
    for (int i = 0; i < GetCount(); ++i)
    {
        CGroupComboBoxItem* item = GetComboBoxItem(index);
        if (item->IsGroupHeader() == false)
        {
            if (item->CompareCaption(lpszFind) == 0)
                return index;
        }
        ++index;
        if (index >= GetCount())
            index = 0;
    }
    return CB_ERR;
}

GetComboBoxItem() in the above code is a utility method:

C++
CGroupComboBoxItem* CGroupComboBox::GetComboBoxItem(int i) const
{
    return reinterpret_cast<CGroupComboBoxItem*>(CComboBox::GetItemData(i));
}

Similarly, PageUp and PageDown keystrokes must be handled separately. This is achieved through PreprocessVkPageDown() andPreprocessVkPageUp() methods which are invoked from the overridden PreTranslateMessage() method. These methods will not be discussed in detail here but the reader can take a glance at the implementation in the source code attached.

Automatically Selecting Matching Item on List Dropdown

If there is a text already entered in edit control, when the drop-down list is opened by clicking the drop-down arrow or by F4 key, the item that has matching caption start should be placed automatically into edit control. This can be done in CBN_DROPDOWN notification handler which is invoked just before the list is dropped-down:

C++
// code below provides only a partial solution (see text below)
void CGroupComboBox::OnCbnDropdown()
{
    // find and copy matching item
    if (GetWindowTextLength() > 0)
        FindStringAndSelect();
}
 
bool CGroupComboBox::FindStringAndSelect()
{
    CString text;
    GetWindowText(text);
    int index = FindStringExact(-1, text);
    if (index == CB_ERR)
        index = FindString(-1, text);
    if (index == CB_ERR)
        return false;
    SelectItem(index);
    SetWindowText(GetComboBoxItem(index)->GetCaption());
    SetEditSel(0, -1);
    return true;
}

However, this will not select the corresponding item in the drop-down list. The calling SetCurSel() method with evaluated index does not have any effect since the drop-down list is not visible yet when CBN_DROPDOWN notification is sent and thus no item can be marked as selected.

Hence, WM_CTLCOLORLISTBOX message handler (which is invoked when drop-down list is painted) must be implemented:

C++
BEGIN_MESSAGE_MAP(CGroupComboBox, CComboBox)
    // ...
    ON_MESSAGE(WM_CTLCOLORLISTBOX, &CGroupComboBox::OnCtlColorListbox)
END_MESSAGE_MAP()
 
LRESULT CGroupComboBox::OnCtlColorListbox(WPARAM wParam, LPARAM lParam)
{
    if (GetWindowTextLength() > 0)
    {
        // check is required to prevent recursion (SetCurSel triggers new WM_CTLCOLOR message)
        if (GetCurSel() == CB_ERR)
            FindStringAndSelect();
    }
    return 0;
}

With these changes, the above implementation of OnCbnDropdown() can be omitted. It must be pointed out that each call of SetCurSel() method (which is called from SelectItem() method) will cause WM_CTLCOLORLISTBOX message to be sent again, so it is very important to check if current selection has changed in order to prevent infinite recursive call of the handler.

The above code copies the matching item text into edit control and the item in the drop-down list is selected when the user presses F4 button. The same happens if the combo box arrow button is pressed. However, once the button is released, the list box scrolls to top, which may end with the selected item moving out of the view. The reason for this is the fact that while the button is depressed, it has the focus; when the button is released the drop-down list gains the focus and is repainted. Although the item is still displayed selected, the cursor down key will start scrolling from the topmost item. To fix this, a WM_LBUTTONUP handler needs to be overridden:

C++
void CGroupComboBox::OnLButtonUp(UINT nFlags, CPoint point)
{
    // store selected item index
    int index = GetCurSel();
    int top = GetTopIndex();
    // prevent list-box update and resulting flickering
    COMBOBOXINFO cbi = { sizeof(COMBOBOXINFO) };
    GetComboBoxInfo(&cbi);
    ::SendMessage(cbi.hwndList, WM_SETREDRAW, FALSE, 0);
 
    CComboBox::OnLButtonUp(nFlags, point);
    
    if (GetDroppedState() && index != CB_ERR)
        ChangeSelection(index, top);
    ::SendMessage(cbi.hwndList, WM_SETREDRAW, TRUE, 0);
} 

Autocompleting Entered Text

When the drop-down list is opened and text typed into edit control, the matching item should be automatically selected and the text in the edit control filled-up with item text as shown on the screenshot below. This can be carried out in the overridden PreTranslateMessage() method:

C++
BOOL CGroupComboBox::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    // ...here comes already described code for VK_UP and VK_DOWN
    case WM_CHAR:
        if (GetDroppedState())
        {
            if (_istprint(pMsg->wParam))
            {
                // fill-up the text in edit control with matching item
                CString text;
                GetWindowText(text);
                DWORD sel = GetEditSel();
                int start = LOWORD(sel);
                int end = HIWORD(sel);
                if (start != end)
                    text.Delete(start, end - start);
                text.AppendChar(TCHAR(pMsg->wParam));
                if (FindAndAutocompleteItem(text))
                    return TRUE;
            }
        }
        break;
    }
    return CComboBox::PreTranslateMessage(pMsg);
}

Image 7

If a character is typed in, the modified pattern is searched for and copied into edit box with the appropriate selection:

C++
bool CGroupComboBox::FindAndAutocompleteItem(const CString& text)
{
    int start = GetCurSel();
    if (start < 0)
        start = 0;
    int index = FindString(start - 1, text);
    if (index == CB_ERR)
        return false;
    SelectItem(index);
    SetWindowText(GetComboBoxItem(index)->GetCaption());
    // select only auto-filled text so that user can continue typing
    SetEditSel(text.GetLength(), -1);
    return true;
}

Cancelling the Selection

Font combo box in Microsoft Word provides an option to cancel a selection by Esc key. To implement this feature, it is necessary only to save the content of edit control at the moment the drop-down list pops-up and retrieve that content when the user presses the Esc key:

C++
void CGroupComboBox::OnCbnDropdown()
{
    GetWindowText(m_previousString);
 
    // follows the code already discussed...
    // ...
}
 
BOOL CGroupComboBox::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    // ...
    case WM_CHAR:
        if (GetDroppedState())
        {
            if (TCHAR(pMsg->wParam) == VK_ESCAPE)
            {
                SetWindowText(m_previousString);
                SetEditSel(0, -1);
            }
        }
        break;
    }
    return CComboBox::PreTranslateMessage(pMsg);
}

m_previousString is a class data member of CString type.

Mouse Click Events on Drop-Down List

When an item in the drop-down list is clicked with the mouse, the corresponding item text is placed into edit control. This is achieved through the already mentioned OnCbnSelchange() handler. Clicking the header item simply closes the drop-down list. To mimic the behavior of font combo box in Microsoft Word where clicking the header has no effect, i.e., leaves the drop-down list open, it is necessary to handle mouse events.

Unfortunately, combo box does not receive any mouse messages passed to the drop down list – the only way to cope with this problem is to dynamically subclass the list box:

  1. CGroupListBox class derived from CListBox class must be defined.
  2. An instance of this class (m_listBox) becomes a data member of CGroupComboBox class.
  3. Inside WM_CTLCOLORLISTBOX, message handler list box is subclassed (original list box is substituted by a CGroupListBox instance):
C++
LRESULT CGroupComboBox::OnCtlColorListbox(WPARAM wParam, LPARAM lParam)
{
    // subclass list box control
    if (m_listBox.GetSafeHwnd() == NULL)
        m_listBox.SubclassWindow(reinterpret_cast<HWND>(lParam));
    // the rest is same as above...
}

Obviously, the list box is subclassed just before it pops-up for the first time.

It must not be forgotten to un-subclass the CGroupListBox instance, the best place for which is the WM_DESTROY message handler:

C++
void CGroupComboBox::OnDestroy()
{
    if (m_listBox.GetSafeHwnd() != NULL)
        m_listBox.UnsubclassWindow();
    CComboBox::OnDestroy();
}

If the application is started after the above changes, it will fail the assertion when the drop-down list is to be shown. Now the subclassed list box must do the item drawing: the DrawItem() method must be simply transferred from CGroupComboBox to CGroupListBox class.

Finally, attention can be paid to the header click issue: PreTranslateMessage() is overridden in CGroupListBox class to prevent closing the list box:

C++
BOOL CGroupListBox::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    case WM_LBUTTONDOWN:
    case WM_LBUTTONDBLCLK:
        CPoint pt(GET_X_LPARAM(pMsg->lParam), GET_Y_LPARAM(pMsg->lParam));
        BOOL bOutside;
        int index = ItemFromPoint(pt, bOutside);
        // if user clicks on group header item, list should remain open
        if (bOutside == FALSE
            && reinterpret_cast<CGroupComboBoxItem*>(GetItemData(index))->IsGroupHeader())
            return TRUE;
        break;
    }
    return CListBox::PreTranslateMessage(pMsg);
}

Adjusting List Width

A nice feature is to adjust the drop-down list width so that each item is entirely visible. This has been implemented through AdjustDropdownListWidth() method, taking care that the drop-down list does not fall out of the desktop area:

C++
void CGroupComboBox::AdjustDropdownListWidth()
{
    COMBOBOXINFO cbi = { sizeof(COMBOBOXINFO) };
    GetComboBoxInfo(&cbi);
    RECT rect;
    ::GetWindowRect(cbi.hwndList, &rect);
    int maxItemWidth = GetMaxItemWidth() + ::GetSystemMetrics(SM_CXEDGE) * 2;
    // extend drop-down list to right
    if (maxItemWidth > rect.right - rect.left)
    {
        rect.right = rect.left + maxItemWidth;
        // reserve place for vertical scrollbar
        if (GetCount() > GetMinVisible())
            rect.right += ::GetSystemMetrics(SM_CXVSCROLL);
        // check if extended drop-down list fits the desktop
        HMONITOR monitor = MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST);
        MONITORINFO mi;
        mi.cbSize = sizeof(mi);
        GetMonitorInfo(monitor, &mi);
        // it doesn't fit, move it left
        if (mi.rcWork.right < rect.right)
        {
            int delta = rect.right - mi.rcWork.right;
            rect.left -= delta;
            rect.right -= delta;
        }
        ::SetWindowPos(cbi.hwndList, NULL, rect.left, rect.top,
                       rect.right - rect.left, rect.bottom - rect.top, SWP_NOZORDER);
    }
}

The method is invoked from WM_CTLCOLORLISTBOX message handler.

Variable Height Owner-Drawn Combo Box

Even though combo box with CBS_OWNERDRAWFIXED style would suit most needs, after item drawing and all above discussed issues are resolved, at first sight only a small additional step to implement MeasureItem() is required for a variable height owner-drawn combo box. Variable-height combo box offers additional flexibility and easier extensibility. In the attached code, variable height combo box is implemented through CGroupComboBoxVariable class, derived directly from CGroupComboBox that has been described in the first part of this article.

Since combo box created with CBS_OWNERDRAWVARIABLE style can display items with variable height, MeasureItem() is invoked for each item and overriding implementation in the derived class is responsible for providing the corresponding item height:

C++
void CGroupComboBoxVariable::MeasureItem(LPMEASUREITEMSTRUCT lpMeasureItemStruct)
{
    CGroupComboBoxItem* item = reinterpret_cast<CGroupComboBoxItem*>
                               (lpMeasureItemStruct->itemData);
    CClientDC dc(this);
    lpMeasureItemStruct->itemHeight = item->GetSize(dc).cy;
}

Note that MeasureItem() method is invoked immediately when an item is added to the combo box through any of the corresponding methods. Therefore, it is impractical to perform separate insertion of string (i.e., caption that appears in the combo box) by AddString() or InsertString() methods and then attach additional data with item height information calling SetItemData() or SetItemDataPtr() methods (note that the latter two methods are actually equivalent and both set the value sending CB_SETITEMDATA message). Even though SetItemHeight() method of the CComboBox class could be used, it is much simpler to insert the entire item in a single step, thus avoiding the need for subsequent call of SetItemHeight() method.

But the transition is not that simple. Let us discuss some issues that arise after switching to the owner-drawn variable combo box.

Accommodating Drop-Down List Height

After CBS_OWNERDRAWVARIABLE style is applied to a combo box, probably the first thing to notice will be the miserable drop-down list height, as shown in the screenshot below. For fixed height combo-box control, height of the drop-down list is adjusted to display 30 items simultaneously by default, but for a drop-down list with variable height items, it is obvious that the height of the list has to be calculated and adjusted explicitly.

Image 8

Before revealing the proposed implementation, some side effects and artifacts must be considered.

Since items are of variable height and the drop-down list always shows the top item entirely, it is apparent that the bottom item in the list will be cut off in most cases – it is not plausible to accommodate the list height to item heights each time the list scrolls. However, it would be inappropriate if the very last item is not aligned to the bottom of the list (or even worse, cut off). The drop-down list must have a non-integral height to be able to display the last item aligned. This option can be changed in the designer by setting the “No integral height” property for the combo box to true. To avoid modifying this option manually for each control, it is possible to set it in the code, e.g., in the overridden PreSubclassWindow() method:

C++
void CGroupComboBoxVariable::PreSubclassWindow()
{
    ModifyStyle(0, CBS_NOINTEGRALHEIGHT, SWP_NOSIZE | SWP_NOMOVE | 
                SWP_NOZORDER | SWP_NOACTIVATE);
    CGroupComboBox::PreSubclassWindow();
}

The height of the drop-down list has to be calculated by summing heights of last items that must fit into the bottommost view. Also, care must be taken that the drop-down list does not fall out of the workspace boundaries. For example, if combo-box control is placed at the bottom of the screen, the drop-down list will pop-up above it. These considerations are implemented in CalculateDropDownListRect() method:

C++
RECT CGroupComboBoxVariable::CalculateDropDownListRect()
{
    // get workspace area
    RECT rect;
    GetWindowRect(&rect);
    HMONITOR monitor = MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST);
    MONITORINFO mi;
    mi.cbSize = sizeof(mi);
    GetMonitorInfo(monitor, &mi);
 
    // check if drop-down box fits below edit box
    int availableHeight = mi.rcWork.bottom - rect.bottom;
    bool showBelow = true;
    // sum the heights of last m_itemsOnLastPage items
    int listHeight = 0;
    // last item
    int nBottom = GetCount() - 1;
    // item that should be on the top of the last page
    int nTop = max(nBottom - m_itemsOnLastPage + 1, 0);
    while ((nBottom >= nTop) && (listHeight + GetItemHeight(nBottom) < availableHeight))
    {
        listHeight += GetItemHeight(nBottom);
        --nBottom;
    }
    // if cannot display requested number of items below and there is more space above,
    // check how many items can be displayed when list is above
    if ((nBottom > nTop) && (availableHeight < rect.top - mi.rcWork.top))
    {
        availableHeight = rect.top - mi.rcWork.top;
        showBelow = false;
        while (nBottom >= nTop && (listHeight + GetItemHeight(nBottom) < availableHeight))
        {
            listHeight += GetItemHeight(nBottom);
            --nBottom;
        }
    }
 
    listHeight += ::GetSystemMetrics(SM_CYEDGE);
    if (showBelow)
    {
        rect.top = rect.bottom;
        rect.bottom += listHeight;
    }
    else
    {
        rect.bottom = rect.top;
        rect.top -= listHeight;
    }
    return rect;
}

The drop-down list size must be adjusted before it is displayed, and an appropriate place for this is in the CBN_DROPDOWN notification handler:

C++
void CGroupComboBoxVariable::OnCbnDropdown()
{
    CGroupComboBox::OnCbnDropdown();
    RECT rect;
    GetWindowRect(&rect); 
    CRect listRect = CalculateDropDownListRect();
    rect.bottom += listRect.Height();
    GetParent()->ScreenToClient(&rect);
    MoveWindow(&rect);
}

Actual implementation differs a little since CalculateDropDownListRect() method is time-consuming. It is not necessary to call it each time the list is dropped down as long as the content of the combo box has not changed.

About the Demo Application

Demo application contains five combo boxes:

  1. ordinary combo-box (used for comparison only)
  2. owner-drawn fixed-size combo box with text-only items
  3. two owner-drawn fixed-size combo boxes with text and image items
  4. owner-drawn variable size combo box text and image items

Combo boxes 3 and 4 use images that are stored in globally available image list (which is useful if several items use the same image) or use icons for each item defined with corresponding resource id. Combo boxes 2 and 4 have associated controls to demonstrate some of the functionalities described. Corresponding options can be switched on and off through public methods EnableDropdownListAutoWidth(), EnableAutocomplete() and EnableSelectionUndoByEscKey().

Disclaimer: Font enumeration procedure in the demo application is used for demonstration purposes only and is not meant to be the recommended approach.

Using the Code

To use above code in your project, following preparation steps must be made:

  1. Include GroupComboBox.h, GroupComboBox.cpp, ShellDlgFont.h and MemDC.h files into your project.
  2. Define your class derived from CGroupComboBoxItem and implement Draw() method; for owner-drawn variable-height combo box, you’ll have to implement GetSize() method too.
  3. Create classes derived from CGroupComboBox or CGroupComboBoxVariable class and implement AddItem() and InsertItem() methods. This step is optional and is required only to make adding/inserting items defined in step (2) easier.
  4. Optionally, create CComboBoxItemCompare derived class with overridden Compare() method if another sorting order of items is required.

These steps have been made for CGroupComboBoxWithIcons, CFontGroupComboBox and CFontGroupComboBoxVariable classes in the demo application so the reader can check the source code if there are some ambiguities left.

On these steps accomplished, simply attach combo box resources in dialog to corresponding class instances using DDX_Control() method.

History

  • 24th December, 2012 - Initial version
  • 19th November, 2013 - ON_CONTROL_REFLECT macros for CBN_SELCHANGE and CBN_DROPDOWN notifications changed to ON_CONTROL_REFLECT_EX in order to allow other controls (including parent dialog) to handle notifications

License

This article, along with any associated source code and files, is licensed under The zlib/libpng License