Skip to content

Navigation

lingion edited this page Sep 12, 2026 · 1 revision

Navigation

本文基于 v1.0.54(versionCode 58)。 TL;DR:单 Activity 组合态导航——四个 Tab、一个 overlay 导航栈、贴底/悬浮 Dock 双形态底栏、四层返回键语义。面向开发者。

单 Activity,无 Navigation 库

导航由 MainActivity.AppRoot 的组合状态驱动,未引入 Jetpack Navigation。核心状态四个:

状态 类型 作用
currentTab Tab 枚举 当前底部 Tab
overlayStack List<OverlayScreen> overlay 导航栈,栈顶为可见页
navDock Boolean 底栏形态,true = 悬浮 Dock
scheduleViewMode ViewMode 课表周视图/网格,会话级

四个 Tab 定义在 Tab 枚举(app/src/main/java/com/lingion/sleepy/MainActivity.kt:171):Schedule(课表)、Today(今日)、Manage(课表管理)、Mine(我的),标签取 R.string.tab_*(app/src/main/res/values/strings.xml:15-18),图标用 Material outlined 系列。底栏选中项直接传 currentTab.ordinal(MainActivity.kt:397)。

overlay 导航栈

栈页集合即 OverlayScreen 枚举的全部 12 个值(MainActivity.kt:178-180):

AddCourse, AllTables, EditTable, Theme, General, Holiday,
Export, Reminder, About, License, WidgetManagement, WidgetEdit

pushOverlay 入栈、popOverlay 弹一层、popToRoot 清空(MainActivity.kt:208-210)。渲染走条件组合:每个 if (topOverlay() == X) 分支画出自己的页面后 return,栈顶决定哪一页占屏。最深一条链是 我的 → 通用设置 → 桌面组件管理 → 编辑桌面组件,栈深 3。

入口对照

栈页 入口 代码锚点
AddCourse 课表页手动加课;管理页手动加课 MainActivity.kt:495,508
AllTables 我的 → 全部课表 MainActivity.kt:515
EditTable 全部课表点某张表;管理页编辑当前表;新建课表自动入栈 MainActivity.kt:296,415,508
Theme 我的 → 外观 MainActivity.kt:516
General 我的 → 通用设置 MainActivity.kt:517
Holiday 通用设置 → 节假日 MainActivity.kt:320
Export 我的 → 导出;管理页导出 MainActivity.kt:518,508
Reminder 我的 → 提醒 MainActivity.kt:519
About 我的 → 关于 MainActivity.kt:520
License 关于 → 开源声明 MainActivity.kt:348
WidgetManagement 通用设置 → 桌面组件管理 MainActivity.kt:321
WidgetEdit 桌面组件管理选中某一项 MainActivity.kt:364

深链进入编辑课程

桌面组件点课程走 intentForCourse():FLAG 一次性 Intent 带 EXTRA_COURSE_ID,MainActivity 查库取出 CourseEntity 放进 editingCourseFlow,AppRoot 的 LaunchedEffect 消费后置 editingCourse,直接进入编辑课程会话(MainActivity.kt:82-88,157-168,241-243)。同一 courseId 重复投递会被去重(MainActivity.kt:159)。

导航参数与栈同寿命,单独 rememberSaveable 持久化(MainActivity.kt:215-218):editTableIdpendingNewTableIdpreviousDefaultTableIdwidgetEditId。原因写在注释里:旋转恢复后若参数归 null,EditTabletableId = null 语义是「编辑当前课表」,会静默改错表。

旋转与进程恢复

overlayStackrememberSaveable 加自定义 Saver 保存(MainActivity.kt:200-205)。save 侧带守卫:

save = { stack -> if (editingCourse == null) stack else emptyList() }

正在编辑课程时栈存空,恢复后回到主 Tab。理由:编辑会话的 CourseEntity 无法写进 Bundle,恢复成空表单会被当成新课程再填一遍,产生重复课程(MainActivity.kt:197-199 注释)。currentTabscheduleViewMode 用普通 remember 持有,Activity 重建后按默认值(课表 Tab、启动视图)重新初始化。

被覆盖页的状态保真

条件组合会把被覆盖页整体移出组合树,remember/rememberSaveable 状态随之销毁——通用设置二级页返回后滚动归零、折叠卡全收起、Tab 往返丢滚动位置,根因都在这里(MainActivity.kt:234-238 注释)。修复方式:AppRoot 取 rememberSaveableStateHolder(),每个 overlay 分支内容包进 SaveableStateProvider("稳定字符串 key");页面被覆盖时状态存进 holder,返回原样恢复,覆盖滚动位置、折叠展开、输入框内容(MainActivity.kt:239, 分支见 288-377)。四个 Tab 的内容同样包 SaveableStateProvider(currentTab.name)(MainActivity.kt:490-513)。

