跳转至

模态视图会话(ModalView)

概述

ModalView 是 SOUI 提供的一种现代化模态交互机制,用于替代传统的 SHostDialog::DoModal 模态对话框。它基于窗口栈实现,支持多层层叠、进出动画、背景点击关闭等特性,可在 Windows 和 Android 等所有支持的平台上使用。

核心概念

ModalView 由两个核心类组成:

  • SModalRoot:全屏遮罩容器,填充整个 SRootWindow 客户区,拦截所有鼠标/键盘消息。点击遮罩区域(子窗口外部)时自动以 IDCANCEL 关闭当前模态会话。
  • SModalView:实际可见的对话框区域容器,放置在 SModalRoot 内部。支持 enterAnimationexitAnimation 动画属性。

与传统模态对话框的对比

特性 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,它提供了更好的跨平台兼容性和更现代化的交互体验。

最佳实践

✅ 应该做的

  1. BeginModalViewSession 后立即绑定事件:确保控件已正确初始化
  2. 使用 sessionID 关闭:总是保存并使用返回的 sessionID,不要依赖 GetLastModalViewSessionID()
  3. 订阅 EventExitModalView:在退出事件中处理结果,确保逻辑完整
  4. 通过布局资源创建:优先使用 BeginModalViewSession(pszLayout) 重载
  5. 合理使用 quitOnClick:对于确认对话框设为 TRUE,对于需要强制操作的设为 FALSE

❌ 不应做的

  1. 不要创建后不显示SModalRoot 必须通过 BeginModalViewSession 添加到窗口树才可见
  2. 不要跳过会话 ID 检查:多层嵌套时必须严格后进先出
  3. 不要在退出动画完成前销毁窗口:退出动画会自动处理生命周期
  4. 不要忘记处理 EventExitModalView:否则可能导致内存泄漏或状态不正确
  5. 不要在 SModalRoot 外的子窗口上绑定 quitOnClick:该属性只对 SModalRoot 的背景区域生效

故障排查

常见问题

Q1: 模态视图不显示

  • 确认调用了 BeginModalViewSession 而不是仅创建 SModalRoot
  • 检查 SModalRoot 是否有正确的 SRootWindow 父级
  • 确认 SModalViewSModalRoot 的第一个子窗口

Q2: 触摸/点击事件无响应

  • 检查是否仍处于之前的模态会话中(使用 GetLastModalViewSessionID() 检查)
  • 确认 SModalRoot 已正确添加到窗口树
  • 查看是否有其他高层级窗口拦截了事件

Q3: 多层嵌套时无法关闭

  • EndModalViewSession(sessionID) 只能关闭栈顶的会话
  • 确保 sessionID 是当前栈顶的 ID
  • 如果不确定,可使用 EndModalViewSession(0, exitCode) 强制关闭栈顶

Q4: 退出动画未播放

  • 确认 XML 中定义了 enterAnimation / exitAnimation 属性
  • 检查动画引用名称和 anim: 前缀是否正确
  • 查看是否在 OnFinish 之前就调用了 DestroyWindow

Q5: 背景点击无法关闭

  • 确认 quitOnClick 属性为 TRUE(默认值)
  • 检查是否有子控件覆盖了整个 SModalRoot 区域(导致没有"背景"可点击)

总结

ModalView 为 SOUI 开发者提供了一套现代化、跨平台的模态交互解决方案:

  1. 简洁 APIBeginModalViewSession / EndModalViewSession 配合使用
  2. 动画支持enterAnimation / exitAnimation 属性实现流畅过渡
  3. 事件驱动EventExitModalView 事件通知退出结果
  4. 栈式管理:支持多层嵌套,自动维护后进先出的会话顺序
  5. 跨平台:在 Windows、Android 上行为完全一致
  6. 与控件无缝集成SModalRoot / SModalView 就是标准的 SWindow,可使用所有 SOUI 控件和布局

参考示例:cnchess-android(demos/cnchess-android) 中国象棋 Android 版本中包含了登录对话框、求和请求/应答、确认对话框等多种 ModalView 使用场景。