辰小忆头像

03 · DelayResetBtn —— 延时按钮、页签与排布容器

DLL:DuiLibDelayResetBtnPlugin.dll
类:CCDelayButton、CCDelayTabButton、CCDelayBtnLayout
标签:DelayResetBtn / DelayButton、DelayTabChangeButton / DelayTabButton、
SDelayResetBtnLayout / DelayResetLayout / DelayResetMutiLayout

一个 DLL 导出三个类,对应原工程 DelayResetBtn 插件里的四份源码
(DelayButton、DelayTabChangeButton、SDelayResetBtnLayout、
DelayResetMutiLayout)。

三个类放一个插件的理由(源码注释原文):

它们共用同一套"按钮状态机 + 计时"逻辑。


一、CCDelayButton:延时复位按钮

1.1 语义:“按下后延时 N 毫秒才生效”

普通按钮是"松手就触发"。延时按钮是按下就开始计时,到点才触发,
期间显示倒计时。

private:
    int    m_nDelayMs;        // 延时毫秒,默认 800
    double m_dRemain;         // 剩余秒数
    bool   m_bCounting;       // 是否在倒计时
    bool   m_bAllowRepeat;    // 倒计时中是否允许再次点击
    bool   m_bHot;
    bool   m_bPressed;
    bool   m_bProgressVisible;

1.2 按下即开始

void CCDelayButton::DoEvent(TEventUI& event)
{
    if (event.Type == UIEVENT_BUTTONDOWN) {
        if (!IsEnabled()) return;
        if (m_bCounting && !m_bAllowRepeat) return;      // ← 不允许重复时忽略
        m_bPressed  = true;
        m_bCounting = true;
        m_dRemain   = (double)m_nDelayMs / 1000.0;       // ← 重置剩余时间
        StartTick();
        Invalidate();
        return;
    }

    if (event.Type == UIEVENT_BUTTONUP) {
        if (m_bPressed) {
            m_bPressed = false;
            Invalidate();
        }
        // 计数仍在继续,由 OnTick 负责到期复位
        return;
    }

    if (event.Type == UIEVENT_MOUSEENTER) { m_bHot = true; Invalidate(); return; }

    if (event.Type == UIEVENT_MOUSELEAVE) {
        m_bHot = false;
        m_bPressed = false;
        Invalidate();
        return;
    }

    CC::CCControlBase::DoEvent(event);
}

松手不停止计时(BUTTONUP 里只清 m_bPressed,不动 m_bCounting)。

这是关键差异——普通按钮松手就触发,延时按钮松手后倒计时继续走,
到点才发通知。用户只需要"点一下",不用一直按着。

1.3 倒计时到期

void CCDelayButton::OnTick(double dt, double dTotal)
{
    (void)dTotal;
    if (!m_bCounting) return;

    m_dRemain -= dt;
    if (m_dRemain <= 0.0) {
        m_dRemain   = 0.0;
        m_bCounting = false;
        m_bPressed  = false;
        Invalidate();
        // 到期:这就是"延时复位"的语义——先亮着,到点发事件
        FireNotify(_T("delaydone"));
        return;
    }
    Invalidate();
}

“先亮着,到点发事件”——这是注释原话。

为什么这样设计?因为这类按钮用于"复位"操作:按下去时设备先"响应"(灯亮 / 声音),
但要等 N 毫秒确认没有二次操作才真正执行复位。期间用户可以再按一次取消
(如果 allowrepeat="true")。

1.4 视觉:三种底色 + 倒计时进度条