BackRestoreSaveableContractTest(app/src/test/java/com/lingion/sleepy/BackRestoreSaveableContractTest.kt)用 7 条结构契约锁住这套接线:AppRoot 必须有 holder 且三个报障页(General/Holiday/WidgetManagement)必须包裹;AddCourse 分支必须留在包裹之外;四个 Tab 必须包裹;GeneralSettingsScreen.expandedSections 必须 rememberSaveable;教务导入的独立 Activity JwImportActivity 的 stage 分支同样包裹(key = stage 类名);选校页搜索词 query 必须 rememberSaveable;栈 saver 的编辑会话守卫不得删。教务直连导入是独立 Activity,不在本栈内;其 stage 机(选校 → WebView 登录 → 确认)套用同一套 SaveableStateProvider 方案,选校列表滚动位置与搜索词跨 WebView 往返保真。

Tab 切换有一条动线特例:导入完成留在管理页。此前硬跳课表页,打断「复制副本 → 追加导入 → 继续操作」的连续操作,现在摘要卡就地刷新(MainActivity.kt:509-511 注释)。另有空表导入引导:无表空态切到管理页时自动弹一次 ImportSheet,会话级一次性 flag,消费即清(MainActivity.kt:95-98)。

编辑课程会话例外

AddCourse 分支刻意不包 SaveableStateProvider,保持裸组合(MainActivity.kt:280-287)。编辑/新增课程会话在旋转、进程恢复时安全丢弃:恢复不出表单,就没有重复加课。这条例外与栈 saver 的守卫是同一条设计,契约测试单独断言该分支内不得出现 SaveableStateProvider,并要求注释锚定理由——防止后人顺手补全把例外抹掉。

底栏:两种形态

设置入口在 我的 → 通用设置 → 底栏样式,选项「贴底 / 悬浮」(app/src/main/res/values/strings.xml:322-324)。持久化键 KEY_NAV_DOCK = "nav_dock",布尔,默认 false(贴底)(app/src/main/java/com/lingion/sleepy/util/AppPrefs.kt:77,244)。navDock 真值在 AppRoot,设置页改完底栏即时切换,无需重建 Activity(MainActivity.kt:231);切换走 GeneralSettingsScreenonNavDockChange 回调与 AppPrefs.setNavDock(app/src/main/java/com/lingion/sleepy/ui/screen/mine/GeneralSettingsScreen.kt:647-652)。

两种形态共用一套动画:单个 secondaryContainer thumb 色块由弹簧(Animatable + spring)在栏上从旧 Tab 滑到新 Tab,图标按区间覆盖率 lerp 变色,文字逐字符扫过上色(app/src/main/java/com/lingion/sleepy/ui/component/PillNavigationBar.kt:79-90)。thumb 几何取实测真值:贴底形态各 Tab 中心/文字左缘用 onGloballyPositioned 实测(positionInRoot 差值),SpaceEvenly 加变宽 label 的实际分布不靠推算;Dock 形态座位定宽,直接按公式算中心(PillNavigationBar.kt:87-89,290-296)。布局变化(旋转/语言/形态)时重测重定位,thumb 平滑滑到新位置。动画参数两形态一致:高硬度无回弹弹簧,首次定位 snapTo,之后 animateTo(PillNavigationBar.kt:127-139,298-310)。

贴底(nav_dock = false,默认)

标准 Scaffold bottomBar 占位,内容止于栏上沿(MainActivity.kt:390-421)。通栏矩形:背景 surfaceContainer,吃 navigationBars insets,上下内边距 6dp / 8dp,行高 76dp(PillNavigationBar.kt:159-190)。thumb 色块 64×32dp,图标 20dp。

悬浮 Dock(nav_dock = true)

去掉 bottomBar 占位,内容 fillMaxSize 通到屏幕底,Dock 作为悬浮层贴 Alignment.BottomCenter 叠在内容上(MainActivity.kt:422-471)。裸 Box 没有 Scaffold 的 insets,顶部显式补 statusBars,否则课表顶栏顶进摄像头挖孔区(MainActivity.kt:424-425 注释)。胶囊外观:surfaceContainer 半透明底(alpha 0.86)+ 12dp 黑色柔投影,圆角 50%(PillNavigationBar.kt:287-288,320-324)。

几何常量集中在 NavDockSpec(PillNavigationBar.kt:69-76):

常量 含义
horizontalMargin 16dp 左右距屏幕边缘
bottomFloat 12dp 胶囊底边悬于手势条上方的高度
capsuleHeight 64dp 胶囊本体高,icon+label 双行,比贴底 76dp 收一档
itemSeat 56dp 每 Tab 座位宽,满足 ≥48dp 触摸目标
itemGap 4dp 座位间距
innerPad 8dp 胶囊内边距

胶囊总宽按公式 itemSeat×4 + itemGap×3 + innerPad×2 算出,四个 Tab 为 252dp(PillNavigationBar.kt:312-313)。thumb 56×52dp、y 偏移 6dp,完整罩住座位内容(PillNavigationBar.kt:333-343),图标 24dp。

