第13章:覆盖操作层与触控区域

「柯南破案,真相只有一个」——阅读页里一个上滑,屏幕下三分之一浮出一层"操作面板"(13.1);这层面板凭什么能接住你的每一次点击,背后是一张动态注册的"触控区域表"在决定谁该响应(13.2);面板中央的格子最是刁钻,一个拨动触控硬件上电下电、一个数着局刷次数决定何时全刷(13.3);最后揭开一条"从阅读页直达设置、改完再回原页"的捷径(13.4)。

🔍

本章导师:柯南

核心方法论:真相只有一个——把"上滑 → 浮层 → 12 个按钮 → 命中检测"这条线索链逐环取证,每条结论都能在源码里找到唯一出处

「真相只有一个。阅读页里一个上滑,屏幕下三分之一多出一片浮层(13.1),浮层里 12 个按钮谁被点、谁响应,全由一张每次重绘都会重建的触控区域表说了算(13.2)。表中央的格子最是刁钻:一个拨动触控硬件上电下电,一个数着局刷次数、数够了才舍得全刷一次(13.3)。收尾的捷径更有意思:从阅读页直接钻回设置页,改完还能落回刚才那一页(13.4)。每一条线索,都能在代码里找到唯一的答案——真相只有一个。」

13.1 覆盖操作层是什么

先给"覆盖操作层"(overlay)下个定义,一句话说人话:它是盖在阅读页上方、临时呼出的一层半屏操作面板,用完即走。它只占屏幕下三分之一——代码里 area_y = (page_h * 2) / 3area_h = page_h - area_y——正文区域保持不动,浮层盖住底部,画满 12 个按钮。呼出它的动作是 UPGLIDE:触摸屏上往上一划(SF32_TouchControls.cpp 里上滑距离超过阈值 SWIPE_THRESHOLD = 100 像素就发这个动作),或者按键长按SF32_ButtonControls.cppBUTTON_LONG_PRESSED 也发 UPGLIDE)。这就是 README 说的"上滑/映射动作"。

呼出动作交给 handleEpubcase UPGLIDE:先检查 reader->is_overlay_active() 防止重复激活,然后 start_overlay()。这个启动函数一次性把浮层状态复位:overlay_active = true、选中格 overlay_selected = 0、跳页累积 overlay_jump_acc = 0、目标页 overlay_target_page = 当前页 + 1(1-based)。浮层一旦激活,阅读页的 UP/DOWN 就不再翻页,而是变成在 12 个按钮之间左右移动选中格(overlay_move_left/right,索引 (sel ± 1 + 12) % 12 循环)。

12 个按钮排成三行,代码里写得明明白白:const int cols[rows] = {3, 5, 4}。第一行三个格子是 「<」/ 中心 / 「>」——左右两个格子在两种"中心属性"之间切换,中间的格子显示并执行当前属性(13.3 展开);第二行五个格子是跳页步进 -5 / -1 / 目标页显示 / +1 / +5;第三行四个格子是 确认 / 目录 / 书库 / 设置。每次点跳页步进,overlay_adjust_target_page(d) 就把目标页 ±5 / ±1,并夹在 [1, 本章总页数] 之间;确认后(索引 8)current_page = 目标页 - 1 并关闭浮层。

/* src/main.cpp:UPGLIDE 呼出覆盖操作层(防重复激活 + 初始同步触控状态) */
case UPGLIDE:
    // 激活阅读页下半屏覆盖操作层
    // 防止重复激活
    if (!reader->is_overlay_active()) {
        reader->start_overlay();
        // 默认中心属性为触控开关,初始同步当前触控状态
        reader->overlay_set_center_mode_touch();
        if (touch_controls)
            reader->overlay_set_touch_enabled(touch_controls->isTouchEnabled());
    }
    break;