void CCDelayButton::PaintCNContent(HDC hDC, const RECT& rcPaint)
{
    (void)rcPaint;
    RECT rc = m_rcItem;
    if (CC::IsRectBlank(rc)) return;

    DWORD dwBg = m_dwNormalColor;
    if (!IsEnabled())     dwBg = 0xFF3A4249;      // 禁用
    else if (m_bCounting) dwBg = m_dwCountColor;  // 倒计时中
    else if (m_bPressed)  dwBg = m_dwPushedColor; // 按下
    else if (m_bHot)      dwBg = m_dwHotColor;    // 悬停

    int nRadius = m_cBorderRadius > 0 ? m_cBorderRadius : 4;
    CC::FillRoundRect(hDC, rc, nRadius, dwBg);

    // 倒计时进度条(从满到空)
    if (m_bProgressVisible && m_bCounting && m_nDelayMs > 0) {
        double dRatio = m_dRemain / ((double)m_nDelayMs / 1000.0);
        dRatio = CC::Clamp01(dRatio);
        int nW = (int)((double)CC::RectWidth(rc) * dRatio);
        if (nW > 0) {
            RECT rcProg = rc;
            rcProg.top   = rcProg.bottom - 3;
            rcProg.right = rcProg.left + nW;
            CC::FillRoundRect(hDC, rcProg, 1, 0xFF35C0FF);
        }
    }

    // 文字:倒计时时显示剩余秒数
    HFONT hFont = m_font.Get(13, false);
    /* 选字体、SetBkMode、SetTextColor */

    CDuiString sText = m_sText;
    if (m_bCounting && !sText.IsEmpty()) {
        CDuiString sSuffix;
        sSuffix.Format(_T(" (%.1fs)"), m_dRemain);      // ← 追加倒计时
        sText += sSuffix;
    }

    RECT rcText = rc;
    rcText.left  += 6;
    rcText.right -= 6;
    ::DrawText(hDC, sText.GetData(), -1, &rcText,
        DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);

    /* 还原字体 */
}

1.5 进度条从满到空

double dRatio = m_dRemain / ((double)m_nDelayMs / 1000.0);

m_dRemain 从满值递减到 0,所以 dRatio 从 1 递减到 0——进度条
从满到空,和普通进度条(从空到满)相反。

这是有意的:倒计时是"时间在消耗",条子缩短直观表达"还剩多少"。

对比 CCButton 的长按进度条(从 0 长到满)——那是"蓄力",条子增长表达"快到了"。

1.6 文字追加倒计时

sSuffix.Format(_T(" (%.1fs)"), m_dRemain);
sText += sSuffix;

按钮从"复位"变成"复位 (0.8s)",%.1f 一位小数,每 100ms 变一次。

为什么用括号而不是另起一行:按钮通常只有 30px 高,一行文字放不下两行。
括号内联是最紧凑的表达。

1.7 allowrepeat 的作用

if (m_bCounting && !m_bAllowRepeat) return;      // 不允许重复 → 忽略点击
  • allowrepeat="true"(默认):倒计时中再点一次会重置倒计时(m_dRemain 重新满格);
  • allowrepeat="false":倒计时中点击无效,必须等它走完。

"复位"类操作通常要 false——防止用户在倒计时中反复点,把复位无限延后。


二、CCDelayTabButton:互斥页签

2.1 一横排(或一竖排)互斥项

private:
    std::vector<CDuiString> m_items;
    int  m_nSelected;
    bool m_bVertical;
    int  m_nGap;
    bool m_bIndicator;      // 选中项下方高亮条
    bool m_bHot;
    int  m_nHotIndex;       // ← 悬停的是哪一项,-1 = 无

m_nHotIndex 是这一项与普通按钮的关键区别——普通按钮只有"整体悬停",
页签需要知道"悬停在哪一个页签上"。

2.2 项矩形计算