Dock 悬在内容上层,各 Tab 页的滚动容器经 LocalNavExtraBottomPadding(PillNavigationBar.kt:66)追加滚动余量:首帧前用 capsuleHeight + bottomFloat(76dp)估算,onGloballyPositioned 实测 Dock 总高后覆盖,保证最后一项能滚到 Dock 上方完全可见(MainActivity.kt:386-388,432-434,454-457)。

返回键:四层语义

三个 BackHandler,enabled 条件互斥,按优先级排列。

第一层+第二层在同一个 handler(MainActivity.kt:250-257),enabled = 栈非空 或 编辑课程中:

  • 新建课表未确认(pendingNewTableId != null):新建课表会立即写库(createEmptyTable(commitSelection = false)),pendingNewTableId 标记它未确认;按返回触发 mainVm.discardNewTable(discardId, fallback) 删掉空表并恢复原默认表,再 popToRoot() 清空整个栈(MainActivity.kt:251-255)。页面内的「放弃」按钮走同款清理,但只 popOverlay(MainActivity.kt:302-305)。
  • 其余情况弹一层:编辑课程会话就地丢弃(editingCourse = null,不保存)并 popOverlay;纯 overlay 每次只退一级——通用设置 → 假期设置按返回只回通用设置,栈化之前一按退两级(MainActivity.kt:193-196 注释)。来自桌面组件/深链的编辑会话(EXTRA_COURSE_ID,MainActivity.kt:82-88,241-243)同样在此丢弃。

第三层(MainActivity.kt:265-267):无 overlay、无编辑、当前 Tab 不是课表 → currentTab = Tab.Schedule,回课表页。课表页是首页,其他三个 Tab 的返回都先回家。

第四层(MainActivity.kt:268-278):已在课表页 → 双击退出。距上次按返回不足 2000ms(SystemClock.elapsedRealtime 计时)才 finish();否则记录时间戳并弹 Toast「再按一次返回退出」(R.string.exit_press_back_again,strings.xml:30)。

系统返回键
  │
  ├─ overlay 栈非空 或 编辑课程中?(BackHandler #1)
  │    ├─ pendingNewTableId 非空 → 新建课表未确认
  │    │      discardNewTable 删空表、恢复原默认表
  │    │      popToRoot 清空整个栈
  │    └─ 否则 → 弹一层
  │           编辑课程会话 → 就地丢弃,不保存
  │           纯 overlay   → popOverlay,每次只退一级
  │
  ├─ 无 overlay、非编辑,当前 Tab ≠ 课表(BackHandler #2)
  │    └─ currentTab = Schedule,回课表页
  │
  └─ 无 overlay、非编辑,当前 Tab = 课表(BackHandler #3)
       ├─ 距上次按返回 < 2000ms → finish() 退出
       └─ 否则 → 记录时间戳,Toast「再按一次返回退出」

课表视图模式的会话契约

scheduleViewMode 用普通 remember 持有,初始化只读启动默认:AppPrefs.getStartView(context) 返回 "cards"ViewMode.Cards,否则 ViewMode.Full(MainActivity.kt:226-230)。启动默认键 KEY_START_VIEW = "start_view",字符串,默认 "full"(AppPrefs.kt:58,384)。

会话内切换视图只改这个状态,不回写 prefs——启动默认与会话内切换是两条独立通道(MainActivity.kt:222-225 注释)。状态提升到 AppRoot 层的原因:overlay(加课/编辑课程)与 Tab 切换都会把 ScheduleScreen 整页移出组合树,状态留在页内就会丢;用户撞到的现象是「网格视图编辑课程,保存后被弹回周视图,每次都要手动切回」(ScheduleViewModeSessionContractTest.kt:7-15)。

契约测试 app/src/test/java/com/lingion/sleepy/ui/screen/schedule/ScheduleViewModeSessionContractTest.kt 锁 8 条:ScheduleScreen 禁止自读 AppPrefs.getStartView;viewMode / onViewModeChange 必须参数注入;页内禁止自持 var viewMode by remember;ViewMode 枚举禁 private;AppRoot 必须持 scheduleViewMode 会话态且初值读启动默认;两个 MainTabs 调用位(贴底 Scaffold 与 Dock)都必须注入;ScheduleScreen 调用位必须接线;全文禁止出现 AppPrefs.putStartView 调用。

两个契约测试仓库里都没有 Robolectric/Compose UI 测试环境,采用结构锁定:读源文件做正则断言,声明式接线在源头等价于读编译产物(BackRestoreSaveableContractTest.kt:17-19 注释)。

相关页面

源码:app/src/main/java/com/lingion/sleepy/MainActivity.kt · app/src/main/java/com/lingion/sleepy/ui/component/PillNavigationBar.kt · app/src/main/java/com/lingion/sleepy/util/AppPrefs.kt · app/src/test/java/com/lingion/sleepy/BackRestoreSaveableContractTest.kt · app/src/test/java/com/lingion/sleepy/ui/screen/schedule/ScheduleViewModeSessionContractTest.kt

Clone this wiki locally