模态视图会话(ModalView)¶
概述¶
ModalView 是 SOUI 提供的一种现代化模态交互机制,用于替代传统的 SHostDialog::DoModal 模态对话框。它基于窗口栈实现,支持多层层叠、进出动画、背景点击关闭等特性,可在 Windows 和 Android 等所有支持的平台上使用。
核心概念¶
ModalView 由两个核心类组成:
SModalRoot:全屏遮罩容器,填充整个SRootWindow客户区,拦截所有鼠标/键盘消息。点击遮罩区域(子窗口外部)时自动以IDCANCEL关闭当前模态会话。SModalView:实际可见的对话框区域容器,放置在SModalRoot内部。支持enterAnimation和exitAnimation动画属性。
与传统模态对话框的对比¶
| 特性 | ModalView | SHostDialog::DoModal |
|---|---|---|
| 窗口创建 | 轻量,基于 SWindow 树 |
重量级,需要独立 HWND |
| 多层嵌套 | ✅ 支持栈式堆叠 | ❌ 通常不支持 |
| 进出动画 | ✅ enterAnimation/exitAnimation |
❌ 不支持 |
| 跨平台 | ✅ Windows/Android 一致 | ⚠️ 依赖平台对话框实现 |
| 背景遮罩 | ✅ 自动全屏遮罩 | ❌ 需手动实现 |
| 退出方式 | EndModalViewSession(sessionID, exitCode) |
EndDialog + 消息循环 |
| 结果传递 | EventExitModalView 事件 |
WM_COMMAND + EndDialog 返回值 |
架构设计¶
graph TB
subgraph "SRootWindow 客户区"
direction TB
subgraph "SModalRoot (全屏遮罩)"
direction TB
subgraph "SModalView (对话框区域)"
A[子控件们]
end
end
subgraph "正常窗口内容"
B[SButton]
C[SEdit]
end
end
"SModalRoot" -- "Z-order 置顶" --> "SModalView"
"SModalRoot" -- "拦截命中测试" --> "鼠标/键盘事件"
"SModalView" -- "enterAnimation" --> "淡入/缩放"
"SModalView" -- "exitAnimation" --> "淡出/缩放"
组件关系¶
sequenceDiagram
participant App as 应用代码
participant Host as SHostWnd
participant Root as SModalRoot
participant View as SModalView
participant Stack as m_modalRootStack
App->>Host: BeginModalViewSession(pModal)
Host->>Root: AssignSessionID()
Host->>Host: InitModalRoot(pModal)
Host->>Root: pRoot->InsertChild(pModal)
Root->>View: PlayEnterAnimation()
Host->>Stack: m_modalRootStack.AddTail(pModal)
Host-->>App: 返回 sessionID
Note over Root,View: 用户交互...
App->>Host: EndModalViewSession(sessionID, exitCode)
Host->>Stack: m_modalRootStack.RemoveTail()
Host->>Root: pModal->EndModalViewSession(callback, exitCode)
Root->>View: PlayExitAnimation()
View-->>Root: OnAnimationStop → OnFinish()
Root->>Root: FireExitCallback(exitCode)
Root->>Host: callback.OnModalViewExit(pModal)
Host->>Host: OnModalViewFinish(pModal) → Destroy
Host->>Host: InitModalRoot(栈中下一个)
核心 API¶
SHostWnd 方法¶
// 方式一:直接使用已创建的 SModalRoot 窗口
ModalViewSessionID BeginModalViewSession(SModalRoot* pView, SWindow* pRoot = NULL);
// 方式二:通过布局资源 ID 创建
SModalRoot* BeginModalViewSession(LPCTSTR pszLayout, SWindow* pRoot = NULL);
// 结束模态会话
BOOL EndModalViewSession(ModalViewSessionID sessionID = 0, int exitCode = 0);
// 获取最近一次模态会话 ID
ModalViewSessionID GetLastModalViewSessionID() const;
SModalRoot 方法¶
// 获取绑定的会话 ID
ModalViewSessionID GetSessionID() const;
// 获取第一个 SModalView 子窗口
SModalView* GetModalView() const;
// 触发会话退出(供 SHostWnd 内部调用)
void EndModalViewSession(IModalViewExitCallback* pCb, int exitCode);
SModalRoot 属性¶
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
quitOnClick |
bool | TRUE |
点击遮罩背景时是否自动关闭模态会话 |
SModalView 属性¶
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
enterAnimation |
string | 无 | 进入动画名称(在 XML 中通过 anim: 前缀引用) |
exitAnimation |
string | 无 | 退出动画名称 |
EventExitModalView 事件¶
模态会话结束时(退出动画完成后)触发,携带退出码:
// 订阅事件
pModal->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs* e) {
EventExitModalView* evt = sobj_cast<EventExitModalView>(e);
int exitCode = evt->exitCode;
// 处理退出逻辑...
return TRUE;
});
使用方法¶
基本流程¶
class CMainDlg : public SHostWnd
{
// ...
};
void CMainDlg::ShowMyModal()
{
// 方式一:通过布局资源创建
SModalRoot* pModal = BeginModalViewSession(_T("layout:dlg_my_modal"));
if (!pModal) return;
// 订阅退出事件,获取结果
pModal->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs* e) {
EventExitModalView* evt = sobj_cast<EventExitModalView>(e);
if (evt->exitCode == IDOK) {
// 用户点击了确定
} else {
// 用户取消
}
return TRUE;
});
// 绑定按钮事件
pModal->FindChildByName(L"btn_ok")->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs* e) {
EndModalViewSession(pModal->GetSessionID(), IDOK);
return TRUE;
});
pModal->FindChildByName(L"btn_cancel")->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs* e) {
EndModalViewSession(pModal->GetSessionID(), IDCANCEL);
return TRUE;
});
}
分步创建方式¶
当需要在显示前操作子控件时,手动创建 SModalRoot:
void CMainDlg::ShowMyModal()
{
// 手动创建 SModalRoot 并加载布局
SModalRoot* pModal = (SModalRoot*)SApplication::getSingleton()
->CreateWindowByName(SModalRoot::GetClassName());
pModal->InitFromResId(_T("layout:dlg_my_modal"));
// 在显示前修改控件
SEdit* pEdit = pModal->FindChildByName2<SEdit>(L"edit_name");
pEdit->SetWindowText(_T("Hello SOUI"));
// 启动模态会话
ModalViewSessionID sessionID = BeginModalViewSession(pModal);
if (sessionID == 0) {
pModal->Release();
return;
}
// ...绑定事件同基本流程...
}
使用窗口 ID 参数的 BeginModalViewSession¶
// 传入自定义 pRoot 指定模态视图的父窗口(默认使用 m_pRoot)
BeginModalViewSession(pModal, pSpecificRoot);
// 不传时使用 SHostWnd 的 m_pRoot 作为父窗口
BeginModalViewSession(pModal, NULL);
多层嵌套¶
ModalView 支持栈式嵌套,需要严格按后进先出顺序关闭:
void CMainDlg::ShowNestedModals()
{
// 第一层
SModalRoot* pModal1 = BeginModalViewSession(_T("layout:dlg_first"));
pModal1->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs* e) {
if (sobj_cast<EventExitModalView>(e)->exitCode == IDOK) {
// 第二层
SModalRoot* pModal2 = BeginModalViewSession(_T("layout:dlg_second"));
pModal2->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs* e2) {
// 第三层
// ...按后进先出顺序关闭
return TRUE;
});
}
return TRUE;
});
}
注意:
EndModalViewSession带有sessionID参数时,只允许关闭栈顶的会话。传入0时将强制关闭栈顶。
XML 资源定义¶
基本结构¶
<!-- 模态视图根容器(全屏遮罩) -->
<modalroot name="dlg_my_modal" quitOnClick="1">
<!-- 实际对话框内容 -->
<modalview enterAnimation="anim:modal_in" exitAnimation="anim:modal_out">
<anchoredlayout width="fill" height="wrap">
<button name="btn_ok" text="确定" />
<button name="btn_cancel" text="取消" />
</anchoredlayout>
</modalview>
</modalroot>
带动画的完整示例¶
<!-- 在 uires/values/anim.xml 中定义动画 -->
<animations>
<animation name="modal_in">
<alpha from="0" to="1" duration="200" />
<scale from="0.8" to="1.0" duration="200" pivot="center" />
</animation>
<animation name="modal_out">
<alpha from="1" to="0" duration="150" />
<scale from="1.0" to="0.8" duration="150" pivot="center" />
</animation>
</animations>
<!-- 在 uires/xml/ 布局中引用 -->
<modalroot name="dlg_login" quitOnClick="1">
<modalview enterAnimation="anim:modal_in" exitAnimation="anim:modal_out">
<anchoredlayout width="fill" height="wrap">
<edit name="edit_server" height="32" />
<edit name="edit_name" height="32" />
<button name="btn_login" text="登录" />
<button name="btn_cancel" text="取消" />
</anchoredlayout>
</modalview>
</modalroot>
布局 XML 文件示例¶
uires/xml/dlg_confirm_modal.xml:
<modalroot layout="anchor" colorBkgnd="@color/modal_mask">
<modalview size="-2,-1" pos="0,0,0" layout="vbox" colorBkgnd="@color/bg_modal" interval="0" padding="0,0,0,0" enterAnimation="anim:modal_up_in" exitAnimation="anim:modal_up_out">
<!-- 顶部金色装饰条 -->
<window size="-2,3" colorBkgnd="@color/gold"/>
<!-- 内容区 -->
<window size="-2,-1" layout="vbox" interval="12" padding="20,15,20,15">
<text name="txt_message" text="确定要执行此操作吗?" font="size:16" colorText="@color/text_light" gravity="center"/>
<window size="-2,-1" layout="hbox" interval="10" gravity="center">
<button name="btn_cancel" size="0,36" weight="1" text="取消" font="size:14" colorText="@color/text_cancel" colorBkgnd="@color/bg_edit" cursor="hand"/>
<button name="btn_ok" size="0,36" weight="1" text="确定" font="size:14,bold" skin="btn_primary" cursor="hand"/>
</window>
</window>
</modalview>
</modalroot>
对应 C++ 使用代码:
// cnesgame.cpp - 中国象棋示例
void CChessGame::OnBtnReqSurrender()
{
SModalRoot* pModal = (SModalRoot*)SApplication::getSingleton()
->CreateWindowByName(SModalRoot::GetClassName());
pModal->InitFromResId("layout:dlg_confirm_modal");
pModal->FindChildByName(L"txt_message")->SetWindowText(_T("确定要认输吗?"));
ModalViewSessionID sessionID = m_pMainDlg->BeginModalViewSession(pModal);
pModal->FindChildByName(L"btn_ok")->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs* e) {
m_pMainDlg->EndModalViewSession(sessionID, IDOK);
return TRUE;
});
pModal->FindChildByName(L"btn_cancel")->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs* e) {
m_pMainDlg->EndModalViewSession(sessionID, IDCANCEL);
return TRUE;
});
}
退出码约定¶
ModalView 没有预定义的退出码协议,应用可自由使用。常见约定:
| 退出码 | 含义 |
|---|---|
IDOK (1) |
操作成功/确认 |
IDCANCEL (2) |
用户取消/关闭 |
0 |
未指定(EndModalViewSession 默认值) |
| 自定义值 | 应用自定义语义 |
常见模式¶
1. 确认对话框¶
void CMainDlg::ShowConfirm(const CString& msg,
std::function<void(bool)> onResult)
{
SModalRoot* pModal = BeginModalViewSession(_T("layout:dlg_confirm_modal"));
if (!pModal) { onResult(false); return; }
pModal->FindChildByName(L"txt_message")->SetWindowText(msg);
pModal->FindChildByName(L"btn_ok")->SubscribeEvent(
EventCmd::EventID, [=](IEvtArgs*) {
EndModalViewSession(pModal->GetSessionID(), IDOK);
return TRUE;
});
pModal->FindChildByName(L"btn_cancel")->SubscribeEvent(
EventCmd::EventID, [=](IEvtArgs*) {
EndModalViewSession(pModal->GetSessionID(), IDCANCEL);
return TRUE;
});
pModal->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs* e) {
auto* evt = sobj_cast<EventExitModalView>(e);
onResult(evt->exitCode == IDOK);
return TRUE;
});
}
// 使用
ShowConfirm(_T("确定要删除吗?"), [this](bool confirmed) {
if (confirmed) DeleteItem();
});
2. 输入对话框¶
void CMainDlg::ShowInputDialog(const CString& title,
const CString& defaultValue,
std::function<void(const CString&)> onResult)
{
SModalRoot* pModal = BeginModalViewSession(_T("layout:dlg_input_modal"));
if (!pModal) return;
pModal->FindChildByName(L"txt_title")->SetWindowText(title);
SEdit* pEdit = pModal->FindChildByName2<SEdit>(L"edit_input");
pEdit->SetWindowText(defaultValue);
auto submit = [=]() {
CString value = pEdit->GetWindowText();
EndModalViewSession(pModal->GetSessionID(), IDOK);
// 在退出时保存值
pModal->SetProperty(L"result_value", value);
};
pModal->FindChildByName(L"btn_ok")->SubscribeEvent(EventCmd::EventID,
[=](IEvtArgs*) { submit(); return TRUE; });
pModal->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs* e) {
auto* evt = sobj_cast<EventExitModalView>(e);
if (evt->exitCode == IDOK) {
CString value;
pModal->GetProperty(L"result_value", value);
onResult(value);
}
return TRUE;
});
}
3. 登录对话框(cnchess-android 示例)¶
// MainDlg.cpp - cnchess-android
void CMainDlg::ShowLogin()
{
SModalRoot* pModal = (SModalRoot*)SApplication::getSingleton()
->CreateWindowByName(SModalRoot::GetClassName());
pModal->InitFromResId(_T("layout:dlg_login_modal"));
SEdit* pEdtSvr = pModal->FindChildByName2<SEdit>(L"edit_server");
SEdit* pEdtName = pModal->FindChildByName2<SEdit>(L"edit_name");
// 加载配置
SXmlDoc doc;
if (doc.load_file(cfgPath)) {
SXmlNode node = doc.root().child(L"config");
pEdtSvr->SetWindowText(S_CW2T(node.attribute(L"svr").as_string()));
pEdtName->SetWindowText(S_CW2T(node.attribute(L"name").as_string()));
}
ModalViewSessionID sessionID = BeginModalViewSession(pModal);
// 登录按钮
pModal->FindChildByName(L"btn_login")->SubscribeEvent(EventCmd::EventID,
[=](IEvtArgs*) {
// 保存配置...
EndModalViewSession(sessionID, IDOK);
return TRUE;
});
// 退出事件
pModal->SubscribeEvent(EventExitModalView::EventID,
[=](IEvtArgs* e) {
auto* e2 = sobj_cast<EventExitModalView>(e);
if (e2->exitCode == IDOK) {
// 登录成功 → 初始化大厅
InitLobby(pEdtSvr->GetWindowText(), pEdtName->GetWindowText());
} else {
// 登录取消 → 关闭应用
DestroyWindow();
}
return TRUE;
});
}
4. 求和请求/应答对话框(cnchess-android 示例)¶
// ChessGame.cpp - 求和请求
void CChessGame::OnBtnReqPeace()
{
SModalRoot* pModal = (SModalRoot*)SApplication::getSingleton()
->CreateWindowByName(SModalRoot::GetClassName());
pModal->InitFromResId("layout:dlg_peace_req_modal");
SEdit* pEdtDesc = pModal->FindChildByName2<SEdit>(L"edit_desc");
ModalViewSessionID sessionID = m_pMainDlg->BeginModalViewSession(pModal);
pModal->FindChildByName(L"btn_ok")->SubscribeEvent(EventCmd::EventID,
[=](IEvtArgs*) {
MSG_PEACE msg;
msg.iIndex = m_iSelfIndex;
SStringA strDesc = S_CT2A(pEdtDesc->GetWindowText(), CP_UTF8);
strcpy_s(msg.szMsg, 100, strDesc);
wsSendMsg(MSG_REQ_PEACE, &msg, sizeof(msg));
PlayTip(_T("已发送求和请求"));
m_pMainDlg->EndModalViewSession(sessionID, IDOK);
return TRUE;
});
pModal->FindChildByName(L"btn_cancel")->SubscribeEvent(EventCmd::EventID,
[=](IEvtArgs*) {
m_pMainDlg->EndModalViewSession(sessionID, IDCANCEL);
return TRUE;
});
}
// ChessGame.cpp - 求和应答
void CChessGame::OnPeaceReq(MSG_PEACE* pPeace)
{
SModalRoot* pModal = (SModalRoot*)SApplication::getSingleton()
->CreateWindowByName(SModalRoot::GetClassName());
pModal->InitFromResId("layout:dlg_peace_ack_modal");
SEdit* pEdtDesc = pModal->FindChildByName2<SEdit>(L"edit_desc");
pEdtDesc->SetWindowText(S_CA2T(pPeace->szMsg, CP_UTF8));
ModalViewSessionID sessionID = m_pMainDlg->BeginModalViewSession(pModal);
pModal->FindChildByName(L"btn_ok")->SubscribeEvent(EventCmd::EventID,
[=](IEvtArgs*) {
m_pMainDlg->EndModalViewSession(sessionID, IDOK);
return TRUE;
});
pModal->FindChildByName(L"btn_cancel")->SubscribeEvent(EventCmd::EventID,
[=](IEvtArgs*) {
m_pMainDlg->EndModalViewSession(sessionID, IDCANCEL);
return TRUE;
});
// 退出事件:根据结果发送应答
pModal->SubscribeEvent(EventExitModalView::EventID,
[=](IEvtArgs* e) {
auto* e2 = sobj_cast<EventExitModalView>(e);
MSG_PEACE msg;
msg.iIndex = m_iSelfIndex;
msg.nResult = (e2->exitCode == IDOK) ? 1 : 0;
wsSendMsg(MSG_ACK_PEACE, &msg, sizeof(msg));
return TRUE;
});
}
键盘与焦点行为¶
ESC 键处理¶
SModalRoot::OnKeyDown 自动拦截 VK_ESCAPE 键:
void SModalRoot::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
{
if (nChar == VK_ESCAPE) {
// 自动以 IDCANCEL 关闭当前模态会话
SRootWindow* pRoot = sobj_cast<SRootWindow>(GetRoot());
pRoot->GetHostWnd()->EndModalViewSession(m_sessionID, IDCANCEL);
} else {
SetMsgHandled(FALSE); // 交给子控件处理
}
}
焦点管理¶
InitModalRoot 会自动:
1. 释放当前鼠标捕获(ReleaseCapture)
2. 将焦点设置到 SModalView 的第一个可聚焦子控件
3. 请求重新布局并播放进入动画
多窗口切换¶
当内层模态会话结束后,SHostWnd::OnModalViewFinish 会自动调用 InitModalRoot 恢复下一层模态窗口的焦点和动画。
与 SHostDialog 的关系¶
ModalView 和 SHostDialog 是两种独立的模态机制,可以在同一应用中混合使用:
| 场景 | 推荐方案 |
|---|---|
| 主窗口内的现代模态 UI | ModalView |
| 需要独立窗口句柄的对话框 | SHostDialog |
| 跨平台一致的模态体验 | ModalView |
| 需要与 Win32 API 互操作 | SHostDialog |
在现代 SOUI 应用中,推荐优先使用 ModalView,它提供了更好的跨平台兼容性和更现代化的交互体验。
最佳实践¶
✅ 应该做的¶
- 在
BeginModalViewSession后立即绑定事件:确保控件已正确初始化 - 使用
sessionID关闭:总是保存并使用返回的sessionID,不要依赖GetLastModalViewSessionID() - 订阅
EventExitModalView:在退出事件中处理结果,确保逻辑完整 - 通过布局资源创建:优先使用
BeginModalViewSession(pszLayout)重载 - 合理使用
quitOnClick:对于确认对话框设为TRUE,对于需要强制操作的设为FALSE
❌ 不应做的¶
- 不要创建后不显示:
SModalRoot必须通过BeginModalViewSession添加到窗口树才可见 - 不要跳过会话 ID 检查:多层嵌套时必须严格后进先出
- 不要在退出动画完成前销毁窗口:退出动画会自动处理生命周期
- 不要忘记处理
EventExitModalView:否则可能导致内存泄漏或状态不正确 - 不要在
SModalRoot外的子窗口上绑定quitOnClick:该属性只对SModalRoot的背景区域生效
故障排查¶
常见问题¶
Q1: 模态视图不显示¶
- 确认调用了
BeginModalViewSession而不是仅创建SModalRoot - 检查
SModalRoot是否有正确的SRootWindow父级 - 确认
SModalView是SModalRoot的第一个子窗口
Q2: 触摸/点击事件无响应¶
- 检查是否仍处于之前的模态会话中(使用
GetLastModalViewSessionID()检查) - 确认
SModalRoot已正确添加到窗口树 - 查看是否有其他高层级窗口拦截了事件
Q3: 多层嵌套时无法关闭¶
EndModalViewSession(sessionID)只能关闭栈顶的会话- 确保
sessionID是当前栈顶的 ID - 如果不确定,可使用
EndModalViewSession(0, exitCode)强制关闭栈顶
Q4: 退出动画未播放¶
- 确认 XML 中定义了
enterAnimation/exitAnimation属性 - 检查动画引用名称和
anim:前缀是否正确 - 查看是否在
OnFinish之前就调用了DestroyWindow
Q5: 背景点击无法关闭¶
- 确认
quitOnClick属性为TRUE(默认值) - 检查是否有子控件覆盖了整个
SModalRoot区域(导致没有"背景"可点击)
总结¶
ModalView 为 SOUI 开发者提供了一套现代化、跨平台的模态交互解决方案:
- 简洁 API:
BeginModalViewSession/EndModalViewSession配合使用 - 动画支持:
enterAnimation/exitAnimation属性实现流畅过渡 - 事件驱动:
EventExitModalView事件通知退出结果 - 栈式管理:支持多层嵌套,自动维护后进先出的会话顺序
- 跨平台:在 Windows、Android 上行为完全一致
- 与控件无缝集成:
SModalRoot/SModalView就是标准的SWindow,可使用所有 SOUI 控件和布局
参考示例:cnchess-android(demos/cnchess-android) 中国象棋 Android 版本中包含了登录对话框、求和请求/应答、确认对话框等多种 ModalView 使用场景。