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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (39投票s)

2004年3月9日

CPOL

13分钟阅读

viewsIcon

235063

downloadIcon

7780

使用文件打开对话框浏览您的图像,并查看您选择的内容。

Sample Image - imagepreviewdialog.jpg

引言

我想将各种格式的图像导入我的应用程序。这些图像可能来自数码相机或扫描仪,并且不一定有容易理解的文件名。看看上面示例图片中的文件名。它并不是很直观。

因此,我显然需要在文件导入对话框中显示预览图像。当用户单击列表视图中的文件时,预览控件会更新以显示图像。我自然会去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(在CWndOnCommand()处理程序中)断言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日 - 添加了一个含糊不清的解释,说明为什么“常用位置”栏有时不会出现。

© . All rights reserved.