Introduction
The standard Windows drag list box (CDragListBox
in MFC) has a well-known shortcoming, which is that it doesn't support multiple selection. It also lacks support for dragging from one list box to another; only reordering within a list box is allowed. My attempt to work around these shortcomings resulted in the creation of two new classes:
CDragListBoxEx
: A drop-in replacement for CDragListBox
, but with multiple selection support CInterDragListBox
: An enhancement of CDragListBoxEx
that supports dragging between two list boxes
Background
The ultimate goal was creating a dialog for adding/removing columns to/from a list view, similar to the "Customize View" dialog found in Outlook. I could have avoided drag altogether and relied on "Add/Remove" and "Move Up/Down" buttons instead, but that would have been lame.
Various drag list box solutions are available elsewhere on CodeProject, but none of them had the exact combination of features I was looking for. In particular, I wanted:
- Support for single, multiple and extended selections
- Support dragging from one list box to another, but without using OLE
- Drag feedback similar to Outlook's "Customize View" dialog
- Support for cancelling a drag via the Escape key
Why didn't I want to use OLE? Because:
- It's a hassle
- I wanted my solution to be simple and lightweight, and
- OLE is overkill for dragging items between two list boxes
I looked into deriving from CDragListBox
but this didn't offer any advantages, because while the implementation of CDragListBox
is available in the MFC sources, most of the real work is done via the DL_*
messages (DL_BEGINDRAG
, etc.), and the implementation of these messages is hidden inside the Windows list box control itself. After thrashing around a bit, I gave up and decided to derive directly from CListBox
. I did however preserve the interface of CDragListBox
as much as possible (more on this below). Consequently it should be possible to replace existing instances of CDragListBox
with either of my classes, with few or no modifications to the calling code.
Using the Code
Using the derived classes is straightforward. If you only need to reorder items within a list box, use CDragListBoxEx
. If you also need to drag items between list boxes, use CInterDragListBox
. Either class can function as a drop-in replacement for MFC's CDragListBox
.
CDragListBoxEx
Since CDragListBoxEx
has the same interface as CDragListBox
, the MFC documentation still applies. The only differences are:
- Multiple and extended selection are supported
- An EndDrag overridable is provided; the default implementation releases capture and resets the drag state
- The
DrawInsert
overridable now takes an additional boolean argument (Enable
), which is true
to draw an insert marker, or false
to erase a previously drawn insert marker
List box items can have associated data, and dragging moves not only the items' text but also their associated data. The SetItemData
and SetItemDataPtr
functions associate user-defined data with list box items.
Note that most of the overridable functions receive a CPoint
as an argument, and this point is in screen coordinates, just as it is in MFC's CDragListBox
. I toyed with switching to client coordinates, but this would have been a mistake for two reasons: it would have broken existing usage of CDragListBox
, and it would have made the implementation of CInterDragListBox
more complicated. A rule of thumb is that if a control exposes a point for use outside the context of that control, (e.g. in other controls), that point should be in screen coordinates.
Also note that you can't reorder items in a sorted list box. That would be most illogical captain!
CInterDragListBox
CInterDragListBox
is derived from CDragListBoxEx
, and has the same interface, so the MFC documentation still applies. The differences are only in the implementation: certain virtual functions are overridden to allow dragging between list boxes, in addition to dragging within a list box (reordering).
Note that the only valid drop targets are CInterDragListBox
instances within the same application. If you need other types of controls to be drop targets, you'll need to implement that yourself, by modifying the Dragging
overridable. If you need to support drop targets in other applications, you should be using OLE and this project is not for you.
Also note that only moving is supported; copying is not implemented. To implement copy, you'll need to override Dropped
and replace the call to MoveSelectedItems
with something more sophisticated. GetSelectedItems
may still be useful, since it's capable of cutting or copying. Its third argument (Delete
) is true
by default, but if it's false
, the selection is copied but not deleted. PasteItems
is probably still useful too.
Points of Interest
The most important variable in CDragListBoxEx
is the drag state (m_DragState
), which is enumerated as follows:
enum { DTS_NONE, DTS_TRACK, DTS_PREDRAG, DTS_DRAG, };
When the left button is pressed, the control enters DTS_TRACK
state, causing it capture the cursor, and monitor the mouse via OnMouseMove
. One problem is that in the default implementation of list box extended selection, a left button down without a Ctrl or Shift modifier key starts a new selection. This makes it impossible to drag multiple items, because starting a drag clears the multiple selection. Presumably this is why CDragListBox
doesn't allow multiple selection. The simple solution is to defer clearing the selection to the mouse up handler in this case. A member variable (m_DeferSelect
) is also set, so that the mouse handler needn't repeat the same test.
void CDragListBoxEx::OnLButtonDown(UINT nFlags, CPoint point)
{
if (m_DragState == DTS_NONE) {
m_DragState = DTS_TRACK;
m_DragOrigin = point;
SetCapture();
}
m_DeferSelect = (GetStyle() & LBS_EXTENDEDSEL) && GetSelCount() > 1
&& !(nFlags & (MK_SHIFT | MK_CONTROL));
if (!m_DeferSelect)
CListBox::OnLButtonDown(nFlags, point);
}
If mouse motion exceeds the drag threshold, BeginDrag
is called, and if it returns TRUE
, the state is changed to DTS_DRAG
. This allows a derived BeginDrag
to disallow dragging for arbitrary reasons of its own. The drag threshold is a Windows system metric, available from GetSystemMetrics
(SM_CXDRAG
and SM_CYDRAG
).
void CDragListBoxEx::OnMouseMove(UINT nFlags, CPoint point)
{
CPoint spt(point);
ClientToScreen(&spt);
if (m_DragState == DTS_TRACK) { if (nFlags & (MK_SHIFT | MK_CONTROL)) CListBox::OnMouseMove(nFlags, point); else { if (abs(m_DragOrigin.x - point.x) >
GetSystemMetrics(SM_CXDRAG)
|| abs(m_DragOrigin.y - point.y) >
GetSystemMetrics(SM_CYDRAG)) {
if (BeginDrag(spt))
m_DragState = DTS_DRAG; }
}
}
if (m_DragState == DTS_DRAG) Dragging(spt);
}
The BeginDrag
implementation mostly just resets some members, just to be sure. The only complication is that in extended selection mode, items can be selected by moving the mouse while holding down the left button. This could cause additional items to be selected after the drag begins. The solution is to do the base class button up behavior, by sending ourselves WM_LBUTTONUP
. However this also causes our button up handler (see below) to be called, which would normally end the drag operation, so it's necessary to introduce an intermediate state, called DTS_PREDRAG
, which our button up handler ignores. Sending WM_LBUTTONUP
also releases capture, so we have to set capture again afterwards.
BOOL CDragListBoxEx::BeginDrag(CPoint point)
{
UNREFERENCED_PARAMETER(point);
m_PrevInsPos = -1;
m_PrevTop = -1;
m_DragState = DTS_PREDRAG;
SendMessage(WM_LBUTTONUP, 0, 0); if (::GetCapture() != m_hWnd) SetCapture();
return(TRUE);
}
Our button up handler has to deal with all possible drag states. For DTS_NONE
and DTS_PREDRAG
, we do the base class behavior. If the state is DTS_TRACK
, the drag never began, because the threshold wasn't exceeded. First we check m_DeferSelect
(see OnLButtonDown
), and if it's set we clear the current selection and select the single item under the cursor. Then we call EndDrag
to release capture and reset our state. If the state is DTS_DRAG
, we're ending a drag by dropping, so in addition to calling EndDrag
we also call the Dropped
overridable, which takes care of moving the selected items to their new location.
void CDragListBoxEx::OnLButtonUp(UINT nFlags, CPoint point)
{
CPoint spt(point);
ClientToScreen(&spt);
switch (m_DragState) {
case DTS_NONE:
case DTS_PREDRAG:
CListBox::OnLButtonUp(nFlags, point);
break;
case DTS_TRACK:
if (m_DeferSelect) { SetSel(-1, FALSE); int pos = HitTest(spt);
if (pos >= 0)
SetSel(pos, TRUE); }
EndDrag();
break;
case DTS_DRAG:
EndDrag();
Dropped(spt);
break;
}
}
The remaining complications are scrolling, drawing the insert point, and setting an appropriate cursor. These are all done by the Dragging
overridable, which gets called for every mouse move (see OnMouseMove
).
UINT CDragListBoxEx::Dragging(CPoint point)
{
AutoScroll(point);
UpdateInsert(point);
if (::WindowFromPoint(point) == m_hWnd)
SetCursor(AfxGetApp()->LoadCursor(IDC_DRAG_MOVE));
else SetCursor(LoadCursor(NULL, IDC_NO));
return(DL_CURSORSET);
}
Scrolling is accomplished by enabling a timer if the cursor moves within one line of the top or bottom of the list box. The actual scrolling is done by the timer message handler (OnTimer
), which obtains the desired scrolling from the m_ScrollDelta
member variable: -1 for up, 1 for down, or 0 for none. For efficiency reasons, the scrolling timer is only created if it's needed.
void CDragListBoxEx::AutoScroll(CPoint point)
{
CRect cr, ir, hr;
GetClientRect(cr);
GetItemRect(0, ir);
int margin = ir.Height();
CPoint cpt(point);
ScreenToClient(&cpt);
if (cpt.y < cr.top + margin) m_ScrollDelta = -1; else if (cpt.y >= cr.bottom - margin) m_ScrollDelta = 1; else
m_ScrollDelta = 0; if (m_ScrollDelta && !m_ScrollTimer) m_ScrollTimer = SetTimer
(SCROLL_TIMER, SCROLL_DELAY, NULL); }
The timer handler. Note that no action is taken unless the scroll position actually changes. This prevents the insert point from flickering if the list box is scrolled to the bottom or top and held there.
void CDragListBoxEx::OnTimer(UINT nIDEvent)
{
if (nIDEvent == SCROLL_TIMER) {
if (m_ScrollDelta) { int NewTop = GetTopIndex() + m_ScrollDelta;
if (NewTop != m_PrevTop) { EraseInsert(); SetTopIndex(NewTop); CPoint pt;
GetCursorPos(&pt);
UpdateInsert(pt); m_PrevTop = NewTop; }
}
} else
CListBox::OnTimer(nIDEvent);
}
DrawInsert
is tasked with drawing/erasing the insert marker. The appearance of the insert marker is strictly a matter of preference of course: I happen to like a red dotted line with two arrows, but if you don't, feel free to modify or override this function. An array of two regions is used to hold the arrows, which are created by the DrawArrow
function. The use of regions allows the arrows to be easily erased, by getting the bounding box of each arrow (via GetRgnBox
), and then redrawing whatever portion of the parent window is within that bounding box. Explicit erasing is robust and gives more freedom than using XOR mode.
Note that the Item
argument may equal the number of items; this is not an error, rather it indicates that the insert marker is positioned after the last item in the list. Also note that y
is clamped to the bottom of the list. This handles the case where the user is scrolling down and accidentally overshoots the bottom of the list box; without clamping, the insert marker would disappear.
void CDragListBoxEx::DrawInsert(int Item, bool Enable)
{
ASSERT(Item >= 0 && Item <= GetCount());
CDC *pDC = GetDC();
CRect cr;
GetClientRect(&cr);
int items = GetCount();
int y;
CRect r;
if (Item < items) {
GetItemRect(Item, &r);
y = r.top;
} else { GetItemRect(items - 1, &r);
y = r.bottom;
}
if (y >= cr.bottom) y = cr.bottom - 1; static const int ARROWS = 2;
CRgn arrow[ARROWS];
MakeArrow(CPoint(cr.left, y), TRUE, arrow[0]);
MakeArrow(CPoint(cr.right, y), FALSE, arrow[1]);
if (Enable) {
COLORREF InsColor = RGB(255, 0, 0);
CPen pen(PS_DOT, 1, InsColor);
CBrush brush(InsColor);
CPen *pPrevPen = pDC->SelectObject(&pen);
pDC->SetBkMode(TRANSPARENT);
pDC->MoveTo(cr.left, y); pDC->LineTo(cr.right, y);
for (int i = 0; i < ARROWS; i++) pDC->FillRgn(&arrow[i], &brush);
pDC->SelectObject(pPrevPen);
} else { CRect r(cr.left, y, cr.right, y + 1);
RedrawWindow(&r, NULL); CWnd *pParent = GetParent();
for (int i = 0; i < ARROWS; i++) {
arrow[i].GetRgnBox(r); ClientToScreen(r);
pParent->ScreenToClient(r);
pParent->RedrawWindow(&r, NULL); }
}
ReleaseDC(pDC);
}
The arrow is stored as an array of points in a local coordinate space. The points are mapped to client coordinates, and then converted to a region via CreatePolygonRgn
. Since the left and right arrows are mirrors of each other, either can be created from a single pattern.
void CDragListBoxEx::MakeArrow(CPoint point, bool left, CRgn& rgn)
{
static const POINT ArrowPt[] = {
{0, 0}, {5, 5}, {5, 2}, {9, 2}, {9, -1}, {5, -1}, {5, -5}
};
static const int pts = sizeof(ArrowPt) / sizeof(POINT);
POINT pta[pts];
int dir = left ? -1 : 1;
for (int i = 0; i < pts; i++) {
pta[i].x = point.x + ArrowPt[i].x * dir;
pta[i].y = point.y + ArrowPt[i].y;
}
rgn.CreatePolygonRgn(pta, pts, ALTERNATE);
}
That's about it for CDragListBoxEx
. We'll conclude with a brief tour of CInterDragListBox
.
The most interesting thing about CInterDragListBox
is how simple it is. Almost all of its behavior is inherited from CDragListBoxEx
. The Dragging
member is overridden to include a decision about whether we're over the source list or a target list, i.e., whether we're reordering or doing an inter-list drag. If we're over a source, our base class handles it. If we're over a target, we call the target's Dragging
member, and again our base class handles it.
The only tricky thing is, when the insert marker jumps to a new instance, it leaves behind a stale marker in the old instance. We can't expect the base class to erase a marker in any instance but itself, so the derived class is responsible for cleaning up the stale marker. It does that by storing the HWND
of the current target list in a member variable (m_TargetList
). When the target changes, EraseTarget
gets called to clean things up in the old target. Note that we use an HWND
, not a CWnd *
, to avoid the problems that could result from storing a temporary CWnd *
.
UINT CInterDragListBox::Dragging(CPoint point)
{
UINT retc;
HWND Target;
CInterDragListBox *pList = ListFromPoint(point);
if (pList == this) { Target = m_hWnd;
retc = CDragListBoxEx::Dragging(point); } else { if (pList != NULL) { Target = pList->m_hWnd;
pList->Dragging(point); } else { Target = NULL;
SetCursor(LoadCursor(NULL, IDC_NO));
}
retc = DL_CURSORSET;
}
if (Target != m_hTargetList) { EraseTarget();
m_hTargetList = Target; }
return(retc);
}
And finally, Dropped
is overridden to handle inter-list drops. If we're reordering, the base class handles it. If it's an inter-list drop, we call MoveSelectedItems
which delegates the real work to the base class functions GetSelectedItems
and pToList
->PasteItems
.
void CInterDragListBox::Dropped(CPoint point)
{
CInterDragListBox *pList = ListFromPoint(point);
if (pList == this) { CDragListBoxEx::Dropped(point); } else { if (pList != NULL) { int InsPos = pList->GetInsertPos(point);
MoveSelectedItems(pList, InsPos);
}
}
}
void CInterDragListBox::MoveSelectedItems(CInterDragListBox *pToList, int InsertPos)
{
int top = GetTopIndex(); CStringArray ItemText;
CDWordArray ItemData;
int ipos; GetSelectedItems(ItemText, ItemData, ipos); pToList->PasteItems(ItemText, ItemData, InsertPos); SetTopIndex(top); }
History
- 4th August, 2009: Initial release