/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
01 · AnimationLabel —— 动画文本控件
DLL:DuiLibAnimationLabelPlugin.dll
类:CCAnimationLabel、CCAnimationVerLabel
XML标签:AnimationLabel / AniLabel、AnimationVerLabel / AniVerLabel
一个 DLL 导出两个类:横向动画文本 + 纵向滚动字幕。两者共用 CCControlBase 的
节拍机制,但绘制逻辑完全不同,所以分成两个类。
一、CCAnimationLabel:五种横向动画
1.1 模式枚举
enum TAnimLabelMode
{
ALM_STATIC = 0, // 不动画,普通文本
ALM_TYPEWRITER, // 逐字出现(打字机,带光标闪烁)
ALM_MARQUEE, // 水平/垂直滚动字幕(跑马灯)
ALM_FADE, // 整行淡入淡出
ALM_BLINK, // 闪烁
};
enum TMarqueeDirection
{
MD_LEFT = 0, // 从右往左
MD_RIGHT, // 从左往右
MD_UP, // 从下往上
MD_DOWN, // 从上往下
};
1.2 状态机
控件本身不存"动画进度条",只存三个量:
double m_dProgress; // 0..1 主进度(打字机 / 淡入用)
double m_dMarqueePos; // 滚动偏移(像素,跑马灯用)
int m_nTypeCount; // 已显示的字符数(打字机用)
bool m_bPlaying; // 是否在播
ResetAnim() 一次性归零:
void CCAnimationLabel::ResetAnim()
{
m_dProgress = 0.0;
m_dMarqueePos = 0.0;
m_nTypeCount = 0;
m_bPlaying = (m_eMode != ALM_STATIC) && !m_sText.IsEmpty();
}
m_bPlaying 的判定带上"文本非空"——空串时不启动动画,OnTick 里第一句就
if (!m_bPlaying) return;,空状态零开销。
1.3 打字机:字符数 = 进度 × 长度
case ALM_TYPEWRITER:
{
m_dProgress += dt * (double)m_nSpeed / (double)nLen; // 每秒 m_nSpeed 个字符
int nCount = (int)(m_dProgress * (double)nLen);
if (nCount != m_nTypeCount) { m_nTypeCount = nCount; Invalidate(); }
if (nCount >= nLen) {
if (m_bLoop) { m_dProgress = 0.0; m_nTypeCount = 0; } // 循环重来
else { m_bPlaying = false; }
}
}
break;
m_nSpeed 的语义是"每秒字符数",所以进度增量是 dt * speed / len——
长度 20 字、速度 30 时,0.67 秒打完。改文本长度不用改速度,这是比"每帧加固定量"
更好的设计。
绘制时只取前 N 个字符:
void CCAnimationLabel::PaintTypewriter(HDC hDC, const RECT& rcPaint)
{
RECT rcText = /* 按 textpadding 收缩 */;
CDuiString sPart;
int nLen = m_sText.GetLength();
int nTake = CC::Clamp(m_nTypeCount, 0, nLen);
if (nTake > 0) sPart = m_sText.Left(nTake);
DrawLine(hDC, sPart.GetData(), rcText, 255);
// 光标:动画未结束时闪烁
if (m_bPlaying) {
double w = CC::TriangleWave(m_animClock.Total(), 0.9); // 0.9 秒一个来回
if (w > 0.5) {
HFONT hFont = (m_pManager != NULL && m_nFont >= 0) ? m_pManager->GetFont(m_nFont) : NULL;
SIZE sz = CC::MeasureText(hDC, hFont, sPart.GetData());
int x = rcText.left + sz.cx + 1;
int nH = (sz.cy > 0) ? sz.cy : 14;
int y = rcText.top + (CC::RectHeight(rcText) – nH) / 2;
RECT rcCur = { x, y, x + 2, y + nH };
CC::FillRoundRect(hDC, rcCur, 0, m_dwTextColor); // 2px 宽竖条
}
}
}
光标位置是实测文本宽度决定的(MeasureText),不是估算——所以中文、英文、
混排都跟着走。
1.4 跑马灯:偏移量 + 双重裁剪
case ALM_MARQUEE:
{
RECT rcClip = /* 按 textpadding 收缩 */;
HDC hDC = (m_pManager != NULL) ? m_pManager->GetPaintDC() : NULL;
HFONT hFont = (m_pManager != NULL && m_nFont >= 0) ? m_pManager->GetFont(m_nFont) : NULL;
SIZE szText = CC::MeasureText(hDC, hFont, m_sText.GetData());
int nTextW = szText.cx;
int nBoxW = CC::RectWidth(rcClip);
int nTextH = (szText.cy > 0) ? szText.cy : CC::RectHeight(rcClip);
// 一个完整周期 = 盒子宽 + 文字宽 + 8px 间隙
double dSpan = 0.0;
if (m_eDirection == MD_LEFT || m_eDirection == MD_RIGHT)
dSpan = (double)(nBoxW + nTextW + 8);
else
dSpan = (double)(CC::RectHeight(rcClip) + nTextH + 8);
if (dSpan <= 1.0) dSpan = 1.0;
m_dMarqueePos += dt * (double)CC::MaxOf(m_nSpeed, 1);
if (m_dMarqueePos >= dSpan) {
m_dMarqueePos = m_bLoop ? (m_dMarqueePos – dSpan) : dSpan;
if (!m_bLoop) m_bPlaying = false;
}
Invalidate();
}
break;
周期 dSpan = 盒子宽 + 文字宽 + 8 的物理含义:文字从"完全在右侧外面"滚到
"完全在左侧外面"正好走这么远。滚完一个周期才重置,视觉上无跳变。
绘制时按方向摆位置:
int nSaved = ::SaveDC(hDC);
::IntersectClipRect(hDC, rcClip.left, rcClip.top, rcClip.right, rcClip.bottom);
switch (m_eDirection) {
case MD_RIGHT: rcText.left = rcClip.right – (int)m_dMarqueePos; ... break;
case MD_UP: rcText.top = rcClip.bottom – (int)m_dMarqueePos; ... break;
case MD_DOWN: rcText.top = rcClip.top – nTextH + (int)m_dMarqueePos; ... break;
case MD_LEFT:
default: rcText.left = rcClip.left – (int)m_dMarqueePos; ... break;
}
UINT uStyle = (m_eDirection == MD_UP || m_eDirection == MD_DOWN)
? (DT_LEFT | DT_TOP | DT_SINGLELINE)
: (DT_LEFT | DT_VCENTER | DT_SINGLELINE);
DrawLine(hDC, m_sText.GetData(), rcText, 255);
if (nSaved != 0) ::RestoreDC(hDC, nSaved);
这里是双重裁剪:CCControlBase::DoPaint 已经裁到 rcPaint,PaintMarquee
再裁到 rcClip(考虑 textpadding)。DrawText 的 rcText 可以超出控件边界
(滚动中必然超出),靠 IntersectClipRect 挡住。
1.5 淡入淡出:GDI 无 alpha 文字的绕法
这是全系列最值得说的一个实现取舍。
::DrawText 画的文字没有 alpha——SetTextColor 的 RGB() 只有 24 位,没有 A 通道。
想做出"淡入",只能把文字颜色往背景色插值(这里是往黑底方向压暗):
void CCAnimationLabel::DrawLine(HDC hDC, LPCTSTR pText, RECT rcText, BYTE uAlpha)
{
if (pText == NULL || pText[0] == _T('\\0')) return;
HGDIOBJ hOldFont = NULL;
if (m_pManager != NULL && m_nFont >= 0) {
HFONT hFont = m_pManager->GetFont(m_nFont);
if (hFont != NULL) hOldFont = ::SelectObject(hDC, hFont);
}
::SetBkMode(hDC, TRANSPARENT);
// GDI 文本没有 alpha,淡入淡出时把文字色往黑底方向插值
DWORD dwColor = m_dwTextColor;
if (uAlpha < 255) {
DWORD a = (DWORD)uAlpha;
DWORD r = (((dwColor >> 16) & 0xFF) * a) / 255;
DWORD g = (((dwColor >> 8) & 0xFF) * a) / 255;
DWORD b = (( dwColor & 0xFF) * a) / 255;
dwColor = (dwColor & 0xFF000000) | (r << 16) | (g << 8) | b;
}
::SetTextColor(hDC, RGB((dwColor >> 16) & 0xFF, (dwColor >> 8) & 0xFF, dwColor & 0xFF));
UINT uStyle = m_uAlign;
if (m_bEllipsis) uStyle |= DT_END_ELLIPSIS;
::DrawText(hDC, pText, –1, &rcText, uStyle);
if (hOldFont != NULL) ::SelectObject(hDC, hOldFont);
}
透明度用三角波驱动,得到"渐亮 → 渐暗"的循环:
void CCAnimationLabel::PaintFade(HDC hDC, const RECT& rcPaint)
{
RECT rcText = /* 按 textpadding 收缩 */;
// 进度 0..1 映射成 0..255..0 的透明度
double dAlphaT = CC::TriangleWave(m_dProgress, 1.0);
BYTE uAlpha = (BYTE)(255.0 * dAlphaT);
DrawLine(hDC, m_sText.GetData(), rcText, uAlpha);
}
这个方案的适用边界要说清楚:它假设背景是深色。浅色皮肤下"压暗"会变成"变黑字",
观感不对。要真正透明文字,得走 BeginPath + TextOut + AlphaBlend 的路径,
成本高很多。本系列所有控件都跑在深色工业界面上,所以选了便宜方案。
1.6 闪烁:半周期显示
void CCAnimationLabel::PaintBlink(HDC hDC, const RECT& rcPaint)
{
RECT rcText = /* 按 textpadding 收缩 */;
// 简单闪烁:半个周期显示
if (CC::TriangleWave(m_animClock.Total(), 1.0) >= 0.5) {
DrawLine(hDC, m_sText.GetData(), rcText, 255);
}
}
注意它用 m_animClock.Total()(累计时间)而不是 m_dProgress——闪烁不需要
进度状态,直接用全局时钟,OnTick 里只要 Invalidate() 就行。
1.7 EstimateSize:按实测文本宽度撑开
SIZE CCAnimationLabel::EstimateSize(SIZE szAvailable)
{
(void)szAvailable;
SIZE sz = { 0, 0 };
if (m_sText.IsEmpty()) {
sz.cx = m_cXY.cx;
sz.cy = m_cXY.cy;
if (sz.cy == 0) sz.cy = 20; // 空文本给个最小高度,避免被压成 0
return sz;
}
HDC hDC = (m_pManager != NULL) ? m_pManager->GetPaintDC() : NULL;
HFONT hFont = (m_pManager != NULL && m_nFont >= 0) ? m_pManager->GetFont(m_nFont) : NULL;
SIZE tsz = CC::MeasureText(hDC, hFont, m_sText.GetData());
sz.cx = tsz.cx + m_rcTextPadding.left + m_rcTextPadding.right + 4;
sz.cy = tsz.cy + m_rcTextPadding.top + m_rcTextPadding.bottom + 2;
if (sz.cy <= 0) sz.cy = 20;
return sz;
}
+4 / +2 是防裁边的余量——GetTextExtentPoint32 返回的宽度偶尔比 DrawText
实际占用少 1px(特别是斜体/粗体),不留余量会切掉最后一个字的边缘。
1.8 属性表
| text | 字符串 | DuiLib 原生 |
| mode | typewriter / marquee / fade / blink / static | 动画模式 |
| speed | int | 打字机=字符/秒;滚动=像素/秒;夹到 1–2000 |
| direction | left / right / up / down | 滚动方向 |
| loop | bool | 是否循环,默认 true |
| textcolor | color | 文字色,默认 #FFE0E8F0 |
| font | int | DuiLib 字体索引,-1 = 默认 |
| align | left / center / right | 水平对齐 |
| textpadding | l,t,r,b | 文字内缩 |
| ellipsis | bool | 超长显示省略号 |
| play | bool | 属性驱动重播:play="true" 会 Restart() |
GetCNAttribute 额外提供两个只读查询:
| advertising | 当前文本(老 XML 的兼容名) |
| playing | "true" / "false" |
1.9 play 属性的妙用
else if (_tcsicmp(pstrName, _T("play")) == 0) {
// 允许外部用属性驱动重播
if (_tcsicmp(pstrValue, _T("true")) == 0) Restart();
else m_bPlaying = false;
}
因为 SetAttribute 是公开的,外部脚本(Lua)可以:
ctrl:SetAttribute("play", "true") — 重新播放
不用链接插件接口,也不用加专门的 Restart() 绑定。
二、CCAnimationVerLabel:纵向滚动字幕
2.1 和 AnimationLabel 的分工
| 文本 | 单行 | 多行(\\n 分隔) |
| 滚动 | 横向为主 | 纵向 |
| 模式 | 5 种 | 2 种(无缝 / 逐行) |
2.2 多行拆分:支持字面量 \\n
这里有一个必须记住的 DuiLib 限制:
DuiLib 的 XML 解析器只认 & < > " ' 这五个实体。

