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

Drag List Box with Multiple Selection and Inter-List Dragging

4.95/5 (6 votes)
4 Aug 2009LGPL39 min read 41.9K   1.4K  
A replacement for MFC's CDragListBox that supports multiple selection and dragging between lists
Image 1

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:

  1. It's a hassle
  2. I wanted my solution to be simple and lightweight, and
  3. 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:

  1. Multiple and extended selection are supported
  2. An EndDrag overridable is provided; the default implementation releases capture and resets the drag state
  3. 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:

C++
enum {	// drag states
	DTS_NONE,     // not dragging
	DTS_TRACK,    // left button down, but motion hasn't reached drag threshold
	DTS_PREDRAG,  // motion reached drag threshold, but drag hasn't started yet
	DTS_DRAG,     // drag is in progress
};

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.

C++
void CDragListBoxEx::OnLButtonDown(UINT nFlags, CPoint point) 
{
	if (m_DragState == DTS_NONE) {
		m_DragState = DTS_TRACK;
		m_DragOrigin = point;
		SetCapture();
	}
	// if extended selection mode and multiple items are selected, don't alter
	// selection on button down without modifier keys; could be start of drag
	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).

C++
void CDragListBoxEx::OnMouseMove(UINT nFlags, CPoint point) 
{
	CPoint	spt(point);
	ClientToScreen(&spt);
	if (m_DragState == DTS_TRACK) {	// if tracking
		if (nFlags & (MK_SHIFT | MK_CONTROL)) // if modifier keys
			CListBox::OnMouseMove(nFlags, point);// delegate to base class
		else {	 // no modifier keys
			// if motion in either axis exceeds drag threshold
			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; // we're dragging
			}
		}
	}
	if (m_DragState == DTS_DRAG)	// if dragging
		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.

C++
BOOL CDragListBoxEx::BeginDrag(CPoint point)
{
	UNREFERENCED_PARAMETER(point);
	m_PrevInsPos = -1;
	m_PrevTop = -1;
	m_DragState = DTS_PREDRAG;
	SendMessage(WM_LBUTTONUP, 0, 0);	// avoids extending selection
	if (::GetCapture() != m_hWnd)	// make sure we still have capture
		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.

C++
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) { // if selection deferred in button down
			SetSel(-1, FALSE);	// clear selection
			int	pos = HitTest(spt);
			if (pos >= 0)
				SetSel(pos, TRUE);	// select one item
		}
		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).

C++
UINT CDragListBoxEx::Dragging(CPoint point)
{
	AutoScroll(point);
	UpdateInsert(point);
	if (::WindowFromPoint(point) == m_hWnd)
		SetCursor(AfxGetApp()->LoadCursor(IDC_DRAG_MOVE));
	else	// not in this list box
 		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.

C++
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)	// if cursor is above top boundary
		m_ScrollDelta = -1;	// start scrolling up
	else if (cpt.y >= cr.bottom - margin) // if cursor is below bottom boundary
		m_ScrollDelta = 1;		// start scrolling down
	else
		m_ScrollDelta = 0;		// stop scrolling
	if (m_ScrollDelta && !m_ScrollTimer)	// if scrolling and timer not created yet
		m_ScrollTimer = SetTimer
			(SCROLL_TIMER, SCROLL_DELAY, NULL);	// create it
}

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.

C++
void CDragListBoxEx::OnTimer(UINT nIDEvent) 
{
	if (nIDEvent == SCROLL_TIMER) {
		if (m_ScrollDelta) {	// if scrolling
			int	NewTop = GetTopIndex() + m_ScrollDelta;
			if (NewTop != m_PrevTop) {	// if scroll position changed
				EraseInsert();	// erase previous insert 
						// before scrolling
				SetTopIndex(NewTop); // scroll to new position
				CPoint	pt;
				GetCursorPos(&pt);
				UpdateInsert(pt);	// draw new insert position
				m_PrevTop = NewTop; // update scroll position
			}
		}
	} 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.

C++
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 {	// insert after last item
		GetItemRect(items - 1, &r);
		y = r.bottom;
	}
	if (y >= cr.bottom)	// if below control
		y = cr.bottom - 1;	// clamp to bottom edge
	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);	// draw line
		pDC->LineTo(cr.right, y);
		for (int i = 0; i < ARROWS; i++)	// draw arrows
			pDC->FillRgn(&arrow[i], &brush);
		pDC->SelectObject(pPrevPen);
	} else {	// erase marker
		CRect	r(cr.left, y, cr.right, y + 1);
		RedrawWindow(&r, NULL);	// erase line
		CWnd	*pParent = GetParent();
		for (int i = 0; i < ARROWS; i++) {
			arrow[i].GetRgnBox(r); // get arrow's bounding box
			ClientToScreen(r);
			pParent->ScreenToClient(r);
			pParent->RedrawWindow(&r, NULL);	// erase arrow
		}
	}
	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.

C++
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 *.

C++
UINT CInterDragListBox::Dragging(CPoint point)
{
	UINT	retc;
	HWND	Target;
	CInterDragListBox	*pList = ListFromPoint(point);
	if (pList == this) {	// if we're in this list
		Target = m_hWnd;
		retc = CDragListBoxEx::Dragging(point); // do reordering
	} else {	// not reordering
		if (pList != NULL) {	// if we're in a target list
			Target = pList->m_hWnd;
			pList->Dragging(point);	// do target behavior
		} else {	// not in a list
			Target = NULL;
	 		SetCursor(LoadCursor(NULL, IDC_NO));
		}
		retc = DL_CURSORSET;
	}
	if (Target != m_hTargetList) {	// if target changed
		EraseTarget();
		m_hTargetList = Target;	// update 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.

C++
void CInterDragListBox::Dropped(CPoint point)
{
	CInterDragListBox	*pList = ListFromPoint(point);
	if (pList == this) {			// if we're in this list
		CDragListBoxEx::Dropped(point); 	// do reordering behavior
	} else {	// not reordering
		if (pList != NULL) {		// if we're in a target list
			int	InsPos = pList->GetInsertPos(point);
			MoveSelectedItems(pList, InsPos);
		}
	}
}

void CInterDragListBox::MoveSelectedItems(CInterDragListBox *pToList, int InsertPos)
{
	int	top = GetTopIndex();		// save scroll position
	CStringArray	ItemText;
	CDWordArray	ItemData;
	int	ipos;	// dummy arg; deletion doesn't affect insert position
	GetSelectedItems(ItemText, ItemData, ipos);	// cut selected items
	pToList->PasteItems(ItemText, ItemData, InsertPos);	// paste items
	SetTopIndex(top);	// restore scroll position
}

History

  • 4th August, 2009: Initial release

License

This article, along with any associated source code and files, is licensed under The GNU Lesser General Public License (LGPLv3)