65.9K
CodeProject 正在变化。 阅读更多。
Home

支持多选和列表间拖动的拖动列表框

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (6投票s)

2009 年 8 月 4 日

LGPL3

9分钟阅读

viewsIcon

42495

downloadIcon

1449

MFC 的 CDragListBox 的替代品,支持多选和列表之间的拖动

引言

标准 Windows 拖动列表框 (MFC 中的 CDragListBox) 有一个众所周知的缺点,即它不支持多选。它也缺乏从一个列表框拖动到另一个列表框的支持;只允许在列表框内重新排序。我尝试解决这些缺点,结果创建了两个新类

  • CDragListBoxExCDragListBox 的直接替代品,但支持多选
  • CInterDragListBoxCDragListBoxEx 的增强版,支持在两个列表框之间拖动

背景

最终目标是创建一个对话框,用于向列表视图添加/删除列,类似于 Outlook 中的“自定义视图”对话框。我本可以完全避免拖放,而是依赖“添加/删除”和“上移/下移”按钮,但这会很笨拙。

CodeProject 上有各种拖动列表框解决方案,但它们都没有我正在寻找的精确功能组合。特别是,我想要

  • 支持单选、多选和扩展选择
  • 支持从一个列表框拖动到另一个列表框,但不使用 OLE
  • 类似于 Outlook “自定义视图”对话框的拖动反馈
  • 支持通过 Esc 键取消拖动

为什么我不想使用 OLE?因为

  1. 这很麻烦
  2. 我希望我的解决方案简单轻巧,并且
  3. 对于在两个列表框之间拖动项目,OLE 是大材小用

我考虑过从 CDragListBox 派生,但这没有任何优势,因为虽然 CDragListBox 的实现可在 MFC 源代码中找到,但大部分实际工作是通过 DL_* 消息(DL_BEGINDRAG 等)完成的,而这些消息的实现隐藏在 Windows 列表框控件本身中。经过一番折腾,我放弃了,决定直接从 CListBox 派生。但是我尽可能保留了 CDragListBox接口(下面会详细介绍)。因此,应该可以用我的任何一个类替换 CDragListBox 的现有实例,而对调用代码进行很少或不进行修改。

Using the Code

使用派生类很简单。如果您只需要在列表框中重新排序项目,请使用 CDragListBoxEx。如果您还需要在列表框之间拖动项目,请使用 CInterDragListBox。这两个类都可以作为 MFC 的 CDragListBox 的直接替代品。

CDragListBoxEx

由于 CDragListBoxEx 具有与 CDragListBox 相同的接口,因此 MFC 文档仍然适用。唯一的区别是

  1. 支持多选和扩展选择
  2. 提供了一个可覆盖的 EndDrag;默认实现释放捕获并重置拖动状态
  3. DrawInsert 可覆盖现在接受一个额外的布尔参数 (Enable),当为 true 时绘制插入标记,当为 false 时擦除先前绘制的插入标记

列表框项目可以具有关联数据,拖动不仅移动项目的文本,还移动其关联数据。SetItemDataSetItemDataPtr 函数将用户定义的数据与列表框项目关联起来。

请注意,大多数可覆盖函数都接收 CPoint 作为参数,并且该点位于屏幕坐标中,就像 MFC 的 CDragListBox 中一样。我曾想过切换到客户端坐标,但这样做会是一个错误,原因有两个:它会破坏 CDragListBox 的现有用法,并且会使 CInterDragListBox 的实现更加复杂。经验法则是,如果控件公开一个点以用于该控件上下文之外(例如,在其他控件中),则该点应位于屏幕坐标中。

另请注意,您不能在排序的列表框中重新排序项目。那将是非常不合逻辑的,长官!

CInterDragListBox

CInterDragListBox 派生自 CDragListBoxEx,并具有相同的接口,因此 MFC 文档仍然适用。区别仅在于实现:某些虚函数被覆盖以允许在列表框之间拖动,除了在列表框内拖动(重新排序)。

请注意,唯一有效的放置目标是同一应用程序中的 CInterDragListBox 实例。如果您需要其他类型的控件作为放置目标,您需要通过修改 Dragging 可覆盖函数来自己实现。如果您需要支持其他应用程序中的放置目标,则应使用 OLE,本项目不适合您。

另请注意,只支持移动;未实现复制。要实现复制,您需要覆盖 Dropped 并将对 MoveSelectedItems 的调用替换为更复杂的内容。GetSelectedItems 可能仍然有用,因为它能够剪切或复制。它的第三个参数 (Delete) 默认设置为 true,但如果为 false,则复制选择但不删除。PasteItems 可能也仍然有用。

关注点

CDragListBoxEx 中最重要的变量是拖动状态 (m_DragState),其枚举如下

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
};

当按下左键时,控件进入 DTS_TRACK 状态,使其捕获光标,并通过 OnMouseMove 监视鼠标。一个问题是,在列表框扩展选择的默认实现中,在没有 Ctrl 或 Shift 组合键的情况下按下左键会开始新的选择。这使得无法拖动多个项目,因为开始拖动会清除多选。这大概就是 CDragListBox 不允许多选的原因。简单的解决方案是在这种情况下将清除选择推迟到鼠标弹起处理程序。还设置了一个成员变量 (m_DeferSelect),这样鼠标处理程序就不必重复相同的测试。

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);
}

