张洋 3 weeks ago
parent
commit
d0d04353db

+ 52 - 4
DuiLib/Control/UIScrollBar.cpp

@@ -538,6 +538,42 @@ namespace DuiLib
 		}
 	}
 
+	// [触屏] 手指容差命中测试(详见 UIScrollBar.h 声明处的说明)。
+	// 逻辑:
+	//   1) 先算 pt 相对滚动条自身 rect 的“外溢距离”,任一方向外溢超过 n_slop 直接判否
+	//      (避免把离滚动条很远的点击误判为抓滑块)。
+	//   2) 把 pt 收进滚动条 rect(垂直滚动条即只收敛 x)——等价于手指横向按偏一点也算命中。
+	//   3) 判断收敛后的点是否落在滑块 rect 外扩 n_slop 后的范围内(再夹回滚动条 rect)。
+	bool CScrollBarUI::IsThumbHit(POINT pt, int n_slop) const
+	{
+		if( !IsVisible() || !IsEnabled() ) return false;
+
+		RECT rcBar = m_rcItem;
+
+		int nOutX = 0;
+		int nOutY = 0;
+		if( pt.x < rcBar.left )       nOutX = rcBar.left - pt.x;
+		else if( pt.x > rcBar.right ) nOutX = pt.x - rcBar.right;
+		if( pt.y < rcBar.top )        nOutY = rcBar.top - pt.y;
+		else if( pt.y > rcBar.bottom )nOutY = pt.y - rcBar.bottom;
+		if( nOutX > n_slop || nOutY > n_slop ) return false;
+
+		POINT ptSnap = pt;
+		if( ptSnap.x < rcBar.left )  ptSnap.x = rcBar.left;
+		if( ptSnap.x > rcBar.right ) ptSnap.x = rcBar.right;
+		if( ptSnap.y < rcBar.top )   ptSnap.y = rcBar.top;
+		if( ptSnap.y > rcBar.bottom )ptSnap.y = rcBar.bottom;
+
+		RECT rcThumbHit = m_rcThumb;
+		::InflateRect(&rcThumbHit, n_slop, n_slop);
+		if( rcThumbHit.left   < rcBar.left )   rcThumbHit.left   = rcBar.left;
+		if( rcThumbHit.right  > rcBar.right )  rcThumbHit.right  = rcBar.right;
+		if( rcThumbHit.top    < rcBar.top )    rcThumbHit.top    = rcBar.top;
+		if( rcThumbHit.bottom > rcBar.bottom ) rcThumbHit.bottom = rcBar.bottom;
+
+		return ::PtInRect(&rcThumbHit, ptSnap) != FALSE;
+	}
+
 	void CScrollBarUI::DoEvent(TEventUI& event)
 	{
 		if( !IsMouseEnabled() && event.Type > UIEVENT__MOUSEBEGIN && event.Type < UIEVENT__MOUSEEND ) {
@@ -554,13 +590,25 @@ namespace DuiLib
 		{
 			return;
 		}
-		if( event.Type == UIEVENT_BUTTONDOWN || event.Type == UIEVENT_DBLCLICK )
+		// 触屏支持:把原生触摸事件与对应鼠标事件等价处理(TOUCHDOWN→BUTTONDOWN、TOUCHMOVE→MOUSEMOVE、
+		// TOUCHUP→BUTTONUP)。这样滑块拖拽/点轨道翻页不再依赖 Windows 为触摸“合成的鼠标移动”——
+		// 实测部分触摸收银机在手指拖拽期间根本不补发 WM_MOUSEMOVE,导致滑块拖不动。
+		if( event.Type == UIEVENT_BUTTONDOWN || event.Type == UIEVENT_DBLCLICK || event.Type == UIEVENT_TOUCHDOWN )
 		{
 			if( !IsEnabled() ) return;
 
 			m_nLastScrollOffset = 0;
 			m_nScrollRepeatDelay = 0;
 
+			// 触屏手指容差:把滑块命中区域向外放宽若干像素(并夹在滚动条自身范围内),
+			// 避免手指按偏一点点就落到“轨道”而触发翻页跳动。鼠标不受影响(鼠标本来就精准)。
+			RECT rcThumbHit = m_rcThumb;
+			::InflateRect(&rcThumbHit, 6, 6);
+			if( rcThumbHit.left   < m_rcItem.left )   rcThumbHit.left   = m_rcItem.left;
+			if( rcThumbHit.right  > m_rcItem.right )  rcThumbHit.right  = m_rcItem.right;
+			if( rcThumbHit.top    < m_rcItem.top )    rcThumbHit.top    = m_rcItem.top;
+			if( rcThumbHit.bottom > m_rcItem.bottom ) rcThumbHit.bottom = m_rcItem.bottom;
+
 			if( ::PtInRect(&m_rcButton1, event.ptMouse) ) {
 				m_uButton1State |= UISTATE_PUSHED;
 				if( !m_bHorizontal ) {
@@ -583,7 +631,7 @@ namespace DuiLib
 					else SetScrollPos(m_nScrollPos + m_nLineSize);
 				}
 			}
-			else if( ::PtInRect(&m_rcThumb, event.ptMouse) ) {
+			else if( ::PtInRect(&rcThumbHit, event.ptMouse) ) {
 				m_uThumbState |= UISTATE_CAPTURED | UISTATE_PUSHED;
 				m_ptLastMouse = event.ptMouse;
 				m_nLastScrollPos = m_nScrollPos;
@@ -615,7 +663,7 @@ namespace DuiLib
 			if( m_pManager != NULL) m_pManager->SendNotify(this, DUI_MSGTYPE_SCROLL);
 			return;
 		}
-		if( event.Type == UIEVENT_BUTTONUP )
+		if( event.Type == UIEVENT_BUTTONUP || event.Type == UIEVENT_TOUCHUP )
 		{
 			m_nScrollRepeatDelay = 0;
 			m_nLastScrollOffset = 0;
@@ -635,7 +683,7 @@ namespace DuiLib
 			}
 			return;
 		}
-		if( event.Type == UIEVENT_MOUSEMOVE )
+		if( event.Type == UIEVENT_MOUSEMOVE || event.Type == UIEVENT_TOUCHMOVE )
 		{
 			if( (m_uThumbState & UISTATE_CAPTURED) != 0 ) {
 				if( !m_bHorizontal ) {

+ 6 - 0
DuiLib/Control/UIScrollBar.h

@@ -86,6 +86,12 @@ namespace DuiLib
 		void DoEvent(TEventUI& event);
 		void SetAttribute(LPCTSTR pstrName, LPCTSTR pstrValue);
 
+		// [触屏] 手指容差命中测试:滚动条仅 16px 宽,指尖按偏几像素就落到内容上导致滑块拖不动。
+		// pt 超出本滚动条 rect 的距离在 n_slop 像素以内、且把 pt 收进滚动条 rect 后正好压在
+		// 滑块(同样外扩 n_slop)上时返回 true。供 CPaintManagerUI::OnTouchInput 在直接命中
+		// 不是滚动条时做“改判”。鼠标路径不使用(鼠标精确)。
+		bool IsThumbHit(POINT pt, int n_slop) const;
+
 		bool DoPaint(HDC hDC, const RECT& rcPaint, CControlUI* pStopControl);
 
 		void PaintBk(HDC hDC);

+ 8 - 5
DuiLib/Core/UIControl.cpp

@@ -843,11 +843,14 @@ namespace DuiLib
 
 	void CControlUI::DoEvent(TEventUI& event)
 	{
-		// [NIM 触屏模型] 触摸按下/抬起的"归一化"已上移到 CPaintManagerUI::OnTouchInput:
-		// 在那里就把 WM_TOUCH 直接派发成等价鼠标事件 UIEVENT_BUTTONDOWN / UIEVENT_BUTTONUP,
-		// 这样无论控件是否重写了 DoEvent(CButtonUI 等绝大多数控件都重写了),
-		// 都能正确收到鼠标语义的事件并原生响应手指操作,无需逐个改写。
-		// 唯一保留的触摸事件是 UIEVENT_TOUCHMOVE,由 CContainerUI::DoEvent 单独处理成 1:1 跟手滚动。
+		// [触屏模型] 触摸事件不做“归一化”:CPaintManagerUI::OnTouchInput 只派发原生
+		// UIEVENT_TOUCHDOWN / UIEVENT_TOUCHMOVE / UIEVENT_TOUCHUP,且只发给本次触摸命中的
+		// 那一个控件(m_pEventTouch),由该控件自决:
+		//   · CContainerUI 认 TOUCHMOVE  → 内容 1:1 跟手平移;
+		//   · CScrollBarUI 认 TOUCHDOWN/MOVE/UP → 拖滑块 / 点轨道翻页;
+		//   · 其余控件(含 CButtonUI)忽略触摸事件,点击一律走 Windows 为触摸模拟的
+		//     WM_LBUTTONDOWN/UP(原生鼠标路径)→ 故无需逐个改写控件。
+		// 未认领的触摸事件沿本函数末尾的 m_pParent->DoEvent 冒泡给父容器。
 
 		if (event.Type == UIEVENT_SETCURSOR)
 		{

+ 133 - 48
DuiLib/Core/UIManager.cpp

@@ -21,9 +21,12 @@ namespace DuiLib
 #ifndef TOUCH_COORD_TO_PIXEL
 #define TOUCH_COORD_TO_PIXEL(l) ((l) / 100)
 #endif
-	// [触屏模型变更] 不再使用 tap-slop 阈值:100% 照搬 NIM Duilib,触摸“点击”交给 Windows
-	// 为触摸模拟的 WM_LBUTTONDOWN/UP(原生鼠标路径),滚动交给原生 UIEVENT_TOUCHMOVE,
-	// 二者由 m_pEventTouch 守卫互斥,无需自定滑动阈值。
+	// [触屏模型] 触摸只做两件事,不做任何“触摸→鼠标”的归一化:
+	//   ①点击:交给 Windows 为触摸自动模拟的 WM_LBUTTONDOWN/UP(原生鼠标路径),
+	//         由 CButtonUI 等控件原生 Activate();
+	//   ②滚动:WM_TOUCH 直接派发 UIEVENT_TOUCHDOWN/MOVE/UP(见 OnTouchInput),
+	//         容器据此 1:1 跟手平移、滚动条据此拖滑块。
+	//   “轻点 vs 拖拽”不用距离阈值互斥,而是在 WM_LBUTTONUP 用 m_bTouchMoved 抑制误点。
 
 	// WM_TOUCH 消息 ID 同样受 `#if _WIN32_WINNT >= 0x0601` 保护,DuiLib 工程未定义
 	// _WIN32_WINNT(StdAfx.h 兜底为 XP)→ 必须自行声明,否则 switch 里引用该消息 ID
@@ -92,6 +95,11 @@ namespace DuiLib
 			_tcscpy_s(utf8, _countof(utf8), msg);
 #endif
 			FILE* f = NULL;
+			// 保护:单个日志超过 4MB 就不再追加,避免在收银机上长期运行把日志写到失控。
+			WIN32_FILE_ATTRIBUTE_DATA fad = { 0 };
+			if (::GetFileAttributesEx(Narrow(path), GetFileExInfoStandard, &fad)
+				&& fad.nFileSizeHigh == 0 && fad.nFileSizeLow >= (4u * 1024u * 1024u))
+				return;
 			if (fopen_s(&f, Narrow(path), "a") == 0 && f != NULL)
 			{
 				fprintf(f, "[%8u][T%5u] %s\n", (unsigned)::GetTickCount(), (unsigned)::GetCurrentThreadId(), utf8);
@@ -638,6 +646,57 @@ namespace DuiLib
 		}
 	}
 
+	// ---------------------------------------------------------------------------
+	// [触屏] 手指容差(滚动条专治)
+	// 滚动条默认仅 16px 宽(CScrollBarUI::DEFAULT_SCROLLBAR_SIZE),指尖按偏 5~10px 就落到
+	// 右侧内容上:内容会跟手滚动,但“滑块拖不动”——用户感知就是“滚动条没法在触屏上滑动”。
+	// 两个措施:①把触点横向收进滚动条 rect,再交给滚动条自己判滑块;②若直接命中的不是
+	// 滚动条,但触点容差范围内正好压在某个滚动条的滑块上,则改判为命中该滚动条。
+	// ---------------------------------------------------------------------------
+	namespace
+	{
+		const int kScrollBarTouchSlop = 12;   // 手指离滚动条 rect 的最大容差(像素)
+
+		// Windows 会把“触摸提升(promoted)出来的合成鼠标消息”打上 MOUSEEVENTF_FROMTOUCH 标记
+		// (位于 GetMessageExtraInfo() 的高位,MSDN 推荐判定值 0xFF515700)。
+		// 这里**只**用它来区分“真鼠标移动”与“触摸补发的鼠标移动”,从而决定要不要更新悬浮态;
+		// 消息本身绝不丢弃——历史教训:整条吞掉会让点击状态机(m_pEventClick)失效、点击全灭。
+		inline bool IsPromotedTouchMouseMessage()
+		{
+			return (::GetMessageExtraInfo() & 0xFF515700) == 0xFF515700;
+		}
+
+		// 把触点收进滚动条自身 rect:垂直滚动条主要收敛 x(y 原样保留,纵向拖拽量完好)。
+		inline POINT Touch_SnapIntoScrollBar(const CControlUI* pBar, POINT pt)
+		{
+			if (pBar == NULL) return pt;
+			RECT rc = pBar->GetPos();
+			if (pt.x < rc.left)   pt.x = rc.left;
+			if (pt.x > rc.right)  pt.x = rc.right;
+			if (pt.y < rc.top)    pt.y = rc.top;
+			if (pt.y > rc.bottom) pt.y = rc.bottom;
+			return pt;
+		}
+
+		// 沿“命中控件 → 祖先链”找最近的、满足容差条件的滚动条滑块;找不到返回 NULL。
+		inline CControlUI* Touch_ResolveScrollBarThumb(CControlUI* pStart, POINT pt)
+		{
+			for (CControlUI* p = pStart; p != NULL; p = p->GetParent())
+			{
+				if (p->GetInterface(DUI_CTR_SCROLLBAR) != NULL) return NULL; // 本身已是滚动条,无需改判
+				CContainerUI* pCont = static_cast<CContainerUI*>(p->GetInterface(DUI_CTR_CONTAINER));
+				if (pCont == NULL) continue;
+				CScrollBarUI* pBars[2] = { pCont->GetVerticalScrollBar(), pCont->GetHorizontalScrollBar() };
+				for (int i = 0; i < 2; ++i)
+				{
+					if (pBars[i] != NULL && pBars[i]->IsThumbHit(pt, kScrollBarTouchSlop))
+						return pBars[i];
+				}
+			}
+			return NULL;
+		}
+	}
+
 	void CPaintManagerUI::InitTouchSupport()
 	{
 		if (m_hWndPaint == NULL || !::IsWindow(m_hWndPaint)) return;
@@ -649,20 +708,20 @@ namespace DuiLib
 
 	void CPaintManagerUI::OnTouchInput(WPARAM wParam, LPARAM lParam)
 	{
-		// 100% 照搬 NIM Duilib 的 Window::HandleMessage case WM_TOUCH(Window.cpp:1139-1199)。
-		// 设计要点(不要自创归一化 / 状态机):
+		// 触屏模型(参考 NIM Duilib 的 Window::HandleMessage case WM_TOUCH,但**不可**照搬它的鼠标守卫,
+		// 旧版 DuiLib 的点击状态机强依赖原生 WM_LBUTTONDOWN/UP,照搬会让点击全灭——已实测踩过):
 		//   1) 独立的 m_pEventTouch 追踪“本次触摸命中控件”,DOWN 设定、UP 清空;绝不复用 m_pEventClick。
-		//   2) 只向控件派发“原生触摸事件” UIEVENT_TOUCHDOWN / UIEVENT_TOUCHMOVE / UIEVENT_TOUCHUP,
-		//      由 CContainerUI::DoEvent 把 TOUCHMOVE 当作 1:1 跟手滚动(对标 NIM ScrollableBox)。
-		//   3) 按钮“点击”不在这里合成!它来自 Windows 为触摸自动模拟的 WM_LBUTTONDOWN/UP,
-		//      走 MessageHandler 里原生的鼠标点击路径(CButtonUI 等据此 Activate())。NIM 从不过滤
-		//      这些模拟鼠标,只在 WM_LBUTTON* 分支加了 m_pEventTouch!=NULL 守卫,保证一次手指手势
-		//      要么滚动、要么点击,不会双触发。
-		//   4) 守卫:DOWN 与 MOVE 都【只对“滚动条”让路】(滑块拖拽由原生鼠标独占),其余控件一律接管。
-		//      不能用 `m_pEventClick != NULL` 做通用守卫——Windows 为触摸补发的合成 WM_LBUTTONDOWN 也会
-		//      设置 m_pEventClick,一旦 WM_TOUCH 不是先到,通用守卫会让容器收不到 TOUCHMOVE(列表滚不动)。
-		//   5) 抬起后置 m_bSuppressHoverAfterTouch,抑制 Windows 补发的合成 MOUSEMOVE 与 WM_MOUSELEAVE
-		//      的自回发把刚清掉的悬浮态重新点亮(“悬浮态不消失 / 多控件同时悬浮”的真凶)。
+		//   2) 只向控件派发“原生触摸事件” UIEVENT_TOUCHDOWN / UIEVENT_TOUCHMOVE / UIEVENT_TOUCHUP:
+		//      · CContainerUI 认 TOUCHMOVE        → 内容 1:1 跟手平移(对标 NIM ScrollableBox);
+		//      · CScrollBarUI 认 TOUCHDOWN/MOVE/UP → 拖滑块 / 点轨道翻页(不依赖合成鼠标移动);
+		//      · 其余控件忽略触摸事件(未认领的会沿 DoEvent 冒泡给父容器)。
+		//   3) 按钮“点击”不在这里合成!它来自 Windows 为触摸自动提升(promote)的 WM_LBUTTONDOWN/UP,
+		//      走 MessageHandler 里原生的鼠标点击路径(CButtonUI 等据此 Activate())。
+		//   4) 不做任何“触摸→鼠标”归一化、不做 tap-slop 互斥、不做通用 m_pEventClick 守卫;
+		//      “轻点 vs 拖拽”只用 m_bTouchMoved 在 WM_LBUTTONUP 处抑制误点。
+		//   5) 触摸**不参与悬浮态**:WM_MOUSEMOVE 里用 MOUSEEVENTF_FROMTOUCH 标记把“提升出来的鼠标消息”
+		//      挡在悬浮 enter/leave 之外(触摸没有“光标停留”语义)——这是“点过的控件一直高亮 /
+		//      多个控件同时高亮”的根治手段;此处再叠加 m_bSuppressHoverAfterTouch 抑制抬起后的复活。
 		TouchApi::PFN_GetTouchInputInfo pfnGetInfo = TouchApi::GetGetTouchInputInfo();
 		if (pfnGetInfo == NULL) return;
 
@@ -686,32 +745,52 @@ namespace DuiLib
 
 			if (ti.dwFlags & TouchApi::TOUCHEVTFLAG_DOWN)
 			{
-				// 说明:这里【不再】用 `if (m_pEventClick != NULL) break;`。
-				// 该守卫本意是“真实鼠标交互中忽略触摸”,但 Windows 会在 WM_TOUCH 前后补发合成的
-				// WM_LBUTTONDOWN(同样会设置 m_pEventClick)。若保证不了 WM_TOUCH 一定先到,
-				// 这句 break 会让 m_pEventTouch 永不被设置 → 容器 TOUCHMOVE 跟手滚动彻底失效。
-				// 由于点击/滚动本质上互不冲突(点击走原生鼠标路径,滚动走 TOUCHMOVE),
-				// 这里只在“命中控件是滚动条”时让路,其余一律接管。
+				// 这里【不再】用 `if (m_pEventClick != NULL) break;` 做通用守卫:Windows 会在 WM_TOUCH
+				// 前后补发合成的 WM_LBUTTONDOWN(同样设置 m_pEventClick),通用守卫会让 m_pEventTouch
+				// 永不设置 → 容器收不到 TOUCHMOVE,列表滚不动。
+				//
+				// 滚动条也不再“交回原生鼠标”:实测部分触摸收银机在手指拖拽期间【不补发合成的
+				// WM_MOUSEMOVE】,导致滑块拖不动。现在改为由 WM_TOUCH 直接驱动——CScrollBarUI 已把
+				// TOUCHDOWN/MOVE/UP 与鼠标事件等价处理,这里让滚动条走常规接管流程,
+				// 后续 TOUCHMOVE 由它消费(不会冒泡到容器把内容滚走)。
 
 				::SetFocus(m_hWndPaint);
 				m_ptLastTouchPos = pt;
+				// 先把手势状态复位:即使这一按没命中任何控件,也不能让上一轮的 m_pEventTouch 残留
+				// 下去(否则后续 TOUCHMOVE 会发给一个早已松开的手指所对应的控件)。
+				m_pEventTouch = NULL;
+				m_bTouchMoved = false;
+				m_ptTouchStart = pt;
 
 				CControlUI* pControl = FindControl(pt);
 				if (pControl == NULL) break;
 				if (pControl->GetManager() != this) break;
 
-				// 滚动条:滑块拖拽/点击由原生鼠标路径(模拟的 WM_LBUTTONDOWN/MOVE/UP)驱动;
-				// 若此处再派发 UIEVENT_TOUCHMOVE 会冒泡到容器把【内容】滚走,与滑块拖拽打架→滑块拖不动、点轨道误翻页。
-				// 直接交回原生鼠标,本函数提前退出(悬浮态由 WM_TOUCH UP 统一清理)。
-				if (pControl->GetInterface(_T("ScrollBar")) != NULL)
+				// [触屏] 手指容差改判:直接命中的不是滚动条时,看看是不是“手指离滚动条滑块很近”。
+				// 典型场景:列表右侧竖直滚动条仅 16px 宽,用户想拖滑块却按到了旁边的列表项上。
+				if (pControl->GetInterface(DUI_CTR_SCROLLBAR) == NULL)
 				{
-					TouchTrace::Log(_T("TOUCHDOWN-SCROLLBAR handback hit=%s pt=%d,%d click=%p capture=%d"),
-						pControl->GetName().GetData(), pt.x, pt.y, (void*)m_pEventClick, (int)m_bMouseCapture);
-					break;
+					CControlUI* pBar = Touch_ResolveScrollBarThumb(pControl, pt);
+					if (pBar != NULL)
+					{
+						TouchTrace::Log(_T("TOUCHDOWN-FATFINGER hit=%s -> scrollbar=%s pt=%d,%d"),
+							pControl->GetName().GetData(), pBar->GetName().GetData(), pt.x, pt.y);
+						pControl = pBar;
+					}
+				}
+
+				bool bTouchIsScrollBar = (pControl->GetInterface(DUI_CTR_SCROLLBAR) != NULL);
+				// 命中滚动条时把触点收进滚动条 rect(横向按偏也能拖滑块);事件点用收敛后的,
+				// 但 m_ptLastTouchPos / m_ptTouchStart 仍记真实坐标(去重与位移判定按真实手指轨迹走)。
+				POINT ptEvent = bTouchIsScrollBar ? Touch_SnapIntoScrollBar(pControl, pt) : pt;
+
+				if (bTouchIsScrollBar)
+				{
+					TouchTrace::Log(_T("TOUCHDOWN-SCROLLBAR touch-driven hit=%s pt=%d,%d -> %d,%d"),
+						pControl->GetName().GetData(), pt.x, pt.y, ptEvent.x, ptEvent.y);
 				}
 
 				m_pEventTouch = pControl;
-				m_bTouchMoved = false;
 				m_ptTouchStart = pt;
 				m_bSuppressHoverAfterTouch = false;  // 新手势开始,解除上一轮抬起后的悬浮抑制
 				pControl->SetFocus();
@@ -723,41 +802,39 @@ namespace DuiLib
 				TEventUI event = { 0 };
 				event.Type = UIEVENT_TOUCHDOWN;
 				event.pSender = pControl;
-				event.ptMouse = pt;
+				event.ptMouse = ptEvent;
 				event.dwTimestamp = ::GetTickCount();
 				event.wKeyState = MapKeyState();
 				pControl->Event(event);
 			}
 			else if (ti.dwFlags & TouchApi::TOUCHEVTFLAG_MOVE)
 			{
-				// 鼠标/指针交互进行中 -> 忽略触摸。但注意:一次触摸手势本身会触发 Windows 模拟的
-				// WM_LBUTTONDOWN,从而把 m_pEventClick 置为【当前命中控件】(容器/按钮/滚动条都会)。
-				// 若整句 break,会把容器的 UIEVENT_TOUCHMOVE 一起吞掉→列表无法跟手滚动。
-				// 故只对“滚动条”让路(滚动条滑块拖拽由原生鼠标驱动,见 DOWN 的 ScrollBar break),
-				// 其余控件(容器/按钮)照常派发 TOUCHMOVE:容器据此 1:1 跟手滚动,按钮不处理 TOUCHMOVE 无害。
-				if (m_pEventClick != NULL && m_pEventClick->GetInterface(_T("ScrollBar")) != NULL)
-				{
-					TouchTrace::Log(_T("TOUCHMOVE-SKIP(click=scrollbar) click=%s pt=%d,%d"),
-						m_pEventClick->GetName().GetData(), pt.x, pt.y);
-					break;
-				}
+				// 【不要】在这里对滚动条让路。滚动条现在完全由 WM_TOUCH 驱动:DOWN 已把 m_pEventTouch
+				// 设为"滚动条自身"(不再交回原生鼠标),CScrollBarUI 也已把 TOUCHDOWN/MOVE/UP 与鼠标
+				// 事件等价处理。若此处仍 `m_pEventClick->GetInterface("ScrollBar") != NULL` 就 break,
+				// TOUCHMOVE 永远到不了滚动条 → 滑块拖不动(历史症状)。滚动条收到 TOUCHMOVE 会自行消费
+				// (CScrollBarUI::DoEvent 命中已捕获分支后 return true),不会冒泡到容器把内容滚走。
 				// 与上一包完全相同的点 -> 去重,避免无谓派发(NIM 同款守卫)
 				if (m_ptLastTouchPos.x == pt.x && m_ptLastTouchPos.y == pt.y) break;
 				// 本次位移增量(相对上一包),随事件下发,滚动容器据此做 1:1 跟手平移
 				LONG dx = pt.x - m_ptLastTouchPos.x;
 				LONG dy = pt.y - m_ptLastTouchPos.y;
 				m_ptLastTouchPos = pt;
-				// 相对手势起点的累计位移超过阈值 -> 判定为拖拽(用于松开时抑制点击)
+				// 相对手势起点的累计位移超过阈值 -> 判定为拖拽(用于松开时抑制点击)。
 				LONG ox = pt.x - m_ptTouchStart.x;
 				LONG oy = pt.y - m_ptTouchStart.y;
-				if (ox > 5 || ox < -5 || oy > 5 || oy < -5)
+				// 阈值取 8px:真实滚动位移远大于此,而指尖“按下去时的轻微滚动”通常在 8px 内。
+				// 阈值过小会把抖动误判成拖拽 → 该次抬起的 BUTTONUP 被抑制 → 按钮停在按下态。
+				if (ox > 8 || ox < -8 || oy > 8 || oy < -8)
 					m_bTouchMoved = true;
 				if (m_pEventTouch == NULL) break;
 
 				TEventUI event = { 0 };
 				event.Type = UIEVENT_TOUCHMOVE;
 				event.pSender = m_pEventTouch;
-				event.ptMouse = pt;
+				// 滚动条:触点同样收进滚动条 rect(与 DOWN 保持一致,避免首帧跳变)。
+				event.ptMouse = (m_pEventTouch->GetInterface(DUI_CTR_SCROLLBAR) != NULL)
+					? Touch_SnapIntoScrollBar(m_pEventTouch, pt) : pt;
 				event.dwTimestamp = ::GetTickCount();
 				event.wKeyState = MapKeyState();
 				event.wParam = (WPARAM)(int)dx;
@@ -813,7 +890,7 @@ namespace DuiLib
 				m_bSuppressHoverAfterTouch = true;
 				m_bMouseTracking = false;
 
-				// 滚动条等场景本函数未接管(m_pEventTouch 为 NULL),捕获由原生鼠标路径负责,这里不释放。
+				// 本函数未接管该次触摸(如 DOWN 时 FindControl 落空)→ 沿旧路径交给原生鼠标,这里不释放。
 				if (m_pEventTouch == NULL) break;
 
 				ReleaseCapture();
@@ -821,7 +898,9 @@ namespace DuiLib
 				TEventUI event = { 0 };
 				event.Type = UIEVENT_TOUCHUP;
 				event.pSender = m_pEventTouch;
-				event.ptMouse = pt;
+				// 与 DOWN/MOVE 一致:滚动条用收敛点,保证 ptMouse 轨迹连续、位移计算不失真。
+				event.ptMouse = (m_pEventTouch->GetInterface(DUI_CTR_SCROLLBAR) != NULL)
+					? Touch_SnapIntoScrollBar(m_pEventTouch, pt) : pt;
 				event.dwTimestamp = ::GetTickCount();
 				event.wKeyState = MapKeyState();
 				TouchTrace::Log(_T("TOUCHUP touch=%s moved=%d hover=%p leaveArr=%d click=%p capture=%d"),
@@ -2126,7 +2205,13 @@ namespace DuiLib
 				event.lParam = lParam;
 				event.dwTimestamp = ::GetTickCount();
 				event.wKeyState = MapKeyState();
-				if (!IsCaptured())
+				// 悬浮态总闸:触摸提升出来的合成鼠标消息(MOUSEEVENTF_FROMTOUCH)不参与
+				// 悬浮 enter/leave。触摸没有“光标停留”语义,一旦让它参与,就会出现
+				// “点过的控件一直高亮 / 连点几个控件后多个同时高亮、怎么点都清不掉”的顽疾
+				// (按钮的 UISTATE_HOT、列表项的 UISTATE_HOT 全靠 MOUSEENTER/MOUSELEAVE 维护)。
+				// 该判断只影响悬浮段,消息依旧往下走 → 点击/拖拽状态机不受影响。
+				const bool bPromotedTouchMove = IsPromotedTouchMouseMessage();
+				if (!IsCaptured() && !bPromotedTouchMove)
 				{
 					pNewHover = FindControl(pt);
 					if (pNewHover != NULL && pNewHover->GetManager() != this) break;

+ 13 - 8
DuiLib/Core/UIManager.h

@@ -469,15 +469,17 @@ namespace DuiLib {
 		bool PreMessageHandler(UINT uMsg, WPARAM wParam, LPARAM lParam, LRESULT& lRes);
 		void UsedVirtualWnd(bool bUsed);
 
-		// 触屏支持(100% 照搬 NIM Duilib 模型,库级统一接管:主窗口 + 所有弹框通用)。
+		// 触屏支持(参考 NIM Duilib 模型,库级统一接管:主窗口 + 所有弹框通用)。
 		// WM_TOUCH 在 MessageHandler 里解析为原生 DuiLib 触摸事件,直接派发给命中的控件:
-		//   DOWN → UIEVENT_TOUCHDOWN   仅用于滚动容器感知手势起点(绝大多数控件忽略)
-		//   MOVE → UIEVENT_TOUCHMOVE   经 DoEvent 冒泡链由 CContainerUI 接收,做 1:1 跟手平移
-		//   UP   → UIEVENT_TOUCHUP     仅用于滚动容器收尾(绝大多数控件忽略)
-		// 按钮“点击”【不】在此合成:它来自 Windows 为触摸自动模拟的 WM_LBUTTONDOWN/UP,
+		//   DOWN → UIEVENT_TOUCHDOWN   滚动条据此抓滑块;容器据此记手势起点(其余控件忽略)
+		//   MOVE → UIEVENT_TOUCHMOVE   CScrollBarUI 消费则拖滑块;否则冒泡给 CContainerUI 做 1:1 跟手平移
+		//   UP   → UIEVENT_TOUCHUP     滚动条据此松开滑块;容器收尾(其余控件忽略)
+		// 按钮“点击”【不】在此合成:它来自 Windows 为触摸自动提升(promote)的 WM_LBUTTONDOWN/UP,
 		// 走 MessageHandler 原生鼠标路径 → CButtonUI 等据此 Activate() 触发 click。
-		// NIM 从不过滤这些模拟鼠标,只在 WM_LBUTTON*/WM_MOUSEMOVE 分支用 m_pEventTouch!=NULL 守卫
-		// 防止“一次手指手势既滚动又点击”。注册在 Init 中完成,无需逐窗口样板。
+		// 不做任何“触摸→鼠标”归一化,也不做 NIM 那种 m_pEventTouch!=NULL 通用守卫(旧版 DuiLib 的
+		// 点击状态机强依赖原生 WM_LBUTTONDOWN/UP,照搬守卫会让点击全灭,已实测);仅用
+		// m_bTouchMoved 标记“本次手势是否发生位移”,位移则松开时不触发点击(避免滚动又误点)。
+		// 注册在 Init 中完成,无需逐窗口样板。
 		void InitTouchSupport();
 		void OnTouchInput(WPARAM wParam, LPARAM lParam);
 
@@ -529,7 +531,10 @@ namespace DuiLib {
 		// m_bMouseTracking 为真时会【自己给自己重发 WM_MOUSEMOVE】(UIManager.cpp WM_MOUSELEAVE 分支),
 		// 导致刚清掉的悬浮态立刻被重新点亮 → “悬浮态不消失 / 多个控件同时悬浮”。
 		// 抬起后置 true,丢弃随后这批“非真实”的 MOUSEMOVE 悬浮逻辑;一旦收到用户真实移动鼠标
-		// (真实鼠标有 MK_LBUTTON 之外的持续位移特征,这里用“距上次触摸位置明显移动”判定)即解除。
+		// (这里用“距上次触摸位置明显移动”判定)即解除。
+		// 注:这是【第二道】保险。第一道(根治)在 WM_MOUSEMOVE 里:凡带 MOUSEEVENTF_FROMTOUCH
+		// 标记的“触摸提升出来的鼠标消息”一律不参与悬浮 enter/leave —— 触摸没有“光标停留”语义,
+		// 让这类消息参与必然出现“点过的控件一直高亮 / 多点几个后多个同时高亮”。
 		bool m_bSuppressHoverAfterTouch = false;
 
 		//