RECT CCDelayTabButton::TabRect(int nIndex) const
{
    int nCount = (int)m_items.size();
    if (nCount <= 0 || nIndex < 0 || nIndex >= nCount) {
        RECT zero = { 0, 0, 0, 0 };
        return zero;
    }

    RECT rc;
    if (m_bVertical) {
        int nH = (CC::RectHeight(m_rcItem) - m_nGap * (nCount - 1)) / nCount;
        if (nH < 1) nH = 1;
        rc.left   = m_rcItem.left;
        rc.right  = m_rcItem.right;
        rc.top    = m_rcItem.top + nIndex * (nH + m_nGap);
        rc.bottom = rc.top + nH;
    }
    else {
        int nW = (CC::RectWidth(m_rcItem) - m_nGap * (nCount - 1)) / nCount;
        if (nW < 1) nW = 1;
        rc.left   = m_rcItem.left + nIndex * (nW + m_nGap);
        rc.right  = rc.left + nW;
        rc.top    = m_rcItem.top;
        rc.bottom = m_rcItem.bottom;
    }
    return rc;
}

(总尺寸 - gap × (n-1)) / n 是等分公式——先扣掉 n-1 个间隙,
剩下的平均分给 n 项。

例:宽 300、3 项、gap 4 → (300 - 4*2) / 3 = 97.33 → 每项 97px,
实际总占 97*3 + 4*2 = 299(少 1px 是取整误差,可接受)。

2.3 命中测试

int CCDelayTabButton::HitTestTab(POINT pt) const
{
    for (int i = 0; i < (int)m_items.size(); ++i) {
        RECT rc = TabRect(i);
        if (pt.x >= rc.left && pt.x < rc.right && pt.y >= rc.top && pt.y < rc.bottom) {
            return i;
        }
    }
    return -1;
}

逐个矩形判断,< right 而不是 <= right(避免相邻项的边界重叠)。

2.4 事件:点击切页 + 悬停跟踪

void CCDelayTabButton::DoEvent(TEventUI& event)
{
    if (event.Type == UIEVENT_BUTTONDOWN) {
        int nIndex = HitTestTab(event.ptMouse);
        if (nIndex >= 0 && nIndex != m_nSelected) {      // ← 点已选中的不重复发
            m_nSelected = nIndex;
            Invalidate();
            FireNotify(_T("tabchanged"));
        }
        return;
    }

    if (event.Type == UIEVENT_MOUSEMOVE) {               // ← 注意是 MOUSEMOVE 不是 MOUSEENTER
        int nIndex = HitTestTab(event.ptMouse);
        if (nIndex != m_nHotIndex) {
            m_nHotIndex = nIndex;
            m_bHot = (nIndex >= 0);
            Invalidate();
        }
    return;
    }

    if (event.Type == UIEVENT_MOUSELEAVE) {
        if (m_nHotIndex != -1) {
            m_nHotIndex = -1;
            m_bHot = false;
            Invalidate();
        }
        return;
    }

    CC::CCControlBase::DoEvent(event);
}

2.5 必须用 UIEVENT_MOUSEMOVE 而不是 MOUSEENTER

这是页签控件特有的要求:

  • MOUSEENTER / MOUSELEAVE 是"进入/离开整个控件",只能知道"鼠标在控件内";
  • MOUSEMOVE 是"在控件内移动",能知道"现在悬停在哪个子项上"。

所以 CCDelayTabButton 必须处理 UIEVENT_MOUSEMOVE。

DuiLib 默认只在控件带 UIFLAG_SETCURSOR 时才投递 MOUSEMOVE。
CCDelayTabButton 没有重写 GetControlFlags()——它继承 CCControlBase,
而 CCControlBase 也没重写,所以是 CControlUI 的默认值。

当前实现能工作,是因为 CControlUI::GetControlFlags() 默认返回 UIFLAG_TABSTOP,
而 DuiLib 的 CPaintManagerUI 对带 TABSTOP 的控件也会投递 MOUSEMOVE
。
如果将来 DuiLib 改了投递规则,需要显式声明:

UINT CCDelayTabButton::GetControlFlags() const
{
    return UIFLAG_TABSTOP | UIFLAG_SETCURSOR;
}

这是个潜在脆弱点,建议主动加上。

2.6 点击已选中项不发通知

