-
Notifications
You must be signed in to change notification settings - Fork 8
Navigation
本文基于 v1.0.54(versionCode 58)。 TL;DR:单 Activity 组合态导航——四个 Tab、一个 overlay 导航栈、贴底/悬浮 Dock 双形态底栏、四层返回键语义。面向开发者。
导航由 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)。
栈页集合即 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):editTableId、pendingNewTableId、previousDefaultTableId、widgetEditId。原因写在注释里:旋转恢复后若参数归 null,EditTable 的 tableId = null 语义是「编辑当前课表」,会静默改错表。
overlayStack 用 rememberSaveable 加自定义 Saver 保存(MainActivity.kt:200-205)。save 侧带守卫:
save = { stack -> if (editingCourse == null) stack else emptyList() }正在编辑课程时栈存空,恢复后回到主 Tab。理由:编辑会话的 CourseEntity 无法写进 Bundle,恢复成空表单会被当成新课程再填一遍,产生重复课程(MainActivity.kt:197-199 注释)。currentTab 与 scheduleViewMode 用普通 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);切换走 GeneralSettingsScreen 的 onNavDockChange 回调与 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)。
标准 Scaffold bottomBar 占位,内容止于栏上沿(MainActivity.kt:390-421)。通栏矩形:背景 surfaceContainer,吃 navigationBars insets,上下内边距 6dp / 8dp,行高 76dp(PillNavigationBar.kt:159-190)。thumb 色块 64×32dp,图标 20dp。
去掉 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
Sleepy Wiki
入门
界面与视图
课程管理
导入导出
小组件与提醒
数据与设置
项目
社区