一个基于 CFileDialog 的类,可提供轻松的图像预览






4.94/5 (39投票s)
使用文件打开对话框浏览您的图像,并查看您选择的内容。
引言
我想将各种格式的图像导入我的应用程序。这些图像可能来自数码相机或扫描仪,并且不一定有容易理解的文件名。看看上面示例图片中的文件名。它并不是很直观。因此,我显然需要在文件导入对话框中显示预览图像。当用户单击列表视图中的文件时,预览控件会更新以显示图像。我自然会去CodeProject看看是否有人已经实现了这样的东西。如果有人实现了,我找不到,所以我自己做了一个。
基础
我的起点是MFC的CFileDialog
类,它是Windows文件打开通用对话框的封装。如果您只需要一些额外的控件,扩展此类相对容易。您可以通过从CFileDialog
派生一个新类,创建一个包含您额外控件的对话框模板,然后在CFileDialog
类中嵌入的OPENFILENAME
结构上做一些“魔法”(请参阅MSDN获取完整详细信息)。(顺便说一句,重要的是要记住,您的对话框模板将成为CFileDialog
对象的子窗口)。创建派生类后,您需要为派生类添加消息映射成员,以处理来自对话框模板中子控件的消息,并在必要时添加数据成员以通过DDX访问您的控件。
这一切都很好。我创建了额外的对话框模板资源,创建了派生类,连接了图片预览控件[^],编译后就可以运行了。对话框弹出来了,我的额外控件也出现了,但是……什么都没有!
这应该不足为奇。我没有添加一个处理程序来捕获列表视图控件中的选择更改,因此无法告知图片预览控件显示最近选择的图像。
我需要找到一种方法来拦截列表视图控件的消息流,检测选择更改,确定涉及哪个文件,并告知图片预览控件显示它。这可以通过“子类化”子窗口来完成,这仅仅意味着发送到子窗口的消息首先会传递到我们编写的代码。我们检查消息,处理我们感兴趣的消息,将其他消息传递给该子窗口的原始消息处理程序,并且(可能)将我们感兴趣的消息也传递给原始消息处理程序。
题外话
如前所述,自定义对话框模板是文件打开对话框的子窗口。因此,如果您要查找文件对话框上的标准控件,您必须转到父窗口。一个显而易见的地方是在您的OnInitDialog()
函数中。假设您要查找“打开”按钮。您启动Spy++并使用它来确定按钮的控件ID,该ID不出所料是1(IDOK
)。您可能会这样做void CImageImportDlg::OnInitDialog()
{
CFileDialog::OnInitDialog();
CWnd *pWnd = GetParent()->GetDlgItem(IDOK);
// Do something with pWnd...
return TRUE;
}
编译运行后,一切正常。所以您采用相同的方法,发现列表视图的控件ID也是1!这怎么可能?这是因为列表视图控件不是文件打开对话框的直接子窗口。使用Spy++会发现它是另一个窗口的子窗口,该窗口的类名为SHELLDLL_DefView
,子ID为0x461。SHELLDLL_DefView
本身又是文件打开对话框的子窗口。好的,我们编写以下代码来访问列表视图……
CWnd *pWnd = GetParent()->GetDlgItem(0x461)->GetDlgItem(IDOK);
然后它就崩溃了。尽管这行代码看起来是正确的。但是如果您将其分解为涉及的单个步骤 CWnd *pShellWnd = GetParent()->GetDlgItem(0x461);
CWnd *pListViewWnd = pShellWnd->GetDlgItem(IDOK);
您会很快发现获取Shell窗口(子ID 0x461)指针的那一行返回了一个NULL指针。但是等等,我们知道带有该子ID的窗口存在,并且它是文件打开对话框的子窗口——Spy++可以证明这一点。是的,在您使用Spy++时,它确实存在。但是,如果您在OnInitDialog()
函数中设置一个断点,获取父窗口的窗口句柄,并使用Spy++在对话框生命周期的该时间点检查子窗口,您将发现Shell窗口尚未创建。
我们稍后将讨论如何解决这个问题。
回到正题
因此,我们确定并非所有对话框控件都在调用OnInitDialog()
时存在(但您在对话框模板中定义的所有控件都会在此之前存在)。稍后(在人类反应时间方面)它们就会存在,然后我们就可以使用它们了。
拦截来自列表视图的通知
我添加了一个从CWnd
派生的类,一旦我能访问SHELLDLL_DefView
子窗口,就将其子类化到派生类。重要的是要注意,我们子类化的是ListView
的父窗口,而不是ListView
本身。我们感兴趣的通知消息会发送到父窗口。我们先来看一下这个类,然后再讨论如何(以及何时)子类化SHELLDLL_DefView
。
class CHookWnd : public CWnd
{
public:
void SetOwner(CImageImportDlg *m_pOwner);
virtual BOOL OnNotify(WPARAM wParam, LPARAM lParam, LRESULT* pResult);
private:
CImageImportDlg *m_pOwner;
};
这是一个非常简单的类。SetOwner()
只是将指向我们对话框对象的指针复制到m_pOwner
变量中。OnNotify()
看起来是这样的。BOOL CHookWnd::OnNotify(WPARAM, LPARAM lParam, LRESULT* pResult)
{
LPNMLISTVIEW pLVHdr = reinterpret_cast<LPNMLISTVIEW>(lParam);
if (pLVHdr->hdr.code == LVN_ITEMCHANGED && (pLVHdr->uChanged & LVIF_STATE))
{
if (pLVHdr->iItem != -1)
{
// It's a valid listview index so we attach the
// the handle of the window that sent the message
// to a local CListCtrl object for easy access.
CListCtrl ctl;
LPCITEMIDLIST pidl;
TCHAR tszBuffer[_MAX_PATH],
tszFileName[_MAX_PATH],
tszExtension[_MAX_EXT];
CString csTemp;
ctl.Attach(pLVHdr->hdr.hwndFrom);
pidl = (LPCITEMIDLIST) ctl.GetItemData(pLVHdr->iItem);
SHGetPathFromIDList(pidl, tszBuffer);
_tsplitpath(tszBuffer, NULL, NULL, tszFileName, tszExtension);
csTemp.Format(_T("%s%s"), tszFileName, tszExtension);
// Update our parent window
if (m_pOwner->m_nPrevSelection != pLVHdr->iItem)
{
m_pOwner->UpdatePreview(csTemp);
}
// Be certain we detach the handle before the CListCtrl
// object goes out of scope (else the underlying List View
// will be deleted, which is NOT what we want).
ctl.Detach();
}
}
*pResult = 0;
return FALSE;
}
一旦我们将SHELLDLL_DefView
窗口句柄(已子类化)连接到一个CHookWnd
类的实例,每次SHELLDLL_DefView
窗口的子控件将其父窗口发送WM_NOTIFY
消息时,此函数都会被调用。WM_NOTIFY
消息又包含子消息,这些子消息根据控件的类型而有所不同。我们感兴趣的子消息是LVN_ITEMCHANGED
子消息。如您所见,我们将lParam
消息参数强制转换为NMLISTVIEW
结构的指针,并检查uChanged
成员。它包含一组标志,告诉我们项的哪些部分发生了更改。我们对项状态的更改感兴趣。这意味着该项先前未被选中,现在被选中了,或者它被选中了(还有其他一些我对此对话框不关心的内容,例如焦点)。因此,如果这是一个我们感兴趣的通知,我们就将方便地在NMLISTVIEW
结构中传递的ListView
的窗口句柄附加到一个CListCtrl
对象,在该对象上调用GetItemData()
,然后通过SHGetPathFromIDList()
API传递该项数据以获取文件名。完成后,我们将返回的路径拆分为文件名和扩展名,重新组合它们,然后告诉我们的所有者对话框对象更新预览窗口。完成后,我们将ListView窗口句柄从CListCtrl
对象分离并返回FALSE
。返回FALSE
很重要,因为它确保WM_NOTIFY
被传递到SHELLDLL_DefView
窗口的原始Windows过程。
修复了一个bug
以前我直接从列表视图文本获取文件名。当您在默认安装的Windows上运行它时,这可以正常工作,因为已知文件类型的扩展名被隐藏了。解决这个小难题需要一些思考和搜索MSDN。我找到了Paul DiLascia的这篇文章[^],其中描述了一个未公开的技巧来解决这个问题。它依赖于一个事实,即列表视图中每个条目的itemdata
实际上是一个PIDL
,其中包含该条目的路径和文件名。这是子类化的时候了
那么,我们如何,更重要的是,何时子类化SHELLDLL_DefView
?我们已经确定不能在OnInitDialog()
期间执行此操作。您可以使用计时器,但这是不好的做法。解决方案在于研究文件打开通用对话框发送的通知。现在可能是时候感谢David Kotchan和他出色的文章实现只读“文件打开”或“文件保存”通用对话框[^]了。在生命周期中,文件打开通用对话框会将各种通知消息发送给对话框过程。其中一个通知是CDN_INITDONE
消息,它告诉我们对话框已完成初始化。当您收到此消息时,您就知道所有控件,包括SHELLDLL_DefView
窗口,都已创建。这似乎是执行子类化的好时机。而且它会起作用,直到用户更改目录!那时,如果您只处理了CDN_INITDONE
消息,代码就会崩溃。这种行为使我感到困惑,直到我阅读了David Kotchan的文章。事实证明,每次用户导航到新目录时,SHELLDLL_DefView
窗口(及其子ListView
窗口)都会被销毁并重新创建。因此,执行子类化的正确位置是在收到CDN_FOLDERCHANGE
通知时。
所以让我们来看看对话框类,看看这一切是如何整合在一起的。
CImageImportDlg类
class CImageImportDlg : public CFileDialog
{
class CHookWnd : public CWnd
{
public:
void SetOwner(CImageImportDlg *m_pOwner);
virtual BOOL OnNotify(WPARAM wParam, LPARAM lParam, LRESULT* pResult);
private:
CImageImportDlg *m_pOwner;
};
DECLARE_DYNAMIC(CImageImportDlg)
public:
CImageImportDlg(LPCTSTR lpszDefExt = NULL,
LPCTSTR lpszFileName = NULL,
DWORD dwFlags =
OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT,
CWnd* pParentWnd = NULL);
virtual ~CImageImportDlg();
virtual void DoDataExchange(CDataExchange *pDX);
virtual BOOL OnNotify(WPARAM wParam, LPARAM lParam, LRESULT* pResult);
void UpdatePreview(LPCTSTR szFilename);
protected:
CImagePreviewStatic m_preview;
CString m_csPreviewName;
int m_nPrevSelection;
CHookWnd m_wndHook;
virtual BOOL OnInitDialog();
};
您会注意到的第一件事是我之前撒了点小谎。CHookWnd
类是嵌套在CImageImportDlg
类中的私有类。这很有道理,因为它在对话框类之外没有用处,而且我不想因为对它的用途记忆不清而在别处实例化它。CImageImportDlg
构造函数接受与CFileDialog
基类相同的参数。这很有道理,因为该类旨在作为CFileDialog
的直接替换。但是,由于该类也旨在允许导入图像,因此它修改基类的行为以使其始终支持多选是有意义的。构造函数是这样做的
CImageImportDlg::CImageImportDlg(LPCTSTR lpszDefExt, LPCTSTR lpszFileName,
DWORD dwFlags, CWnd* pParentWnd)
: CFileDialog(TRUE, lpszDefExt, lpszFileName, dwFlags, szFilter, pParentWnd)
{
m_ofn.Flags |= OFN_ENABLETEMPLATE | OFN_ALLOWMULTISELECT | OFN_ENABLESIZING;
m_ofn.hInstance = AfxGetInstanceHandle();
m_ofn.lpTemplateName = MAKEINTRESOURCE(IDD_IMAGEPREVIEWDLG);
// Provide a big buffer for returned filenames
m_ofn.lpstrFile = new TCHAR[10000];
m_ofn.nMaxFile = 10000;
memset(m_ofn.lpstrFile, 0, countof(m_ofn.lpstrFile));
}
我们修改标志以允许多选文件(并且作为附加的好处,我们使对话框可调整大小)。上面提到的用于向对话框添加控件的“魔法”在此处揭示。我们设置了OFN_ENABLETEMPLATE
标志,将m_ofn.hInstance
成员变量设置为我们可执行实例的实例句柄,并将m_ofn.lpTemplateName
成员变量设置为我们的模板标识符,然后让MFC处理其余的事情。
我们还需要提供一个足够大的缓冲区来包含用户选择的文件名。幸运的是,文件打开通用对话框假定它只需要保存文件名而不包括路径。对话框完成后,您可以通过调用GetPathName()
函数来获取路径。
OnNotify()
看起来是这样的
BOOL CImageImportDlg::OnNotify(WPARAM, LPARAM lp, LRESULT *pResult)
{
LPOFNOTIFY of = (LPOFNOTIFY) lp;
CString csTemp;
switch (of->hdr.code)
{
case CDN_FOLDERCHANGE:
// Once we get this notification our old subclassing of
// the SHELL window is lost, so we have to
// subclass it again. (Changing the folder causes a
// destroy and recreate of the SHELL window).
if (m_wndHook.GetSafeHwnd() != HWND(NULL))
m_wndHook.UnsubclassWindow();
m_wndHook.SubclassWindow(GetParent()->GetDlgItem(lst2)->GetSafeHwnd());
break;
}
*pResult = 0;
return FALSE;
}
正如您所猜到的,这正在拦截发送到对话框的WM_NOTIFY
消息。不要将其与CHookWnd
类中的OnNotify()
处理程序混淆。该类捕获发送到SHELLDLL_DefView
窗口的消息。对话框过程永远不会看到这些消息。此消息处理程序看到的是Windows内部发送的文件打开通用对话框的CDN_??
消息。我们唯一感兴趣的是CDN_FOLDERCHANGE
消息。如上所述,看到此消息意味着用户已导航到新目录,因此,SHELL窗口已被销毁并重新创建。因此,代码会检查我们是否已经子类化了窗口,如果我们已经子类化了,我们就取消子类化我们的钩子窗口,并将其子类化到新创建的SHELL窗口。
危险!
这是关于使用未公开功能的强制性警告。我在上面提到SHELLDLL_DefView
窗口有一个子ID为0x461,并暗示我使用Spy++找到了它。您的警惕性应该提高了。那是一个魔术常数,在未来的Windows版本中可能会改变。是的,它可能会。但是请考虑Microsoft会分发一个名为dlgs.h
的文件,其中包含各种通用对话框中窗口的许多常量。其中一些对话框自Windows 95以来就存在,并且常量没有改变,因此可以合理地猜测这些值在Microsoft那里是固定的。(事实上,那些注重细节的人可能会注意到上面子类化Shell窗口的那一行使用的是常量lst2
,而不是0x461,并且可能想知道它从何而来)。使用代码
将下载中的四个源文件添加到您的项目中。将以下行添加到stdafx.h
文件的末尾。#define countof(x) (sizeof(x) / sizeof(x[0]))
#include <GdiPlus.h>
using namespace Gdiplus;
#pragma comment(lib, "gdiplus.lib")
将此代码添加到您的应用程序初始化中// Initialize GDI+
GdiplusStartupInput gdiplusStartupInput;
GdiplusStartup(&m_gdiplusToken, &gdiplusStartupInput, NULL);
并将此代码添加到您的应用程序关闭时。GdiplusShutdown(m_gdiplusToken);
并记住在某处(可在应用程序初始化和关闭时访问)添加一个变量,声明为unsigned long m_gdiplusToken;
将user_dialog_template.rc
中的对话框模板合并到您的项目的.rc
文件中。合并模板后,您需要确保模板ID和两个静态控件ID已添加到您的项目的resource.h
文件中。然后,在您使用CFileDialog
的地方替换为CImageImportDlg
,编译后您应该可以开始使用了。示例项目演示了如何执行此操作。
发现并修复了一个bug
实际上,这根本不是我的代码中的bug,但它会被视为bug,所以由我来修复它。原始版本在Debug模式下,如果用户使用工具栏最右边的按钮更改视图类型,该类会触发断言。MFC(在CWnd
的OnCommand()
处理程序中)断言wParam
的高位字为0(命令来自菜单)或lParam
是一个有效的窗口句柄。出于某种原因,CFileDialog
不遵守此规则,MFC会断言。这是一个无害的错误,但调试版本断言会让人感到不安,所以我通过向CHookWnd
添加一个OnCommand()
处理程序来修复它,该处理程序将wParam
参数替换为0。此bug的产生是因为一旦我们使用MFC代码子类化了Shell窗口,它所有的消息,即使是我们不关心的,都会通过MFC处理程序。获取“常用位置”栏
您将在文章开头的截图中看到,对话框显示了“常用位置”栏。有几个人问过如何获得它。答案非常简单,它经常不出现的原因也很有趣。我将稍微扩展一下我在这篇文章的消息板上回复的一个消息。答案先。不要设置m_ofn.lStructSize
成员。只需接受CFileDialog
构造函数中设置的默认值。然后,如果您的平台支持“常用位置”栏,您就会得到它。如果不支持,则不会。现在是原因。
我见过的讨论扩展CFileDialog
类的示例大多数都在派生类的构造函数中包含一行,如下所示:
m_ofn.lStructSize = sizeof(OPENFILENAME);
乍一看,这似乎是合理的。我自己也经常这样做。但是让我们看一下CFileDialog
构造函数内部的代码。CFileDialog::CFileDialog(bunch of parameters)
{
// determine size of OPENFILENAME struct if dwSize is zero
if (dwSize == 0)
{
OSVERSIONINFO vi;
ZeroMemory(&vi, sizeof(OSVERSIONINFO));
vi.dwOSVersionInfoSize = sizeof(OSVERSIONINFO);
::GetVersionEx(&vi);
// if running under NT and version is >= 5
if (vi.dwPlatformId == VER_PLATFORM_WIN32_NT && vi.dwMajorVersion >= 5)
dwSize = sizeof(OPENFILENAME);
else
dwSize = OPENFILENAME_SIZE_VERSION_400;
}
// size of OPENFILENAME must be at least version 4
ASSERT(dwSize >= OPENFILENAME_SIZE_VERSION_400);
m_ofn.lStructSize = dwSize;
}
(我删除了几行不相关的代码)。构造函数会进行一些操作系统版本检查,并据此设置结构大小。最终,您的代码将调用Windows通用对话框代码,传递OPENFILENAME
结构。该代码会根据传递给它的各种参数(包括结构大小(可以被视为一种版本控制))来改变其行为。传递一个具有“旧”长度的结构,它会假设它正在处理一个“旧”应用程序并作出相应的反应。但是等等!如果是Windows 2000,CFileDialog
结构中的代码会复制我一直在做的事情,将sizeof(OPENFILENAME)
赋值给m_ofn.lStructSize
成员!嗯哼。然而,如果我在我的系统上,使用最新的Platform SDK,并且使用VS .NET 2003,我却没有得到“常用位置”栏。我进行了一些单步调试,发现MFC库为结构成员分配了一个大小0x58。然后,单步调试我的派生类构造函数(它当然在基类之后运行),结构大小被重置为0x4c。
看起来我的系统即使安装了最新的Platform SDK,也正在拾取一个较旧的头文件。*耸肩*。解决方法是在我的构造函数中不为结构大小赋值,而是让CFileDialog
为我完成。这也不是一个严重的解决方法。考虑到结构大小由基类设置,所以从不需要设置结构大小。
历史
2004年3月9日 - 初始版本
2004年3月10日 - 修复了一个ASSERT bug。
2004年3月12日 - 更改了从ListView控件获取文件名的at方式。
2004年3月23日 - 添加了一个含糊不清的解释,说明为什么“常用位置”栏有时不会出现。