移动端专项优化¶
移动端(Android / iOS / 鸿蒙)与桌面端有两个本质差异,SOUI 针对它们做了专门优化:
- 没有自己的消息循环(msgloop)。移动端进程由 JVM / ArkTS 运行时拉起,主循环在系统侧,业务代码无法像桌面
SHostDialog::DoModal那样"再拉起一套嵌套消息循环"来阻塞等待对话框返回。为此 SOUI 提供了SModalView(模态视图) 机制,在既有消息循环内实现模态效果。 - 交互是手指滑动,不是鼠标点击。为此
SPanel及其子类的滚动条增加了 fling(惯性滑动) 支持,并相应调整了子控件对WM_LBUTTONDOWN/WM_LBUTTONUP的响应方式。
桌面端可以照常使用 SHostDialog,但移动端工程请改用下面的 SModalView;fling 则对所有平台生效(桌面端用鼠标拖拽同样会触发),只是对手指滑动的体验提升最明显。
一、SModalView:替代 SHostDialog 的模态视图¶
为什么不直接用 SHostDialog¶
桌面端 SHostDialog::DoModal 的退出依赖它自己管理一个消息循环:
// SOUI/include/core/SHostDialog.h
STDMETHOD_(IMessageLoop *, GetMsgLoop)(THIS) OVERRIDE; // 对话框自己的消息循环
STDMETHOD_(INT_PTR, DoModal)(THIS_ HWND hParent, ...) OVERRIDE; // 内部 RunModalLoop 阻塞
SAutoRefPtr<IMessageLoop> m_MsgLoop; // 嵌套消息循环
在 Android / 鸿蒙上,进程由 Java / ArkTS 运行时创建,消息泵(见 移动端概述)运行在系统框架内部,业务侧无法再独立 RunModalLoop。一旦在移动端调用 DoModal,要么卡死、要么收不到事件。
SModalView 不依赖任何嵌套消息循环:它把"模态窗口"挂到既有的 SRootWindow 窗口树里,靠普通命中测试天然地把输入优先路由到最顶层模态层,因此能在移动端正常工作,同时桌面端也能用。
原理¶
涉及三个对象(见 SOUI/include/core/SModalViewSession.h):
SModalRoot:全屏遮罩窗口,铺满SRootWindow客户区。它叠在非模态窗口之上,所有鼠标/键盘命中测试优先命中它,从而实现"模态"拦截。点它的空白背景(即没有命中任何子窗口)会以IDCANCEL退出当前模态会话(quitOnClick属性控制)。SModalView:真正可见的"对话框"窗口,作为SModalRoot的子窗口存在。支持enterAnimation/exitAnimation,语义与SRootWindow的动画一致(用anim:前缀引用 XML 里定义的动画)。退出动画播完才真正销毁,所以能看到淡出/缩放效果。- 模态栈:
SHostWnd维护一个模态会话栈。会话可嵌套(弹窗上再弹窗),但必须按相同顺序结束。每个SModalRoot有一个唯一的ModalViewSessionID。
flowchart TB
RW[SRootWindow 窗口树]
RW --> N[非模态业务窗口]
RW --> MR1[SModalRoot #1 全屏遮罩]
MR1 --> MV1[SModalView 可见对话框]
MR1 -. 命中测试优先 .-> MR1
MV1 --> B1[按钮等子控件]
subgraph 嵌套示例
MR1 --> MR2[SModalRoot #2]
MR2 --> MV2[SModalView]
end
API¶
SHostWnd 提供两组入口:
// SOUI/include/core/SHostWnd.h
// 已有一个 SModalRoot 对象时
ModalViewSessionID BeginModalViewSession(SModalRoot* pView, SWindow* pRoot = NULL);
// 直接给布局资源名,内部自动创建 SModalRoot 并塞进一个 modalview
SModalRoot* BeginModalViewSession(LPCTSTR pszLayout, SWindow* pRoot = NULL);
BOOL EndModalViewSession(ModalViewSessionID sessionID = 0, int exitCode = 0);
ModalViewSessionID GetLastModalViewSessionID() const;
BeginModalViewSession返回唯一ModalViewSessionID;传入的窗口不能已经挂到别的父窗口上。如果传的不是SModalRoot,会被自动 reparent 进新建的SModalRoot。- 会话结束通过订阅
SModalRoot上的EventExitModalView事件来感知,事件携带exitCode:
// SOUI/include/event/SEvents.h
DEF_EVT(EventExitModalView, EVT_EXIT_MODALVIEW, on_exit_modal_view,{
int exitCode;
})
用法示例¶
C++ 侧(取自 demos/android-demo,与桌面 DoModal 的最大区别:Begin/End 不阻塞,结果通过事件回调回来):
// demos/android-demo/app/src/main/cpp/MainDlg.cpp
void CMainDlg::OnBtnModalInput() {
// 1) 创建 SModalRoot 并加载布局
SModalRoot *pModal = (SModalRoot*)SApplication::getSingleton()
.CreateWindowByName(SModalRoot::GetClassName());
pModal->InitFromResId(_T("layout:model_view"));
// 2) 进入模态会话(非阻塞,立即返回 session id)
ModalViewSessionID session_id = BeginModalViewSession(pModal);
// 3) 业务按钮以 EndModalViewSession 结束会话,并带回退出码
pModal->FindChildByName(_T("btn_ok"))->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs *e){
EndModalViewSession(session_id, IDOK);
return TRUE;
});
pModal->FindChildByName(_T("btn_cancel"))->SubscribeEvent(EventCmd::EventID, [=](IEvtArgs *e){
EndModalViewSession(session_id, IDCANCEL);
return TRUE;
});
// 4) 监听会话结束(动画播完后触发)
pModal->SubscribeEvent(EventExitModalView::EventID, [=](IEvtArgs *e){
EventExitModalView *e2 = sobj_cast<EventExitModalView>(e);
SLOGI() << "modal return:" << e2->exitCode;
return TRUE;
});
}
XML 布局(demos/android-demo/.../uires/xml/model_view.xml):
<modalroot layout="anchor" colorBkgnd="rgba(0,0,0,0.5)">
<modalview size="-2,-1" pos="0,0,6" offset="0,-1" layout="vbox" interval="10"
enterAnimation="anim:modal_in" exitAnimation="anim:modal_out">
<button size="-2,80" name="btn_ok" text="OK" skin="btn_primary"/>
<button size="-2,80" name="btn_cancel" text="Cancel"/>
</modalview>
</modalroot>
modalroot的colorBkgnd即半透明背景遮罩;quitOnClick="1"时点击遮罩区即IDCANCEL退出。modalview的enterAnimation/exitAnimation引用uires/xml中定义的 SOUI 动画资源。
和 SHostDialog 的对照
| 维度 | SHostDialog::DoModal |
SModalView |
|---|---|---|
| 消息循环 | 自己 RunModalLoop(嵌套) |
复用应用既有循环 |
| 调用方式 | 阻塞直到返回 INT_PTR |
Begin 不阻塞,结果走 EventExitModalView |
| 移动端可用 | ❌ 拉不起自己的 msgloop | ✅ |
| 动画 | 无内置进出场动画 | enter/exitAnimation |
二、滚动条 fling(惯性滑动)¶
SPanel 以及它的一切子类(SListCtrl、SListbox、STreeCtrl、滚动容器等)的滚动条都增加了 fling 支持:手指(或鼠标)快速滑动后松手,滚动不会立刻停,而是按松手瞬间的速度再做一段惯性减速滑动,符合移动端触摸习惯。
原理¶
实现位于 SOUI/src/core/SPanel.cpp:
- 拖拽滚动过程中记录最近一次移动的速度
m_fLastVelocityX / m_fLastVelocityY(按时间差估算)。 - 松手(
WM_LBUTTONUP)时,若距上次移动 < 100ms 且速度有效,调用StartFlingAnimation(fReleaseVX, fReleaseVY)。 StartFlingAnimation用SFloatAnimator(接入GetTimelineHandlersMgr()时间轴)从当前滚动位置动画到"按速度推算的目标位置",并在动画回调里ScrollToPos+OnFlingScroll。- fling 动画运行期间会消费左键相关消息以中止本次惯性:触摸
WM_LBUTTONDOWN或按住拖拽会立即StopFlingAnimation,从而让"手指一碰就停"。
sequenceDiagram
participant U as 用户手指
participant P as SPanel
participant A as SFloatAnimator
U->>P: 拖拽(LBUTTONDOWN/MOVE) 记录速度
U->>P: 松手(LBUTTONUP)
P->>P: StartFlingAnimation(速度)
P->>A: start()
loop 动画帧
A->>P: onAnimationUpdate → ScrollToPos
end
U->>P: 再次按下(LBUTTONDOWN)
P->>A: StopFlingAnimation(立即停)
涉及的关键成员/虚函数(SOUI/include/core/SPanel.h):
void StartFlingAnimation(float fVelocityX, float fVelocityY); // 启动惯性
void StopFlingAnimation(); // 停止
virtual void OnFlingScroll(); // fling 每帧回调(可重写做滚动条高亮等)
SAutoRefPtr<SFloatAnimator> m_pFlingAnimatorV; // 垂直惯性动画器
SAutoRefPtr<SFloatAnimator> m_pFlingAnimatorH; // 水平惯性动画器
带来的子控件消息处理变化(重要)¶
加入拖拽滚动 / fling 后,SPanel 的 WM_LBUTTONDOWN / WM_LBUTTONUP 消息处理优先服务于滚动(命中拖拽阈值就进入滚动、fling 进行中也要消费这些消息)。只有当一次按键不是滚动操作时,才会把"点击"转发给子控件。
源码逻辑(SOUI/src/core/SPanel.cpp:1346):
void SPanel::OnLButtonDown(UINT nFlags, CPoint pt) {
LRESULT lRet = 0;
if (HandleMouseDrag(WM_LBUTTONDOWN, nFlags, MAKELPARAM(pt.x, pt.y), lRet))
return; // 被拖拽/fling 拦截,不再下传
OnLButtonDownEx(nFlags, pt); // 非滚动:转给 Ex 虚函数
if (IsEnableDragMode()) StartDragPending(pt);
}
因此:
原来子控件直接重写
OnLButtonDown/OnLButtonUp来响应点击的做法,在SPanel体系下会被滚动逻辑"吃掉"。要正确响应点击,应当改为重写OnLButtonDownEx/OnLButtonUpEx两个虚函数(同系列还有OnMouseMoveEx)。
OnLButtonDownEx/OnLButtonUpEx/OnMouseMoveEx是SPanel上新增的virtual钩子,默认实现只是转发到基类(__baseCls::OnLButtonDown等)。- 你的控件继承
SPanel或其子类时,用 Ex 系列取代原本的OnLButtonDown/OnLButtonUp重写,即可在"非滚动"的那次按下/抬起里安全地做自己的点击逻辑;滚动手势仍由SPanel统一处理。
SOUI 内置控件已据此迁移:SListCtrl、SListbox、STreeCtrl 的点击处理都已改到 OnLButtonDownEx / OnLButtonUpEx(见它们的头文件及 SPanel.cpp 中的调用)。
迁移清单
如果你自己的控件类之前在 SPanel 派生体系里重写了:
OnLButtonDown→ 改为重写OnLButtonDownExOnLButtonUp→ 改为重写OnLButtonUpEx- (可选)
OnMouseMove→ 改为重写OnMouseMoveEx
否则在可滚动容器里,点击事件会被拖拽/fling 逻辑拦截而收不到。
小结¶
| 优化 | 解决什么问题 | 桌面端 | 移动端 |
|---|---|---|---|
SModalView |
移动端拉不起独立 msgloop,无法用 SHostDialog::DoModal |
可用(但推荐统一用 SModalView) |
✅ 必须使用 |
| 滚动条 fling | 手指滑动惯性、滚动手感 | 鼠标拖拽同样生效 | ✅ 体验最明显 |
子控件 On*Ex 钩子 |
让点击逻辑不被滚动拦截 | 需同步迁移 | 需同步迁移 |