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

引言
标准 Windows 拖动列表框 (MFC 中的 CDragListBox
) 有一个众所周知的缺点,即它不支持多选。它也缺乏从一个列表框拖动到另一个列表框的支持;只允许在列表框内重新排序。我尝试解决这些缺点,结果创建了两个新类
CDragListBoxEx
:CDragListBox
的直接替代品,但支持多选CInterDragListBox
:CDragListBoxEx
的增强版,支持在两个列表框之间拖动
背景
最终目标是创建一个对话框,用于向列表视图添加/删除列,类似于 Outlook 中的“自定义视图”对话框。我本可以完全避免拖放,而是依赖“添加/删除”和“上移/下移”按钮,但这会很笨拙。
CodeProject 上有各种拖动列表框解决方案,但它们都没有我正在寻找的精确功能组合。特别是,我想要
- 支持单选、多选和扩展选择
- 支持从一个列表框拖动到另一个列表框,但不使用 OLE
- 类似于 Outlook “自定义视图”对话框的拖动反馈
- 支持通过 Esc 键取消拖动
为什么我不想使用 OLE?因为
- 这很麻烦
- 我希望我的解决方案简单轻巧,并且
- 对于在两个列表框之间拖动项目,OLE 是大材小用
我考虑过从 CDragListBox
派生,但这没有任何优势,因为虽然 CDragListBox
的实现可在 MFC 源代码中找到,但大部分实际工作是通过 DL_*
消息(DL_BEGINDRAG
等)完成的,而这些消息的实现隐藏在 Windows 列表框控件本身中。经过一番折腾,我放弃了,决定直接从 CListBox
派生。但是我尽可能保留了 CDragListBox
的接口(下面会详细介绍)。因此,应该可以用我的任何一个类替换 CDragListBox
的现有实例,而对调用代码进行很少或不进行修改。
Using the Code
使用派生类很简单。如果您只需要在列表框中重新排序项目,请使用 CDragListBoxEx
。如果您还需要在列表框之间拖动项目,请使用 CInterDragListBox
。这两个类都可以作为 MFC 的 CDragListBox
的直接替代品。
CDragListBoxEx
由于 CDragListBoxEx
具有与 CDragListBox
相同的接口,因此 MFC 文档仍然适用。唯一的区别是
- 支持多选和扩展选择
- 提供了一个可覆盖的 EndDrag;默认实现释放捕获并重置拖动状态
DrawInsert
可覆盖现在接受一个额外的布尔参数 (Enable
),当为true
时绘制插入标记,当为false
时擦除先前绘制的插入标记
列表框项目可以具有关联数据,拖动不仅移动项目的文本,还移动其关联数据。SetItemData
和 SetItemDataPtr
函数将用户定义的数据与列表框项目关联起来。
请注意,大多数可覆盖函数都接收 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 系统度量,可从 GetSystemMetrics
(SM_CXDRAG
和 SM_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_NONE
和 DTS_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
最有趣的地方在于它的简单性。它几乎所有的行为都继承自 CDragListBoxEx
。Dragging
成员被重写,以包含一个关于我们是在源列表上还是在目标列表上(即,我们是在重新排序还是在进行跨列表拖动)的决策。如果我们在源上,我们的基类会处理它。如果我们在目标上,我们调用目标的 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
,它将实际工作委托给基类函数 GetSelectedItems
和 pToList
->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 日:首次发布