/* lib/Epub/EpubList/EpubReader.cpp:render_overlay 的三行布局(3 + 5 + 4)与标签填充(节选) */
// 三行布局:3, 5, 4(第三行加了"设置"按钮)
const int rows = 3;
const int cols[rows] = {3, 5, 4};
const int gap_w = 10;
...
switch (idx)
{
  case 0: rt_snprintf(label, cap, "<"); break;
  case 1: /* 中心:触摸开关 / 全刷周期(13.3)*/ break;
  case 2: rt_snprintf(label, cap, ">"); break;
  case 3: rt_snprintf(label, cap, "-5"); break;
  case 4: rt_snprintf(label, cap, "-1"); break;
  case 5: /* 显示 "目标页/总页数" */ break;
  case 6: rt_snprintf(label, cap, "+1"); break;
  case 7: rt_snprintf(label, cap, "+5"); break;
  case 8: rt_snprintf(label, cap, "确认"); break;
  case 9: rt_snprintf(label, cap, "目录"); break;
  case 10: rt_snprintf(label, cap, "书库"); break;
  case 11: rt_snprintf(label, cap, "设置"); break;
  default: label[0] = '\0'; break;
}
索引标签作用
0 / 2< / >切换中心属性:触控开关 ⇄ 全刷周期
1触摸开关 / 全刷周期显示当前中心属性;执行它(拨触控 / 循环全刷周期)
3 / 4 / 6 / 7-5 / -1 / +1 / +5跳页步进,把目标页 ±5 / ±1(夹在 1 与本章总页数之间)
5目标页/总页数显示累积出的目标页(只显示,不响应动作)
8确认current_page = 目标页 - 1,关闭浮层(含点浮层外正文区)
9目录进入章节目录页
10书库返回书库页
11设置进入阅读设置(13.4,带锚点恢复)
柯南提示

线索要对齐。注意"防重复激活"这道防线:is_overlay_active() 既挡触摸侧(上滑只在浮层未激活时触发),又挡按键侧(长按同理)。两层输入走同一条 UPGLIDE 管道,却因为这道检查不会互相打架——这是"多种输入共享一个动作语义"的标准写法。

13.2 UIRegionsManager:触控区域动态注册与命中检测

先说两个词。浮层上 12 个按钮,每个都对应屏幕上一块矩形区域——叫触控区域(region);判断"你点下的坐标落在哪块区域里、该触发哪个动作",叫命中检测(hit test)。整个机制的核心是 UIRegionsManager.cpp:它维护一个全局数组 g_area_array[MAX_AREAS],每一项是一个 AreaRect——结构体里只有四个坐标字段 start_x / start_y / end_x / end_y,外加一个 UIAction action 字段,容量上限 MAX_AREAS = 30

它提供三个函数,分工非常清晰:clear_areas() 把计数 g_area_count 归零(数组内容一并 memset 清零),每次重绘页面都要先清一次add_area(x, y, w, h)动态注册——追加到数组尾部、计数加一,满了返回 falsestatic_add_area(x, y, w, h, index)静态注册——直接写进指定的下标,不占用"尾部追加"的语义。覆盖层的 render_overlay() 用的是动态版(每画一个按钮就 add_area 一次,下标天然 0..11),而设置页用的是静态版(按 SET_TOUCH * 3 这样的公式定位到固定下标,因为设置项的行号是固定的)。

一个容易被忽略的真相:真正的命中检测并不在 UIRegionsManager 里。这个文件只管"区域表的读写",判断坐标落在哪块区域、映射成哪个动作,是触控回调 SF32_TouchControls::tp_rx_indicate 里那个巨大的 switch (ui_state) 干的——每个页面状态一段 if (x >= g_area_array[i].start_x && ...) 逐块比对。区域表是"地",命中检测是"判",两者各司其职。还有个值得如实标注的细节:AreaRect 里那个 action 字段从头到尾没被赋值——动作映射写在触控回调里,区域表只是纯坐标。

