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 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.
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:
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 (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.
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:
void CGroupComboBox::PreSubclassWindow()
{
CDC* dc = GetDC();
CFont* font = GetFont();
CFont* fontOld = dc->SelectObject(font);
TEXTMETRIC tm;
dc->GetTextMetrics(&tm);
int border = ::GetSystemMetrics(SM_CYBORDER);
SetItemHeight(-1, tm.tmHeight + 2 * border);
SetItemHeight(0, tm.tmHeight);
dc->SelectObject(fontOld);
ReleaseDC(dc);
CComboBox::PreSubclassWindow();
}
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:
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.
For a simplest combo box item, Draw()
method implementation would be something like:
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:
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.
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:
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:
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.
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.
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:
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:
int CGroupComboBox::AddItem(CGroupComboBoxItem* item, int groupIndex)
{
CGroupComboBoxHeader* groupHeader = m_headers[groupIndex];
groupHeader->AssignItem(item);
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:
int CGroupComboBox::InsertItem(CGroupComboBoxItem* item, int groupIndex)
{
CGroupComboBoxHeader* groupHeader = m_headers[groupIndex];
groupHeader->AssignItem(item);
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:
void CGroupComboBox::OnDeleteItem(int nIDCtl, LPDELETEITEMSTRUCT lpDeleteItemStruct)
{
CGroupComboBoxItem* item =
reinterpret_cast<CGroupComboBoxItem*>(lpDeleteItemStruct->itemData);
if (item->IsGroupHeader() == false)
delete item;
CComboBox::OnDeleteItem(nIDCtl, lpDeleteItemStruct);
}
Combo box with the above implementations still has some flaws. They are dealt with in the following sections.
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:
void CGroupComboBox::OnCbnSelchange()
{
int index = GetCurSel();
if (index >= 0)
{
CGroupComboBoxItem* item = GetComboBoxItem(index);
if (item->IsGroupHeader() == false)
{
SetWindowText(item->GetCaption());
SetEditSel(0, -1);
}
}
}
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:
BOOL CGroupComboBox::PreTranslateMessage(MSG* pMsg)
{
switch (pMsg->message)
{
case WM_KEYDOWN:
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:
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:
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:
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.
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:
void CGroupComboBox::OnCbnDropdown()
{
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:
BEGIN_MESSAGE_MAP(CGroupComboBox, CComboBox)
ON_MESSAGE(WM_CTLCOLORLISTBOX, &CGroupComboBox::OnCtlColorListbox)
END_MESSAGE_MAP()
LRESULT CGroupComboBox::OnCtlColorListbox(WPARAM wParam, LPARAM lParam)
{
if (GetWindowTextLength() > 0)
{
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:
void CGroupComboBox::OnLButtonUp(UINT nFlags, CPoint point)
{
int index = GetCurSel();
int top = GetTopIndex();
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);
}
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:
BOOL CGroupComboBox::PreTranslateMessage(MSG* pMsg)
{
switch (pMsg->message)
{
case WM_CHAR:
if (GetDroppedState())
{
if (_istprint(pMsg->wParam))
{
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);
}
If a character is typed in, the modified pattern is searched for and copied into edit box with the appropriate selection:
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());
SetEditSel(text.GetLength(), -1);
return true;
}
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:
void CGroupComboBox::OnCbnDropdown()
{
GetWindowText(m_previousString);
}
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.
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:
CGroupListBox
class derived from CListBox
class must be defined. - An instance of this class (
m_listBox
) becomes a data member of CGroupComboBox
class. - Inside
WM_CTLCOLORLISTBOX
, message handler list box is subclassed (original list box is substituted by a CGroupListBox
instance):
LRESULT CGroupComboBox::OnCtlColorListbox(WPARAM wParam, LPARAM lParam)
{
if (m_listBox.GetSafeHwnd() == NULL)
m_listBox.SubclassWindow(reinterpret_cast<HWND>(lParam));
}
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:
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:
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 (bOutside == FALSE
&& reinterpret_cast<CGroupComboBoxItem*>(GetItemData(index))->IsGroupHeader())
return TRUE;
break;
}
return CListBox::PreTranslateMessage(pMsg);
}
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:
void CGroupComboBox::AdjustDropdownListWidth()
{
COMBOBOXINFO cbi = { sizeof(COMBOBOXINFO) };
GetComboBoxInfo(&cbi);
RECT rect;
::GetWindowRect(cbi.hwndList, &rect);
int maxItemWidth = GetMaxItemWidth() + ::GetSystemMetrics(SM_CXEDGE) * 2;
if (maxItemWidth > rect.right - rect.left)
{
rect.right = rect.left + maxItemWidth;
if (GetCount() > GetMinVisible())
rect.right += ::GetSystemMetrics(SM_CXVSCROLL);
HMONITOR monitor = MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST);
MONITORINFO mi;
mi.cbSize = sizeof(mi);
GetMonitorInfo(monitor, &mi);
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.
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:
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.
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.
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:
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:
RECT CGroupComboBoxVariable::CalculateDropDownListRect()
{
RECT rect;
GetWindowRect(&rect);
HMONITOR monitor = MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST);
MONITORINFO mi;
mi.cbSize = sizeof(mi);
GetMonitorInfo(monitor, &mi);
int availableHeight = mi.rcWork.bottom - rect.bottom;
bool showBelow = true;
int listHeight = 0;
int nBottom = GetCount() - 1;
int nTop = max(nBottom - m_itemsOnLastPage + 1, 0);
while ((nBottom >= nTop) && (listHeight + GetItemHeight(nBottom) < availableHeight))
{
listHeight += GetItemHeight(nBottom);
--nBottom;
}
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:
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.
Demo application contains five combo boxes:
- ordinary combo-box (used for comparison only)
- owner-drawn fixed-size combo box with text-only items
- two owner-drawn fixed-size combo boxes with text and image items
- 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.
To use above code in your project, following preparation steps must be made:
- Include GroupComboBox.h, GroupComboBox.cpp, ShellDlgFont.h and MemDC.h files into your project.
- 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. - 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. - 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.
- 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