if (nIndex >= 0 && nIndex != m_nSelected) {

点当前已选中的页签时,nIndex == m_nSelected,不触发。

这是对的——页签是"切换到某个页",已经在那一页了就不该再发"切换"事件,
否则窗口侧会重复加载页面。

2.7 绘制

void CCDelayTabButton::PaintCNContent(HDC hDC, const RECT& rcPaint)
{
    (void)rcPaint;

    int nCount = (int)m_items.size();
    if (nCount <= 0) return;                      // ← 空状态直接返回

    int nFontH = CC::MaxOf(10, CC::MinOf(CC::RectHeight(TabRect(0)) / 2, 14));
    HFONT hFont = m_font.Get(nFontH, false);
    /* 选字体、SetBkMode */

    for (int i = 0; i < nCount; ++i) {
        RECT rcTab = TabRect(i);
        if (CC::IsRectBlank(rcTab)) continue;

        bool bSelected = (i == m_nSelected);
        bool bHot      = (i == m_nHotIndex);

        DWORD dwBg = bSelected ? m_dwSelectBg : (bHot ? m_dwHotBg : m_dwTabBg);
        CC::FillRoundRect(hDC, rcTab, m_cBorderRadius > 0 ? m_cBorderRadius : 4, dwBg);

        // 选中项下方高亮指示条
        if (bSelected && m_bIndicator) {
            RECT rcBar = rcTab;
            rcBar.top = rcBar.bottom - 2;          // ← 底部 2px
            CC::FillRoundRect(hDC, rcBar, 1, 0xFF35C0FF);
        }

        DWORD dwText = bSelected ? m_dwSelectTextColor : m_dwTextColor;
        ::SetTextColor(hDC, RGB((dwText >> 16) & 0xFF, (dwText >> 8) & 0xFF, dwText & 0xFF));
        ::DrawText(hDC, m_items[(size_t)i].GetData(), -1, &rcTab,
            DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
    }

    /* 还原字体 */
}

三层状态:

状态底色额外装饰
选中m_dwSelectBg(蓝)底部 2px 高亮条
悬停m_dwHotBg(微亮)无
普通m_dwTabBg(深灰)无

2.8 items 属性的幂等

void CCDelayTabButton::SetItems(LPCTSTR pstrItems)
{
    m_items.clear();
    if (pstrItems != NULL) {
        CDuiString sAll = pstrItems;
        int nPos = 0;
        while (nPos <= sAll.GetLength()) {
            int nComma = sAll.Find(_T(','), nPos);
            CDuiString sItem = (nComma < 0) ? sAll.Mid(nPos) : sAll.Mid(nPos, nComma - nPos);
            m_items.push_back(sItem);            // ← 空项也推(保留占位)
            if (nComma < 0) break;
            nPos = nComma + 1;
        }
    }
    if (m_nSelected >= (int)m_items.size()) m_nSelected = 0;
    Invalidate();
}

注意 m_items.push_back(sItem) 不判空——和 FiveColorChart::labels 不同。

"监控,,设置" 会得到 3 项,中间那项是空字符串(显示为空白页签)。
这是"保留占位"的设计(有些界面需要空页签做间隔)。

幂等性:m_items.clear() 在开头,所以解析两次结果相同。

2.9 SetSelected 会发通知

void CCDelayTabButton::SetSelected(int nIndex)
{
    if (m_items.empty()) return;
    m_nSelected = CC::Clamp(nIndex, 0, (int)m_items.size() - 1);
    Invalidate();
    FireNotify(_T("tabchanged"));        // ← 编程式设置也发通知
}

和点击不同——DoEvent 里点击已选中项不发通知,但 SetSelected 无条件发。

原因:SetSelected 是外部主动切换(脚本或窗口代码),调用方期望
“切换完成"的通知。而点击已选中项是"用户什么也没做”,不该发。


三、CCDelayBtnLayout:排布容器

3.1 它不接管布局

源码注释写得很明确:

按钮排布容器(只管画分隔与背景,不接管子控件的布局 —— DuiLib 的
XML 里子控件仍然按 hbox/vbox 排,这里额外提供"等分辅助线")

所以它是个纯装饰控件:

private:
    bool  m_bVertical;
    int   m_nGap;
    bool  m_bDivider;           // 是否画外框
    DWORD m_dwDividerColor;
    int   m_nCellCount;         // 画几个等分格

3.2 绘制

void CCDelayBtnLayout::PaintCNContent(HDC hDC, const RECT& rcPaint)
{
    (void)rcPaint;
    if (m_nCellCount <= 0 && !m_bDivider) return;      // ← 什么都没配就什么都不画

    // 等分辅助线:让排版对齐时有个参照(默认关闭,属性里开启)
    if (m_nCellCount > 1) {
        for (int i = 1; i < m_nCellCount; ++i) {       // ← 从 1 开始,跳过左/上边界
            if (m_bVertical) {
                int y = m_rcItem.top + CC::RectHeight(m_rcItem) * i / m_nCellCount;
                CC::DrawSegment(hDC, m_rcItem.left, y, m_rcItem.right, y, 1, m_dwDividerColor);
            }
            else {
                int x = m_rcItem.left + CC::RectWidth(m_rcItem) * i / m_nCellCount;
                CC::DrawSegment(hDC, x, m_rcItem.top, x, m_rcItem.bottom, 1, m_dwDividerColor);
            }
        }
    }

    if (m_bDivider) {
        CC::StrokeRoundRect(hDC, m_rcItem, m_cBorderRadius, 1, m_dwDividerColor);
    }
}

i 从 1 开始,到 nCellCount - 1 结束——只画内部分隔线,不画外边界。
nCellCount = 3 时画 2 条线(在 1/3 和 2/3 位置)。

3.3 为什么不接管子控件布局

DuiLib 的布局系统(CHorizontalLayoutUI / CVerticalLayoutUI)已经能做
“横向排列、等分或固定宽”。CCDelayBtnLayout 如果再自己布局,会和 DuiLib 打架。

所以它的定位是**“视觉容器”**:

<SDelayResetBtnLayout orientation="horizontal" cellcount="3" divider="true" height="40">
    <DelayResetBtn text="复位A" width="90" height="32" />
    <DelayResetBtn text="复位B" width="90" height="32" />
    <DelayResetBtn text="复位C" width="90" height="32" />
</SDelayResetBtnLayout>

子控件靠自己的 width 定位,CCDelayBtnLayout 只负责画"3 等分的辅助线"
和"外框",让视觉上看起来是分格的。

这是个"辅助对齐"的调试工具,不是真正的布局容器。生产界面里通常
cellcount="0" divider="false",什么装饰也不画。

3.4 空状态主动返回

if (m_nCellCount <= 0 && !m_bDivider) return;

没配 cellcount 也没开 divider 时,完全不画。

这是 _tools/paint_test.cpp 里"5 个画空白的控件"之一——不是缺陷,
是"没配置就不画"的预期行为。


四、属性表

CCDelayButton

属性值说明
text字符串按钮文字
delay / delaymsint延时毫秒,夹到 0–60000,默认 800
allowrepeatbool倒计时中允许重复点击,默认 true
normalcolorcolor常态底色,默认 #FF2A323A
hotcolorcolor悬停底色,默认 #FF37424C
pushedcolorcolor按下底色,默认 #FF1E252B
countcolorcolor倒计时底色,默认 #FF1F4A63(深蓝)
textcolorcolor文字色,默认 #FFDCE6F0
progressbool倒计时进度条,默认 true

只读:delay、counting("true"/"false")、remain(剩余毫秒)。

CCDelayTabButton

属性值说明
items"监控,回放,设置"页签项列表
selectedint选中索引,默认 0
orientationhorizontal / vertical排布方向
gapint项间距,夹到 0–40,默认 4
selectcolorcolor选中底色,默认 #FF2F80C8
tabcolorcolor普通底色,默认 #FF232A31
hotcolorcolor悬停底色,默认 #FF2E3740
textcolorcolor普通文字色,默认 #FFC8D4E0
selecttextcolorcolor选中文字色,默认 #FFFFFFFF
indicatorbool选中项下方高亮条,默认 true

只读:selected、selectedtext、itemcount。

CCDelayBtnLayout

属性值说明
orientationhorizontal / vertical等分线方向
gapint间隙值(当前只存不用)
dividerbool画外框,默认 false
dividercolorcolor分隔线色,默认 #FF39424C
cellcountint等分格数,夹到 0–64,默认 0

只读:orientation、gap。

gap 属性在 CCDelayBtnLayout 里只存不用——SetGap 存了值,
但 PaintCNContent 里完全没用它。因为"不接管子控件布局",间隙由子控件
自己的 margin 控制。


五、XML 示例

<!-- 延时复位按钮:0.8 秒后触发 -->
<DelayResetBtn text="重置" delay="800" width="100" height="34" />

<!-- 倒计时中不允许重复点击 -->
<DelayResetBtn text="即刻重置" delay="2000" allowrepeat="false"
               countcolor="#FF6B2A1E" width="120" height="34" />

<!-- 关掉进度条,只显示倒计时文字 -->
<DelayResetBtn text="延时启动" delay="3000" progress="false"
               width="110" height="34" />

<!-- 三页签 -->
<DelayTabChangeButton items="主界面,日志,设置" selected="0"
                      gap="4" indicator="true"
                      width="300" height="36" />

<!-- 竖排页签 -->
<DelayTabChangeButton items="通道1,通道2,通道3" orientation="vertical"
                      width="100" height="120" />

<!-- 排布容器:3 等分辅助线 + 外框 -->
<SDelayResetBtnLayout orientation="horizontal" cellcount="3" divider="true"
                      height="44">
    <DelayResetBtn text="复位A" delay="800"  width="100" height="34" margin="0,0,8,0" />
    <DelayResetBtn text="复位B" delay="800"  width="100" height="34" margin="0,0,8,0" />
    <DelayResetBtn text="复位C" delay="800"  width="100" height="34" />
</SDelayResetBtnLayout>

显示效果

文章配图

六、踩过的坑

6.1 页签需要 UIEVENT_MOUSEMOVE

见 2.5。MOUSEENTER 只能知道"在控件内",MOUSEMOVE 才能知道"在第几项上"。

当前靠 UIFLAG_TABSTOP 默认投递能工作,但建议显式加 UIFLAG_SETCURSOR。

6.2 CCDelayBtnLayout 的 gap 只存不用

见第四节。间隙由子控件 margin 控制。

6.3 items 保留空项

m_items.push_back(sItem);    // 空项也推

"监控,,设置" → 3 项,中间空白。这是"保留占位"的刻意设计。

对比 FiveColorChart::labels 里是 if (!sItem.IsEmpty())——空项跳过。
两个控件的策略不同,因为页签需要"空位"占住横向空间,而状态条不需要。

6.4 倒计时进度条方向相反

见 1.5。延时按钮从满到空,普通按钮从空到满。别搞混。

6.5 SetSelected 与点击的通知策略不同

见 2.9。编程式设置无条件发,点击已选中项不发。


七、空状态处理

控件空状态行为
DelayResetBtntext=""只有色块和进度条,无文字
DelayResetBtndelay=0按下后 m_dRemain=0,下一帧 OnTick 立即触发 delaydone
DelayTabButtonitems=""m_items 为空 → PaintCNContent 首句返回,完全空白
DelayTabButtonitems=",,,"4 个空白页签(占位)
DelayBtnLayoutcellcount=0 + divider=false完全不画(主动返回)
DelayBtnLayoutcellcount=1i < 1 循环不执行,只可能画外框

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/hdjsjb/article/details/170055269

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--