对比更清楚:阅读页浮层未激活时的左右翻页,用的不是区域表,而是两个硬编码矩形——x 在 10..200、y 在 10..1010 算上一页,x 在 550..750 算下一页。这就是"动态注册"和"硬编码"两种做法的活例子:区域表随重绘重建、位置灵活,硬编码写死、简单粗暴。浮层激活后命中检测切回区域表:逐个比对 0..11 号按钮,命中就 touch_sel = btnSELECT;若点到浮层外、即正文区(y < area_y),则 touch_sel = 8——等于按了一次"确认",关闭浮层。

/* src/UIRegionsManager.cpp:区域表的清空与两种注册方式 */
void clear_areas()
{
    g_area_count = 0;
    memset(g_area_array, 0, sizeof(g_area_array));
}

// 动态添加:追加到数组尾部,满了返回 false
bool add_area(int x, int y, int width, int height)
{
    if (g_area_count >= MAX_AREAS) {
        return false; // 数组已满
    }
    g_area_array[g_area_count].start_x = x;
    g_area_array[g_area_count].start_y = y;
    g_area_array[g_area_count].end_x = x + width;
    g_area_array[g_area_count].end_y = y + height;
    g_area_count++;
    return true;
}

// 静态添加:直接写到指定下标(设置页按 SET_* * 3 定位)
bool static_add_area(int x, int y, int width, int height, int index)
{
    if(index < 0 || index >= MAX_AREAS) {
        return false; // 索引超出范围
    }
    g_area_array[index].start_x = x;
    g_area_array[index].start_y = y;
    g_area_array[index].end_x = x + width;
    g_area_array[index].end_y = y + height;
    return true;
}
/* src/boards/controls/SF32_TouchControls.cpp:浮层激活时的命中检测(READING_EPUB 分支) */
case READING_EPUB:
    if (reader && reader->is_overlay_active())
    {
      // overlay 激活时:检测 12 个按钮的触控区域(由 render_overlay 通过 add_area 注册)
      bool hit = false;
      for (int btn = 0; btn < 12; btn++)
      {
        if (x >= g_area_array[btn].start_x && x <= g_area_array[btn].end_x &&
            y >= g_area_array[btn].start_y && y <= g_area_array[btn].end_y)
        {
          touch_sel = btn;
          action = SELECT;
          hit = true;
          break;
        }
      }
      // 点击 overlay 区域外的正文部分 → 关闭 overlay(相当于按确认)
      if (!hit)
      {
        int area_y = (instance->renderer->get_page_height() * 2) / 3;
        if (y < area_y)
        {
          touch_sel = 8; // 确认
          action = SELECT;
        }
      }
    }
API行为典型调用方
clear_areas()清空区域表(计数归零 + 数据清零)每个页面的渲染函数开头
add_area(x, y, w, h)动态追加,尾部计数加一;满 30 返回 false覆盖层 render_overlay、主页面、天气页
static_add_area(x, y, w, h, idx)按指定下标写入,不占尾部语义设置页 SET_* * 3、书库/目录列表项
命中检测坐标 vs g_area_array[i] 逐块比对,映射 UIActionSF32_TouchControls::tp_rx_indicateswitch(ui_state)
柯南提示

证据要区分"谁画的"和"谁判的"。区域表是纯坐标的"地",动作映射写在触控回调里;AreaRect.action 字段有声明没赋值。读代码时别被结构体字段带偏——结构体里声明了什么,不等于真的用了什么。动手改项目时,若想让区域自带动作,就把 action 填上并在命中检测里直接读取,能少写半页 if

13.3 中心功能:触控开关与全刷周期

浮层第一行的中央格子(索引 1)是"执行键",左右两个箭头(索引 0 / 2)负责在两种中心属性之间切换:OverlayCenterMode { CENTER_TOUCH = 0, CENTER_FULL_REFRESH = 1 }。切到"触控开关"时,中央格显示 触摸开关:开/关;切到"全刷周期"时,显示 全刷周期:N次/每次。按中央格,就执行当前属性。

