MFC分割窗口(CSplitterWnd)与选项卡视图(CTabView)的混合使用
本文提供了在主框架和选项卡视图中建立分割窗口,在分割窗口中建立选项卡视图并实现视图切换,这样分割窗口和选项卡视图就能循环嵌套使用了,本Demo项目的源码在Github上可供下载:https://github.com/fenggwsx/SplitterWndTabViewCombined-Demo
新建解决方案
为了方便演示,我在创建MFC项目时,选择的应用程序类型为单文档,项目样式为MFC standard:
创建完成后,首先在头文件framework.h
中包含头文件afxcview.h
,因为等下用到的CTreeView
在这个头文件里,接着在pch.h
中包含Demo项目下的头文件MainFrm.h
,然后编译运行,界面如图所示:
在主框架中创建分割窗口
先添加两个类,分别为CIndexTreeView
(继承自CTreeView
)和CView1
(继承自CView
),CIndexTreeView
用来做索引的,为后续视图切换做准备,CView1
是用来看显示效果的,为了让它能够易于辨识,我们需要在该类中写入一些绘图代码
先来写一下CIndexTreeView
中的代码,第一步是要让该类具有动态创建的功能,所以在头文件中添加如下代码 :
protected:
CIndexTreeView() noexcept;
DECLARE_DYNCREATE(CIndexTreeView)
在源文件CIndexTreeView.cpp
中添加如下代码:
IMPLEMENT_DYNCREATE(CIndexTreeView, CTreeView)
第二步,打开类向导,响应WM_CREATE
和TVN_SELCHANGED
消息,重写虚函数PreCreateWindow
第三步,在OnCreate
函数中写入如下代码:
int CIndexTreeView::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CTreeView::OnCreate(lpCreateStruct) == -1)
return -1;
TVINSERTSTRUCT tvInsert;
HTREEITEM hRootItem;
tvInsert.hInsertAfter = NULL;
tvInsert.hParent = TVI_ROOT;
tvInsert.item.mask = LVFIF_TEXT;
tvInsert.item.pszText = _T("Root");
hRootItem = GetTreeCtrl().InsertItem(&tvInsert);
GetTreeCtrl().InsertItem(_T("Node1"), hRootItem);
GetTreeCtrl().InsertItem(_T("Node2"), hRootItem);
GetTreeCtrl().Expand(hRootItem, TVE_EXPAND);
return 0;
}
这样,我们已经将CIndexTreeView
的节点都建立好了
第四步,在PreCreateWindow
函数中写入如下代码:
BOOL CIndexTreeView::PreCreateWindow(CREATESTRUCT& cs)
{
cs.style |= TVS_SHOWSELALWAYS | TVS_HASLINES | TVS_LINESATROOT | TVS_HASBUTTONS;
return CTreeView::PreCreateWindow(cs);
}
这些代码是为了修改CIndexTreeView
的一些样式,所以这一步不是必须的
接着写CView1
中的代码,第一步同样是要让它有动态创建的功能,代码与CIndexTreeView
中的类似,只需要将其中的名称改为相应的CView1
中的名称
第二步是要重写纯虚函数OnDraw
,因为是纯虚函数,所以必须重写,在函数中写入如下代码:
void CView1::OnDraw(CDC* pDC)
{
CRect rect;
GetClientRect(&rect);
pDC->DrawText(CString(GetThisClass()->m_lpszClassName), &rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
这些绘图命令会在视图的中央绘制出视图类的类名称
然后写CMainFrame
中的代码,第一步是在CMainFrame
类的头文件MainFrm.h
中声明成员变量:
protected:
CSplitterWnd m_wndSplitterWnd;
第二步,重写虚函数OnCreateClient
,代码如下,,包含相应头文件(CIndexTreeView
和CView1
):
BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext)
{
m_wndSplitterWnd.CreateStatic(this, 1, 2);
m_wndSplitterWnd.CreateView(0, 0, RUNTIME_CLASS(CIndexTreeView), CSize(200, 0), pContext);
m_wndSplitterWnd.CreateView(0, 1, RUNTIME_CLASS(CView1), CSize(0, 0), pContext);
return TRUE;
}
编译运行,可以看到如下界面,界面被分成了左右两块区域,左边是CIndexTreeView
,右边是CView1
:
创建选项卡视图
首先我们要新建视图CView2
,与CView1
相同,可以将CView1
中的代码复制过来,更改类名即可
接下来我们要创建选项卡视图,添加类CMyTabView
继承自CTabView
(因为只有一个选项卡视图,所以不用下标)
第一步,同样是要让CMyTabView
支持动态创建,这里不再赘述
第二步,响应WM_CREATE
消息,在OnCreate
函数中写入如下代码,包含相应头文件(View2.h
):
int CMyTabView::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CTabView::OnCreate(lpCreateStruct) == -1)
return -1;
GetTabControl().SetLocation(CMFCTabCtrl::LOCATION_TOP);
GetTabControl().ModifyTabStyle(CMFCTabCtrl::STYLE_FLAT);
AddView(RUNTIME_CLASS(CView2), CString(RUNTIME_CLASS(CView2)->m_lpszClassName));
return 0;
}
实现分割窗口的视图切换
首先在CMainFrame
中添加函数Switch
:
public:
void Switch(int nIndex);
接着在Switch
函数中写入如下代码:
void CMainFrame::Switch(int nIndex)
{
switch (nIndex)
{
case 0:
m_wndSplitterWnd.DeleteView(0, 1);
m_wndSplitterWnd.CreateView(0, 1, RUNTIME_CLASS(CView1), CSize(0, 0), NULL);
break;
case 1:
m_wndSplitterWnd.DeleteView(0, 1);
m_wndSplitterWnd.CreateView(0, 1, RUNTIME_CLASS(CMyTabView), CSize(0, 0), NULL);
break;
}
m_wndSplitterWnd.RecalcLayout();
}
然后在CIndexTreeView
的OnTvnSelchanged
函数中写入代码:
void CIndexTreeView::OnTvnSelchanged(NMHDR* pNMHDR, LRESULT* pResult)
{
LPNMTREEVIEW pNMTreeView = reinterpret_cast<LPNMTREEVIEW>(pNMHDR);
HTREEITEM hRootItem = GetTreeCtrl().GetRootItem();
HTREEITEM hCurItem = pNMTreeView->itemNew.hItem;
if (hCurItem != hRootItem)
{
int nIndex = 0;
HTREEITEM hItem = GetTreeCtrl().GetChildItem(hRootItem);
while (hItem)
{
if (hItem == hCurItem)
break;
hItem = GetTreeCtrl().GetNextSiblingItem(hItem);
nIndex++;
}
CMainFrame* pFrame = DYNAMIC_DOWNCAST(CMainFrame, AfxGetMainWnd());
if (pFrame != NULL)
{
pFrame->Switch(nIndex);
pFrame->SetActiveView(this);
}
}
*pResult = 0;
}
最后编译运行,点击左边目录树上的Node2
节点,可以看到如下界面:
改进视图切换的方式
可以看到,在CMainFrame
的Switch
函数中,我们是通过删除分割窗格中原有的视图然后重新建立(推倒重建)的方法来实现视图的切换,但是当视图中要显示大量数据时,使用这种方法可能会导致卡顿的问题,所以我们可以使用另一种策略,通过显示和隐藏达到视图切换的目的,当然原来这种推倒重建的方法在数据量少的情况下是没有问题的
首先我们会发现,CSplitterWnd
中没有绑定视图的操作,我们只能通过调用它的CreateView
来创建视图,然而在调用时,我们只能通过RUNTIME_CLASS(class_name)
告诉它要创建的视图类型,它会去新建一个视图,对于我们已有的视图,是无法直接绑定上去的
其次,CSplitterWnd
的每一个窗格中只支持一个视图,如果将两个视图建在同一个窗格中程序就会报错
于是我通过分析CSplitterWnd
中GetPane
函数的源码明白了CSplitterWnd
运作机理,找到了解决方案,以下是GetPane
函数的源码:
CWnd* CSplitterWnd::GetPane(int row, int col) const
{
ASSERT_VALID(this);
CWnd* pView = GetDlgItem(IdFromRowCol(row, col));
ASSERT(pView != NULL); // panes can be a CWnd, but are usually CViews
return pView;
}
可以看到,GetPane
函数仅仅是通过GetDlgItem
来获取窗口指针的,所以窗口的ID号决定了窗口所在的位置,而同一个ID号有多个窗口会导致GetDlgItem
返回NULL
,进而引发程序报错
再来看看CSplitterWnd
中IdFromRowCol
的源码:
int CSplitterWnd::IdFromRowCol(int row, int col) const
{
ASSERT_VALID(this);
ASSERT(row >= 0);
ASSERT(row < m_nRows);
ASSERT(col >= 0);
ASSERT(col < m_nCols);
return AFX_IDW_PANE_FIRST + row * 16 + col;
}
#define AFX_IDW_PANE_FIRST 0xE900 // first pane (256 max)
#define AFX_IDW_PANE_LAST 0xE9ff
可以看到,CSplitterWnd
中窗口的ID号,是从0xE900到0xE9ff,共256个,这也是CSplitterWnd
的窗口分割最多支持16行16列的原因,了解了CSplitterWnd
的工作方式,我们就可以通过改变视图的ID号和ShowWindow
函数来实现显示和隐藏了
首先我们要找一个0xE900到0xE9ff之外的ID号,这里直接选择0xFFFF
声明两个视图类的指针作为CMainFrame
的成员变量(这样我们就可以对视图进行管理了):
protected:
CView1* m_pView1;
CMyTabView* m_pMyTabView;
修改CMainFrame
中的OnCreateClient
函数:
BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext)
{
m_wndSplitterWnd.CreateStatic(this, 1, 2);
m_wndSplitterWnd.CreateView(0, 0, RUNTIME_CLASS(CIndexTreeView), CSize(200, 0), pContext);
m_pView1 = DYNAMIC_DOWNCAST(CView1, RUNTIME_CLASS(CView1)->CreateObject());
m_pMyTabView = DYNAMIC_DOWNCAST(CMyTabView, RUNTIME_CLASS(CMyTabView)->CreateObject());
m_pView1->Create(NULL, NULL, WS_CHILD,
CRect(0, 0, 0, 0), &m_wndSplitterWnd, 0xFFFF, pContext);
m_pMyTabView->Create(NULL, NULL, WS_CHILD,
CRect(0, 0, 0, 0), &m_wndSplitterWnd, 0xFFFF, pContext);
Switch(0);
return TRUE;
}
注意Switch(0);
语句不能漏掉,不然没有一个视图的ID是m_wndSplitterWnd.IdFromRowCol(0,1)
会导致分割窗口找不到ID号所对应的窗口而出错
修改Switch
函数:
void CMainFrame::Switch(int nIndex)
{
switch (nIndex)
{
case 0:
::SetWindowLong(m_pView1->m_hWnd, GWL_ID, m_wndSplitterWnd.IdFromRowCol(0,1));
m_pView1->ShowWindow(SW_SHOW);
::SetWindowLong(m_pMyTabView->m_hWnd, GWL_ID, 0xFFFF);
m_pMyTabView->ShowWindow(SW_HIDE);
break;
case 1:
::SetWindowLong(m_pView1->m_hWnd, GWL_ID, 0xFFFF);
m_pView1->ShowWindow(SW_HIDE);
::SetWindowLong(m_pMyTabView->m_hWnd, GWL_ID, m_wndSplitterWnd.IdFromRowCol(0, 1));
m_pMyTabView->ShowWindow(SW_SHOW);
break;
}
m_wndSplitterWnd.RecalcLayout();
}
重新编译运行,可以看到实现了同样的切换效果
在选项卡视图中创建分割窗口
首先我们要新建视图CView3
,这个视图中代码的结构也可以从CView1
中复制过来,但是要删除OnDraw
中的代码(不要删除函数的声明与定义,因为OnDraw
是纯虚函数)
接着修改CMyTabView
的OnCreate
函数:
int CMyTabView::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CTabView::OnCreate(lpCreateStruct) == -1)
return -1;
GetTabControl().SetLocation(CMFCTabCtrl::LOCATION_TOP);
GetTabControl().ModifyTabStyle(CMFCTabCtrl::STYLE_FLAT);
CCreateContext context;
context.m_pCurrentDoc = GetDocument();
context.m_pCurrentFrame = NULL;
context.m_pLastView = NULL;
context.m_pNewDocTemplate = NULL;
context.m_pNewViewClass = NULL;
AddView(RUNTIME_CLASS(CView2), CString(RUNTIME_CLASS(CView2)->m_lpszClassName), -1, &context);
AddView(RUNTIME_CLASS(CView3), CString(RUNTIME_CLASS(CView3)->m_lpszClassName), -1, &context);
return 0;
}
这里注意到,我新建了一个CCreateContext
并在AddView
的第四个参数中使用,这是通过分析AddView
源码得来的,以下是部分AddView
源码:
CView* pView = DYNAMIC_DOWNCAST(CView, pViewClass->CreateObject());
ASSERT_VALID(pView);
if (!pView->Create(NULL, _T(""), WS_CHILD | WS_VISIBLE, CRect(0, 0, 0, 0), &m_wndTabs, (UINT) -1, pContext))
{
TRACE1("CTabView:Failed to create view '%s'\n", pViewClass->m_lpszClassName);
return -1;
}
CDocument* pDoc = GetDocument();
if (pDoc != NULL)
{
ASSERT_VALID(pDoc);
BOOL bFound = FALSE;
for (POSITION pos = pDoc->GetFirstViewPosition(); !bFound && pos != NULL;)
{
if (pDoc->GetNextView(pos) == pView)
{
bFound = TRUE;
}
}
if (!bFound)
{
pDoc->AddView(pView);
}
}
可以看到,AddView
函数先使用了CreateObject
创建对象,然后用Create
函数创建了视图,最后去CDocument
里面寻找类是否绑定了文档,如果没有则进行绑定,这个过程的确符合构建的一般顺序,然而我们在调用Create
函数的时候却触发了WM_CREATE
消息,导致被创建的视图在调用Create
函数后先要响应WM_CREATE
消息,然后进行文档绑定,但是在被创建的类CView3
中,在响应WM_CREATE
消息时需要创建分割窗口,还要创建分割窗口中的视图,然而在这一创建过程中,CView3
的GetDocument
函数将返回NULL
,导致文档类指针无法继续向子窗口传递,所以我使用了CCreateContext
结构体,在调用Create
函数时直接将文档指针传入,从而使CView3
在创建子窗口时能继续传递文档指针
然后为CView3
响应WM_CREATE
消息,在OnCreate
函数中写入如下代码:
int CView3::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CView::OnCreate(lpCreateStruct) == -1)
return -1;
CCreateContext context;
context.m_pCurrentDoc = GetDocument();
context.m_pCurrentFrame = NULL;
context.m_pLastView = NULL;
context.m_pNewDocTemplate = NULL;
context.m_pNewViewClass = NULL;
m_wndSplitterWnd.CreateStatic(this, 2, 1);
m_wndSplitterWnd.CreateView(0, 0, RUNTIME_CLASS(CView1), CSize(0, 0), &context);
m_wndSplitterWnd.CreateView(1, 0, RUNTIME_CLASS(CView2), CSize(0, 0), &context);
return 0;
}
保险起见,仍然使用CCreateContext
传递文档指针,这里再次使用了CView1
和CView2
,其实应该使用另外视图的,为了减少大量的重复代码,重复使用了这两个视图
然后为CView3
响应WM_SIZE
消息,在OnSize
函数中写入如下代码:
void CView3::OnSize(UINT nType, int cx, int cy)
{
CView::OnSize(nType, cx, cy);
CRect rect;
GetClientRect(&rect);
if (m_wndSplitterWnd.GetSafeHwnd() != NULL)
{
m_wndSplitterWnd.MoveWindow(&rect);
m_wndSplitterWnd.SetRowInfo(0, cy / 2, 0);
m_wndSplitterWnd.RecalcLayout();
}
}
这样实现了两个子视图平分分割窗口的功能
最后编译运行,点击左边目录树上的Node2
节点,在点击选项卡上的CView3
选项,可以看到如下界面:
总结
本文给出了分割窗口(CSplitterWnd)与选项卡视图(CTabView)相互建立的方法,同时给出了两种视图切换的方式,这样一来,我们可以不停地建立选项卡,分割视图,再建立选项卡,循环往复(只要你愿意这么做)