、
 这类数字实体会被原样当文本传进来,不会转成回车换行。
所以多行文本在 XML 属性里只能用字面量反斜杠转义:
<AnimationVerLabel text="第一行\\n第二行\\n第三行" speed="60" seamless="true" />
SplitLines() 同时支持真实换行符和字面量转义:
void CCAnimationVerLabel::SplitLines()
{
m_lines.clear();
LPCTSTR p = m_sText.GetData();
if (p == NULL || p[0] == _T('\\0')) return;
CDuiString sCur;
while (*p != _T('\\0')) {
// 1) 真实 CR / LF
if (*p == _T('\\r')) {
m_lines.push_back(sCur); sCur.Empty(); ++p;
if (*p == _T('\\n')) ++p; // 吃掉 CRLF 的 LF
continue;
}
if (*p == _T('\\n')) {
m_lines.push_back(sCur); sCur.Empty(); ++p;
continue;
}
// 2) XML 属性里写的字面量转义 \\n / \\r\\n / \\t
if (*p == _T('\\\\') && p[1] != _T('\\0')) {
if (p[1] == _T('n')) {
m_lines.push_back(sCur); sCur.Empty(); p += 2; continue;
}
if (p[1] == _T('r')) {
m_lines.push_back(sCur); sCur.Empty(); p += 2;
if (*p == _T('\\n')) ++p; continue;
}
if (p[1] == _T('t')) { sCur += _T(' '); p += 2; continue; } // Tab 变空格
// 其它反斜杠原样保留(比如 Windows 路径 C:\\Temp)
sCur += *p; ++p; continue;
}
sCur += *p;
++p;
}
m_lines.push_back(sCur); // ← 最后一行,即使为空也要推(保持行数语义)
}
其它反斜杠原样保留这条很关键——文本可能是路径 D:\\VCPro\\…,
不认识的转义不能吞掉字符。
2.3 无缝滚动:重复绘制形成首尾相接
纵向滚动的核心问题是"滚到底之后上面空了"。解法是把整段文本重复画 N 遍,
形成无限延伸的带子,只裁可视区:
void CCAnimationVerLabel::PaintCNContent(HDC hDC, const RECT& rcPaint)
{
if (m_lines.empty()) return; // ← 空状态直接返回
/* 选字体、SetBkMode、SetTextColor */
int nLineH = ResolveLineHeight(hDC);
int nStep = nLineH + m_nGap; // 每行占高 = 行高 + 间距
int nBoxH = CC::RectHeight(m_rcItem);
if (nBoxH <= 0) return;
int nSaved = ::SaveDC(hDC);
::IntersectClipRect(hDC, m_rcItem.left, m_rcItem.top, m_rcItem.right, m_rcItem.bottom);
int nCount = (int)m_lines.size();
int nTotal = nCount * nStep;
if (nTotal <= 0) nTotal = 1;
// 无缝模式下把整段文本重复绘制,形成首尾相接的滚动
int nRepeats = 2;
if (m_eMode == VSM_SEAMLESS && nTotal < nBoxH * 2 + nStep) {
nRepeats = (nBoxH * 2) / nTotal + 1;
if (nRepeats > 8) nRepeats = 8; // ← 上限 8 遍,防极端参数爆 CPU
}
int nStartY = m_rcItem.top – (int)m_dOffset;
for (int r = 0; r < nRepeats; ++r) {
for (int i = 0; i < nCount; ++i) {
int y = nStartY + r * nTotal + i * nStep;
// 完全在可视区外的行直接跳过
if (y > m_rcItem.bottom || y + nLineH < m_rcItem.top) continue;
RECT rcLine = { m_rcItem.left, y, m_rcItem.right, y + nLineH };
::DrawText(hDC, m_lines[i].GetData(), –1, &rcLine,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
}
}
if (nSaved != 0) ::RestoreDC(hDC, nSaved);
/* 还原字体 */
}
三个要点:
nBoxH*2/nTotal 可能算出上百遍,必须夹住;
不做可见性判断会很慢;
2.4 滚动推进:两种模式
void CCAnimationVerLabel::OnTick(double dt, double dTotal)
{
(void)dTotal;
if (m_lines.empty()) return; // ← 空状态不耗 CPU
HDC hDC = (m_pManager != NULL) ? m_pManager->GetPaintDC() : NULL;
int nLineH = ResolveLineHeight(hDC);
int nStep = nLineH + m_nGap;
int nTotal = (int)m_lines.size() * nStep;
int nBoxH = CC::RectHeight(m_rcItem);
if (nTotal <= 0 || nBoxH <= 0) return;
// —- 模式 A:无缝连续滚动 —-
if (m_eMode == VSM_SEAMLESS) {
m_dOffset += dt * (double)m_nSpeed;
if (m_dOffset >= (double)nTotal) {
if (m_bLoop) {
while (m_dOffset >= (double)nTotal) m_dOffset -= (double)nTotal; // ← while 不是 if
}
else {
m_dOffset = (double)nTotal;
}
}
Invalidate();
return;
}
// —- 模式 B:逐行推入:滚一行 -> 停 1.2 秒 -> 再滚下一行 —-
if (m_dStepHold > 0.0) { // 停留中
m_dStepHold -= dt;
return; // ← 停留期间不 Invalidate,省 CPU
}
m_dOffset += dt * (double)m_nSpeed;
if (m_dOffset >= (double)nStep) {
m_dOffset = 0.0;
m_dStepHold = 1.2;
// 完成一行后把首行挪到末尾
CDuiString sFirst = m_lines[0];
m_lines.erase(m_lines.begin());
if (m_bLoop) m_lines.push_back(sFirst);
Invalidate();
}
else {
Invalidate();
}
}
模式 B 用 erase(begin()) + push_back() 轮转行序,比维护"当前首行索引"更简单,
而且 m_lines 只有几十行,O(n) 的代价可以忽略。
while 而不是 if 的重置很关键:如果 dt 很大(或 speed 极高),
一次 Tick 可能超过多个周期,用 if 只会减一次,偏移会一直累积变大。
停留期间直接 return 不 Invalidate()——1.2 秒内画面完全静止,
没必要重绘。
2.5 行高解析
int CCAnimationVerLabel::ResolveLineHeight(HDC hDC) const
{
if (m_nLineHeight > 0) return m_nLineHeight; // 属性显式指定
HFONT hFont = (m_pManager != NULL && m_nFont >= 0) ? m_pManager->GetFont(m_nFont) : NULL;
SIZE sz = CC::MeasureText(hDC, hFont, _T("\\u56FD")); // 用"国"字量高度
return (sz.cy > 0) ? sz.cy : 16;
}
用汉字"国"而不是 “A” 来量高度——中文字形的实际行高比拉丁字母高,
用 “A” 量会导致中英文混排时行间距挤在一起。\\u56FD 是转义写法,源码里写汉字也行,
但转义能避免编码问题。
2.6 属性表
| text | 多行字符串 | 用 \\n 分隔 |
| speed | int | 像素/秒,夹到 1–2000 |
| seamless | bool | true = 无缝连续,false = 逐行推入 |
| scrollmode | seamless / step | 同上,语义化写法 |
| gap | int | 行间距,夹到 0–200 |
| loop | bool | 默认 true |
| textcolor | color | 默认 #FFDCE6F0 |
| font | int | 字体索引 |
| lineheight | int | 0 = 按字体自动;否则固定像素 |
| scrolltop | 任意 | 出现该属性就 ScrollToTop() |
只读查询:speed、linecount(行数)、offset(当前偏移)。
三、踩过的坑
3.1 XML 里的多行文本只能用字面量 \\n
原以为可以写:
<!– ✗ 无效 –>
<AnimationVerLabel text="第一行
第二行" />
实测 DuiLib 的 CDialogBuilder 只处理五个命名实体,数字实体原样保留,

 会变成屏幕上显示的 "
" 六个字符。
正确写法:
<!– ✓ 有效 –>
<AnimationVerLabel text="第一行\\n第二行" />
所以 SplitLines() 里必须专门处理 \\\\ + n。
3.2 edit 工具会剥掉 UTF-8 BOM
用文本编辑工具改 .cpp 后,BOM 会被剥掉。MSVC 随即按 GBK 读这个文件,
中文注释变成乱码,并且注释末尾的换行被吞掉——下一行的 inline 被吃进注释里,
报出 error C2001: 字符串字面量中的换行符 这种完全指向错误位置的错误。
改完文件必须补 BOM:
python _tools\\fix_bom.py .
3.3 淡入淡出假设深色背景
DrawLine 的 alpha 实现是把颜色往黑色压。如果控件放在浅色背景上,
“淡出"会变成"变黑字”,不是"变透明"。
要真正透明,需要:
// 代价高得多的做法:路径 + 离屏位图 + AlphaBlend
::BeginPath(hDC);
::TextOut(hDC, x, y, text, len);
::EndPath(hDC);
// 把路径填充到 32bpp DIB,再 AlphaBlend 到目标
本系列所有控件都跑在深色工业界面,选了便宜方案。换浅色皮肤时需要重做这里。
四、空状态处理
| AnimationLabel | text="" | m_bPlaying = false,OnTick 立即返回;PaintStatic 里 DrawLine 遇到空串直接返回 |
| AnimationVerLabel | text="" | m_lines 为空,PaintCNContent 与 OnTick 首句返回 |
| AnimationLabel 未设 mode | mode=static | 就是普通文本,不启动定时器(m_bPlaying=false,但 StartTick 仍会调——节拍在,只是 OnTick 空转,开销极小) |
五、XML 示例
<!– 打字机:每秒 30 字,带光标 –>
<AnimationLabel text="欢迎使用本款软件" mode="typewriter" speed="30"
font="1" textcolor="#FF20D0FF" align="center" />
<!– 跑马灯:从右往左,每秒 80 像素 –>
<AnimationLabel text="请注意,软件为简单Demo" mode="marquee"
direction="left" speed="80" loop="true" textcolor="#FFE8C33B" />
<!– 淡入淡出 –>
<AnimationLabel text="软件系统检测中" mode="fade" speed="40" textcolor="#FF35C0FF" />
<!– 闪烁 –>
<AnimationLabel text="检测" mode="blink" textcolor="#FFE04A3B" />
<!– 纵向无缝滚动字幕(三行) –>
<AnimationVerLabel text="08:00 软件启动\\n08:02 检测无更新\\n08:05 启动完毕"
speed="60" seamless="true" gap="4" lineheight="20"
textcolor="#FFDCE6F0" />
<!– 纵向逐行推入(每行滚完停 1.2 秒) –>
<AnimationVerLabel text="第一项:通过\\n第二项:通过\\n第三项:通过"
scrollmode="step" speed="100" gap="6" />
Demo示例效果