属性一:触控开关。它不只改一个软件标志,还同步触控硬件上电 / 下电——README 原话"同步触控硬件上电/下电"。执行分支里:touch_controls->setTouchEnable(!cur) 翻转软件开关,然后若原来关着就 powerOnTouch()、原来开着就 powerOffTouch()。这两个函数最终调用 rt_device_control(tp_device, RTGRAPHIC_CTRL_POWERON / POWEROFF),真正把触控芯片的电给断了(省电)或重新给上(恢复触控)。连开机都有一条:SF32_TouchControls 构造函数里,若启动时 !touch_enable,就直接把触控设备 POWEROFF——软件开关与硬件电源始终对齐。

属性二:全刷周期。墨水屏局刷久了会残影,所以刷新计数满一个周期就强制全刷一次。选项定义在 epub_screen.cppkFullRefreshOptions[] = {5, 10, 20, 0},默认下标 1 即 10 次screen_cycle_full_refresh_period() 循环切换。执行分支里调 reader->overlay_cycle_full_refresh()(内部就是 screen_cycle_full_refresh_period(true)),再 set_part_disp_times(当前周期值) 应用到底层显示驱动。驱动里 g_part_disp_times = val > 0 ? val : 1,每刷 g_part_disp_times 次(reflesh_times % g_part_disp_times == 0)全刷一次——所以"每次(0)"会被归一成 1,等于每次都全刷。

关键观察:浮层与功能设置页共享同一份全局状态screen_cycle_full_refresh_period / screen_get_full_refresh_periodepub_screen.cpp 里的全局函数,设置页第 3 行和浮层中央格改的是同一个 full_refresh_idx——在设置页把全刷周期改成 20 次,回到浮层中央格看到的也是 20 次。这一设计避免了两处各存一份的"状态分叉",代价是两者永远联动。

/* src/main.cpp:浮层中央格(sel==1)执行当前中心属性 */
else if (sel == 1 || touch_sel == 1)
{
    // 中心矩形:根据当前属性执行
    if (reader->overlay_is_center_touch())
    {
        bool cur = touch_controls ? touch_controls->isTouchEnabled() : false;
        if (touch_controls)
        {
            touch_controls->setTouchEnable(!cur);
            if (!cur) touch_controls->powerOnTouch(); else touch_controls->powerOffTouch();
        }
        reader->overlay_set_touch_enabled(!cur);
    }
    else
    {
        reader->overlay_cycle_full_refresh();  // 5/10/20/每次(0) 之间循环
        set_part_disp_times(reader->overlay_get_full_refresh_value());
    }
}
/* src/epub_screen.cpp:全刷周期选项表 + src/boards/display_dbi/epd_display.c 的应用 */
// 全刷周期选项:5、10、20、每次(0)
static const int kFullRefreshOptions[] = {5, 10, 20, 0};
static int full_refresh_idx = 1; // 默认10次

void screen_cycle_full_refresh_period(bool refresh)
{
  if(refresh)
    full_refresh_idx = (full_refresh_idx + 1) % kFullRefreshOptionsCount;
  else
    full_refresh_idx = (full_refresh_idx - 1 + kFullRefreshOptionsCount) % kFullRefreshOptionsCount;
}