如果鼠标移动超过拖动阈值,则调用 BeginDrag,如果它返回 TRUE,则状态更改为 DTS_DRAG。这允许派生的 BeginDrag 以其自身的任意原因禁止拖动。拖动阈值是一个 Windows 系统度量,可从 GetSystemMetricsSM_CXDRAGSM_CYDRAG)获取。

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);
}

BeginDrag 实现主要是重置一些成员,以确保万无一失。唯一的复杂之处在于,在扩展选择模式下,可以通过按住左键移动鼠标来选择项目。这可能会导致拖动开始后选择更多项目。解决方案是执行基类按钮弹起行为,通过向自身发送 WM_LBUTTONUP。然而,这也会导致我们的按钮弹起处理程序(见下文)被调用,这通常会结束拖动操作,因此有必要引入一个中间状态,称为 DTS_PREDRAG,我们的按钮弹起处理程序会忽略它。发送 WM_LBUTTONUP 也会释放捕获,所以之后我们必须重新设置捕获。

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);
}

我们的按钮弹起处理程序必须处理所有可能的拖动状态。对于 DTS_NONEDTS_PREDRAG,我们执行基类行为。如果状态是 DTS_TRACK,则拖动从未开始,因为未超过阈值。首先我们检查 m_DeferSelect(参见 OnLButtonDown),如果它已设置,我们清除当前选择并选择光标下的单个项目。然后我们调用 EndDrag 来释放捕获并重置我们的状态。如果状态是 DTS_DRAG,我们正在通过放下结束拖动,因此除了调用 EndDrag 之外,我们还调用 Dropped 可覆盖函数,它负责将选定的项目移动到新位置。

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;
	}
}

其余的复杂性是滚动、绘制插入点和设置适当的光标。这些都由 Dragging 可覆盖函数完成,该函数在每次鼠标移动时都会被调用(参见 OnMouseMove)。

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);
}

如果光标在列表框顶部或底部的一行内移动,则通过启用计时器来完成滚动。实际的滚动由计时器消息处理程序 (OnTimer) 完成,它从 m_ScrollDelta 成员变量获取所需的滚动:-1 表示向上,1 表示向下,0 表示不滚动。为了提高效率,只有在需要时才创建滚动计时器。

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
}

计时器处理程序。请注意,除非滚动位置实际发生变化,否则不执行任何操作。这可以防止在列表框滚动到底部或顶部并保持在那里时插入点闪烁。

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 负责绘制/擦除插入标记。插入标记的外观当然是严格的个人偏好问题:我碰巧喜欢带两个箭头的红色虚线,但如果你不喜欢,请随意修改或覆盖此函数。使用两个区域的数组来容纳箭头,这些箭头由 DrawArrow 函数创建。区域的使用允许轻松擦除箭头,通过获取每个箭头的边界框(通过 GetRgnBox),然后重新绘制父窗口在该边界框内的任何部分。明确擦除是健壮的,并且比使用 XOR 模式提供更大的自由度。

请注意,Item 参数可能等于项目数;这不是错误,而是表示插入标记位于列表中最后一个项目之后。另请注意,y 被钳制到列表底部。这处理了用户向下滚动并意外超出列表框底部的情况;如果没有钳制,插入标记将消失。

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);
}

箭头作为点数组存储在本地坐标空间中。这些点被映射到客户端坐标,然后通过 CreatePolygonRgn 转换为区域。由于左箭头和右箭头是彼此的镜像,因此可以从单个图案创建其中任何一个。

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);
}

关于 CDragListBoxEx 就这些了。我们将简要介绍一下 CInterDragListBox

CInterDragListBox 最有趣的地方在于它的简单性。它几乎所有的行为都继承自 CDragListBoxExDragging 成员被重写,以包含一个关于我们是在源列表上还是在目标列表上(即,我们是在重新排序还是在进行跨列表拖动)的决策。如果我们在源上,我们的基类会处理它。如果我们在目标上,我们调用目标的 Dragging 成员,并且我们的基类会再次处理它。

唯一棘手的是,当插入标记跳到新实例时,它会在旧实例中留下一个陈旧的标记。我们不能指望基类擦除除了自身之外的任何实例中的标记,因此派生类负责清理陈旧的标记。它通过将当前目标列表的 HWND 存储在成员变量 (m_TargetList) 中来完成此操作。当目标更改时,会调用 EraseTarget 来清理旧目标中的内容。请注意,我们使用 HWND,而不是 CWnd *,以避免存储临时 CWnd * 可能导致的问题。

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);
}

最后,Dropped 被重写以处理跨列表放置。如果我们在重新排序,基类会处理它。如果是跨列表放置,我们调用 MoveSelectedItems,它将实际工作委托给基类函数 GetSelectedItemspToList->PasteItems

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
}

历史

  • 2009 年 8 月 4 日:首次发布
© . All rights reserved.