/* epd_display.c:局刷计数到周期即全刷 */
static int g_part_disp_times = 10;  // 局刷 g_part_disp_times-1 次后全刷一次
static int reflesh_times = 0;
void set_part_disp_times(int val)
{
    g_part_disp_times = val > 0 ? val : 1;  // "每次(0)"归一成 1 → 每次都全刷
    reflesh_times = 1;
}
/* 刷新时:if (reflesh_times % g_part_disp_times == 0) { 全刷 } else { 局刷 } */
选项下标驱动实际值(g_part_disp_times刷新行为
5 次05局刷 4 次后第 5 次全刷
10 次(默认)110局刷 9 次后第 10 次全刷
20 次220局刷 19 次后第 20 次全刷
每次(0)31reflesh_times % 1 == 0 恒真 → 每次都全刷
柯南提示

真相藏在"共享"二字里。设置页与浮层改的是同一个 full_refresh_idx,这才让两处显示永远一致。但也提醒你:全局可变状态是双刃剑——浮层中央格能改全局全刷周期,改动会"泄漏"回设置页。读这类项目时,凡见全局 static 变量,先问一句"还有谁在改它"。

13.4 从阅读页直接进阅读设置并恢复位置

README 把这条捷径标为"新增":覆盖操作层第 12 格「设置」可从阅读页直接进入阅读设置(字体/字号/行距/边距),退出后自动恢复到当前阅读位置。入口代码在 main.cppsel == 11 分支:先 reader->save_anchor(g_anchor_block, g_anchor_line) 把"当前读到哪"存进两个全局变量,置 g_has_anchor = true,再记下 g_state_before_settings = READING_EPUB,随后 stop_overlay()、切到 READING_SETTINGS 状态并 reading_settings_draw()

锚点(anchor)是什么?它把"当前页"翻译成"当前页开头在正文里的块/行坐标":save_anchor 调用 parser->get_page_anchor(state.current_page),拿到 PageAnchor{block_index, line_index}。为什么存坐标而不是页码?因为改完字号、行距、边距后整本书重新排版,页码会变——但"第几块正文的第几行"这个语义位置不会变。退出时 restore_by_anchor 反向用锚点 parser->find_page_by_anchor(anchor) 重新找到排版后的新页码,这就是"自动恢复到当前阅读位置"的原理。注意:若锚点找不到(极端情况下排版吞掉了那行),代码只打个 warning,停在当前页不蹦跶——容错处理得很克制。

退出路径由 g_state_before_settings 决定,这也是两处入口的对照。若从功能设置页进(handleSettingsPage 返回 2,此时 g_state_before_settings = SETTINGS_PAGE),退出回设置页;若从阅读页浮层进(g_state_before_settings = READING_EPUB),退出回阅读页——delete reader 后用 handleEpub(renderer, NONE) 按新设置重建阅读器,再 restore_by_anchor 定位。README 明说"退出后自动恢复到当前阅读位置",锚点正是这句话的实现。

/* src/main.cpp:浮层「设置」格 → 存锚点、进阅读设置 */
else if (sel == 11 || touch_sel == 11) //设置(阅读设置)
{
    // 保存锚点
    reader->save_anchor(g_anchor_block, g_anchor_line);
    g_has_anchor = true;
    g_state_before_settings = READING_EPUB;
    renderer->set_margin_bottom(0);
    reader->stop_overlay();
    ui_state = READING_SETTINGS;
    reading_settings_draw(renderer);
    touch_sel = -1;
    return;
}
/* src/main.cpp:READING_SETTINGS 退出 → 按来源回阅读页(用锚点恢复位置) */
case READING_SETTINGS:
{
    bool still_in_settings = reading_settings_handle_action(renderer, ui_action);
    if (!still_in_settings) {
        if (g_state_before_settings == READING_EPUB) {
            // 从阅读页的覆盖层进入的 → 回到阅读页
            ui_state = READING_EPUB;
            delete reader;
            reader = nullptr;
            handleEpub(renderer, NONE);
            // 用锚点恢复阅读位置
            if (g_has_anchor && reader) {
                reader->restore_by_anchor(g_anchor_block, g_anchor_line);
                reader->render();
                g_has_anchor = false;
            }
        } else {
            // 从主界面设置页进入的 → 回到设置页
            ui_state = SETTINGS_PAGE;
            (void)handleSettingsPage(renderer, NONE, true);
        }
    }
    return;
}
入口设置 g_state_before_settings退出回到是否用锚点恢复
功能设置页 → 阅读设置SETTINGS_PAGE设置页否(只回设置页)
阅读页浮层 → 设置格READING_EPUB阅读页(重建阅读器)是(save_anchor/restore_by_anchor
柯南提示

这条捷径的证据链最完整:入口存锚点(save_anchor)→ 中间改排版(reading_settings_handle_action)→ 退出重建阅读器 → 锚点找回新页码(restore_by_anchor)。核心思想一句话:排版可变,语义位置不可变——所以存"第几块第几行"而不是"第几页"。以后做任何"改配置后回原位置"的功能,都可以照搬这套锚点思路。

章末练习

练习 1:十二格地图 入门

对照 13.1,写出覆盖操作层三行按钮的完整布局(每行几个、索引范围、各自作用)。哪一格只显示、不响应动作?

提示

三行 3 + 5 + 4,共 12 格,索引 0..11;索引 5 显示"目标页/总页数"。

参考答案

第一行(3 格,索引 0-2):「<」/ 中心属性 / 「>」,左右切换中心属性、中央执行;第二行(5 格,索引 3-7):-5 / -1 / 目标页显示 / +1 / +5;第三行(4 格,索引 8-11):确认 / 目录 / 书库 / 设置。索引 5 只显示当前累积的目标页与本章总页数,不响应动作。

练习 2:谁在画、谁在判 进阶

add_areastatic_add_area 有什么区别?真正的命中检测(坐标→动作)发生在哪个文件、以什么结构组织?AreaRect 里的 action 字段被赋值了吗?

提示

前者追加到尾部,后者写指定下标;命中检测在 SF32_TouchControls.cpp 的 switch(ui_state);action 字段从头到尾没人写。

参考答案

add_area 动态追加到数组尾部(满了返回 false),适合浮层这种"每画一个按钮注册一个"的场景;static_add_area 直接写到固定下标,适合设置页按 SET_* * 3 公式定位。命中检测在 SF32_TouchControls::tp_rx_indicate 里,按 ui_state 分页面、再逐块比对坐标映射成 UIActionAreaRect.action 只是结构体里声明了,从未赋值——动作映射写在触控回调里,区域表只存坐标。

练习 3:中心属性的两副面孔 进阶

浮层中央格有哪两种中心属性?各自按下去会执行什么(特别是"触控开关"为什么能真正省电)?为什么浮层里改的全刷周期在功能设置页也生效?

提示

OverlayCenterMode 两个值;powerOnTouch/powerOffTouch 调 rt_device_control 的 POWERON/POWEROFF;两者共享 epub_screen.cpp 的全局 full_refresh_idx。

参考答案

两种属性:触控开关(CENTER_TOUCH)与全刷周期(CENTER_FULL_REFRESH)。触控开关按下时,翻转 touch_enable 并调用 powerOnTouch()/powerOffTouch(),后者经 rt_device_control(..., RTGRAPHIC_CTRL_POWERON/POWEROFF) 真正切断/恢复触控芯片电源,所以能省电。全刷周期按下时循环 screen_cycle_full_refresh_period(true)set_part_disp_times 应用到显示驱动。因为浮层与设置页共享 epub_screen.cpp 里的全局 full_refresh_idx,改一处、两处同步显示。

练习 4:锚点破案 挑战

从浮层进阅读设置后,"自动恢复到当前阅读位置"靠什么实现?为什么存的是块/行坐标而不是页码?退出时若锚点找不到,代码会怎么处理?

提示

save_anchor 拿 get_page_anchor;restore_by_anchor 拿 find_page_by_anchor;找不到只 ulog_w 不蹦跳,停在当前页。

参考答案

入口用 save_anchor(g_anchor_block, g_anchor_line) 把当前页开头的 PageAnchor{block_index, line_index} 存进全局变量;退出时重建阅读器后 restore_by_anchorfind_page_by_anchor 在新排版里找到锚点对应的页码并渲染。存坐标而非页码,是因为改字号/行距/边距后整书重排、页码会变,而"第几块正文的第几行"语义位置稳定。若找不到锚点,代码只打 warning 并保持当前页,不会崩溃或乱跳——这是精心设计的容错。