260 lines
12 KiB
Markdown
260 lines
12 KiB
Markdown
|
|
# 横屏矩阵视图架构说明
|
|||
|
|
|
|||
|
|
## 目录
|
|||
|
|
|
|||
|
|
- [核心问题](#核心问题)
|
|||
|
|
- [架构总览](#架构总览)
|
|||
|
|
- [CSS 旋转原理](#css-旋转原理)
|
|||
|
|
- [布局策略详解](#布局策略详解)
|
|||
|
|
- [滚动方案](#滚动方案)
|
|||
|
|
- [修改指南](#修改指南)
|
|||
|
|
- [常见陷阱](#常见陷阱)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 核心问题
|
|||
|
|
|
|||
|
|
横屏模式下,矩阵视图需要:
|
|||
|
|
|
|||
|
|
1. **旋转 90°** 来利用横屏空间:手机横屏时物理宽度大、高度小,课表需要填满这个空间
|
|||
|
|
2. **表头固定**:周二~周日那一行在滚动时一直可见
|
|||
|
|
3. **纵向滚动**:所有课时(第 1 节~第 N 节)都能通过滚动看到
|
|||
|
|
4. **无多余空白/截断**
|
|||
|
|
|
|||
|
|
关键矛盾:CSS `transform: rotate(90deg)` 旋转后,元素的**坐标方向和视觉方向不再一致**。overflow、position: sticky、scrollTop 都在元素自身坐标系工作,而用户看到的是旋转后的视觉效果。处理不当就会出现"滚动不到底"、"表头跟着跑"、"出现多余空白"等问题。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 架构总览
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
index.vue
|
|||
|
|
┌──────────────────────────────────────────────────────┐
|
|||
|
|
│ landscape-shell (position: fixed; inset: 0) │
|
|||
|
|
│ ┌──────────────────────────────────────────────────┐ │
|
|||
|
|
│ │ landscape-shell__toolbar ← 退出横屏 / 本周等 │ │
|
|||
|
|
│ ├──────────────────────────────────────────────────┤ │
|
|||
|
|
│ │ landscape-shell__body (overflow: hidden) │ │
|
|||
|
|
│ │ ┌──────────────────────────────────────────────┐ │ │
|
|||
|
|
│ │ │ landscape-stage-viewport--matrix │ │ │
|
|||
|
|
│ │ │ (overflow: hidden — 只负责裁剪) │ │ │
|
|||
|
|
│ │ │ ┌──────────────────────────────────────────┐ │ │ │
|
|||
|
|
│ │ │ │ landscape-stage (rotate 90°) │ │ │
|
|||
|
|
│ │ │ │ ┌──────────────────────────────────────┐ │ │ │
|
|||
|
|
│ │ │ │ │ matrix-container--landscape │ │ │ │
|
|||
|
|
│ │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ ClazzMatrixView │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ ┌──────────────────────────────┐ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ │ ml-wrap (flex column) │ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ │ ├─ ml-header (sticky top) │ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ │ └─ ml-body (overflow-y: auto) │ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ │ ├─ ml-row (第1节: A B C..)│ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ │ ├─ ml-row (第2节: A B C..)│ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ │ └─ ml-row (第N节: ...) │ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ │ └──────────────────────────────┘ │ │ │ │ │
|
|||
|
|
│ │ │ │ │ └──────────────────────────────────┘ │ │ │ │
|
|||
|
|
│ │ │ │ └──────────────────────────────────────┘ │ │ │
|
|||
|
|
│ │ │ └──────────────────────────────────────────┘ │ │
|
|||
|
|
│ │ └──────────────────────────────────────────────┘ │
|
|||
|
|
│ └──────────────────────────────────────────────────┘ │
|
|||
|
|
└──────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 关键层次职责
|
|||
|
|
|
|||
|
|
| 层 | CSS | 职责 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `landscape-shell__body` | `overflow: hidden` | 裁剪旋转容器,不滚动 |
|
|||
|
|
| `landscape-stage-viewport--matrix` | `overflow: hidden` | 裁剪旋转容器,不滚动 |
|
|||
|
|
| `landscape-stage` | `overflow: visible` | 旋转舞台,内容可见即可 |
|
|||
|
|
| `ml-wrap` | `overflow: hidden` | 包裹 header+body,裁剪 |
|
|||
|
|
| `ml-header` | `position: sticky; top: 0` | 表头固定在滚动容器顶部 |
|
|||
|
|
| `ml-body` | `overflow-y: auto` | **唯一滚动容器** |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## CSS 旋转原理
|
|||
|
|
|
|||
|
|
### 旋转公式
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
.landscape-stage {
|
|||
|
|
width: var(--clazz-landscape-body-height); /* 约 325px */
|
|||
|
|
height: 100dvw; /* 约 812px */
|
|||
|
|
transform: rotate(90deg) translateY(-100%);
|
|||
|
|
transform-origin: top left;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- `width` = body-height(约 325px),`height` = 100dvw(约 812px)
|
|||
|
|
- 旋转后变成视觉上 812×325,填满横屏
|
|||
|
|
- `transform-origin: top left` + `translateY(-100%)` 纠正旋转后的位置偏移
|
|||
|
|
|
|||
|
|
### 旋转后的坐标系变化
|
|||
|
|
|
|||
|
|
旋转前(元素自身坐标系 → 视觉方向):
|
|||
|
|
|
|||
|
|
| 元素方向 | 视觉方向 |
|
|||
|
|
|----------|----------|
|
|||
|
|
| +X(向右) | → 视觉右侧 |
|
|||
|
|
| +Y(向下) | → 视觉下方 |
|
|||
|
|
|
|||
|
|
旋转 90° CW 后:
|
|||
|
|
|
|||
|
|
| 元素方向 | 视觉方向 |
|
|||
|
|
|----------|----------|
|
|||
|
|
| +X(向右) | → 视觉下方 |
|
|||
|
|
| +Y(向下) | → 视觉左侧 |
|
|||
|
|
|
|||
|
|
**关键理解**:overflow、scrollTop、position: sticky 全部在**元素自身坐标系**工作,不受 transform 影响。
|
|||
|
|
|
|||
|
|
### ml-* 布局在旋转前后的映射
|
|||
|
|
|
|||
|
|
旋转前 ml-wrap 布局(元素坐标系):
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌──────┬──────────────────────────────┐ ← ml-header (元素顶部)
|
|||
|
|
│ 时段 │ 周一 周二 周三 周四 ... │ flex row, 元素 X 方向排列
|
|||
|
|
├──────┼──────────────────────────────┤
|
|||
|
|
│ 第1节 │ A1 B1 C1 D1 ... │ ← ml-row
|
|||
|
|
│ 第2节 │ A2 B2 C2 D2 ... │ ← ml-row
|
|||
|
|
│ 第3节 │ A3 B3 C3 D3 ... │ ← ml-row
|
|||
|
|
│ ... │ │
|
|||
|
|
└──────┴──────────────────────────────┘
|
|||
|
|
↑ ↑
|
|||
|
|
元素 X 方向 元素 X 方向
|
|||
|
|
(label) (day cells)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
旋转 90° CW 后的视觉效果:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
←←← 元素 Y 方向 = 视觉左侧(更多课时往左延伸)
|
|||
|
|
↓ ┌──────┬──────┬──────┬──────┐
|
|||
|
|
元 │ 时段 │ 周一 │ 周二 │ 周三 │ ... → ml-header (sticky top)
|
|||
|
|
素 视觉 ├──────┼──────┼──────┼──────┤
|
|||
|
|
X ↓ │ 第1节 │ A1 │ B1 │ C1 │ ...
|
|||
|
|
↓ 下方 ├──────┼──────┼──────┼──────┤
|
|||
|
|
│ 第2节 │ A2 │ B2 │ C2 │ ...
|
|||
|
|
├──────┼──────┼──────┼──────┤
|
|||
|
|
│ ... │ │ │ │
|
|||
|
|
└──────┴──────┴──────┴──────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 滚动方案
|
|||
|
|
|
|||
|
|
### 为什么浏览器原生滚动不够
|
|||
|
|
|
|||
|
|
旋转后,`overflow-y: auto` 的滚动方向(元素 Y = 视觉左侧)和用户期望的滚动方向(视觉下方)不一致。所以需要手动接管。
|
|||
|
|
|
|||
|
|
### 手动滚动实现
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// ml-body 元素上绑定的事件
|
|||
|
|
@touchstart="onLandscapeBodyTouchStart"
|
|||
|
|
@touchmove.prevent="onLandscapeBodyTouchMove"
|
|||
|
|
@wheel="onLandscapeBodyWheel"
|
|||
|
|
|
|||
|
|
// touchmove: 手指上下滑动 → 控制元素 scrollTop
|
|||
|
|
function onLandscapeBodyTouchMove(e: TouchEvent) {
|
|||
|
|
const currentY = e.touches[0]?.clientY;
|
|||
|
|
const previousY = landscapeTouchLastY.value;
|
|||
|
|
const body = landscapeBodyRef.value;
|
|||
|
|
if (currentY == null || previousY == null || !body) return;
|
|||
|
|
body.scrollTop += currentY - previousY; // 直接操作元素 scrollTop
|
|||
|
|
landscapeTouchLastY.value = currentY;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// wheel: 鼠标滚轮 → 控制元素 scrollTop
|
|||
|
|
function onLandscapeBodyWheel(e: WheelEvent) {
|
|||
|
|
const body = landscapeBodyRef.value;
|
|||
|
|
if (!body) return;
|
|||
|
|
body.scrollTop += e.deltaY; // 直接操作元素 scrollTop
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
关键点:
|
|||
|
|
- **直接操作 `element.scrollTop`**,不走浏览器的 scroll 行为
|
|||
|
|
- scrollTop 在元素自身坐标系工作,不管视觉方向
|
|||
|
|
- `touchmove.prevent` 阻止浏览器默认行为(浏览器会尝试在 viewport 层滚动)
|
|||
|
|
- CSS `touch-action: none` + `overscroll-behavior: contain` 进一步禁止浏览器介入
|
|||
|
|
|
|||
|
|
### sticky header 为什么能用
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
.ml-header {
|
|||
|
|
position: sticky;
|
|||
|
|
top: 0; /* 元素自身坐标系的 top,不是视觉 top */
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
sticky 在**滚动容器的坐标系**中计算。ml-header 的滚动容器是 ml-body(最近的有 overflow 的父元素)。ml-body 的 scrollTop 在元素 Y 方向 = 旋转后的视觉左侧方向。sticky 的 `top: 0` 意味着"在元素 Y 方向离顶部 0px 处固定"——旋转后正好映射到视觉顶部。
|
|||
|
|
|
|||
|
|
这就是为什么 sticky header 必须在旋转内部,不能在外部。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 修改指南
|
|||
|
|
|
|||
|
|
### 如果要改表头样式
|
|||
|
|
|
|||
|
|
改 `ClazzMatrixView.vue` 的 `ml-header` / `ml-day-header` 相关 CSS。**不要**在 `index.vue` 里加外部表头。
|
|||
|
|
|
|||
|
|
### 如果要改滚动行为
|
|||
|
|
|
|||
|
|
改 `ClazzMatrixView.vue` 的 `ml-body` CSS 和 `onLandscapeBodyTouchMove` / `onLandscapeBodyWheel` 函数。**不要**在 viewport 层设 `overflow: auto`。
|
|||
|
|
|
|||
|
|
### 如果要调整布局层次
|
|||
|
|
|
|||
|
|
记住这条规则:**"谁包含内容,谁负责滚动"**。内容(ml-row)在 ml-body 里 → ml-body 负责滚动。viewport 只负责裁剪(overflow: hidden)。不在 viewport 层滚动。
|
|||
|
|
|
|||
|
|
### 如果要改旋转角度
|
|||
|
|
|
|||
|
|
`landscape-stage` 的 `rotate(90deg)` 是整个策略的基础。如果要改成其他角度(例如 -90deg),坐标系映射方向全部会变,需要重新审视 scroll handler 的实现。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 常见陷阱
|
|||
|
|
|
|||
|
|
### 陷阱 1:把表头放旋转容器外
|
|||
|
|
|
|||
|
|
症状:表头和内容宽度对不齐、位置偏移、横竖坐标不对应。
|
|||
|
|
|
|||
|
|
原因:旋转后的元素宽度不等于 CSS 上写的 width(因为 transform 不影响 layout box)。外部固定元素无法和旋转后内部元素对齐。
|
|||
|
|
|
|||
|
|
**解法**:表头放旋转容器内部,用 sticky。
|
|||
|
|
|
|||
|
|
### 陷阱 2:在 viewport 层设 overflow: auto
|
|||
|
|
|
|||
|
|
症状:出现大量空白可滚动区域、滚不到底。
|
|||
|
|
|
|||
|
|
原因:viewport 不在旋转容器内部,它看到的 stage flow box 尺寸是 `width: 325px; height: 812px`(**旋转前的尺寸**)。812px 的可滚动高度远大于视觉上 325px 的显示区域,所以很多是空白。
|
|||
|
|
|
|||
|
|
**解法**:viewport 固定 = `overflow: hidden`,滚动容器在内部 ml-body。
|
|||
|
|
|
|||
|
|
### 陷阱 3:试图去掉旋转改用其他方案
|
|||
|
|
|
|||
|
|
症状:布局显示方向完全不对。
|
|||
|
|
|
|||
|
|
原因:横屏模式的核心思想就是用旋转来利用物理横屏空间。去掉旋转 = 失去横屏适配。
|
|||
|
|
|
|||
|
|
**解法**:保留旋转,在旋转内部解决问题。
|
|||
|
|
|
|||
|
|
### 陷阱 4:用 CSS overflow 的自动滚动而不手动接管
|
|||
|
|
|
|||
|
|
症状:滚动方向错乱(上下滑变成左右滚动)。
|
|||
|
|
|
|||
|
|
原因:旋转后 overflow-y 对应视觉左侧方向。浏览器不会自动映射方向。
|
|||
|
|
|
|||
|
|
**解法**:手动接管 touch/wheel 事件,直接操作 scrollTop。
|
|||
|
|
|
|||
|
|
### 陷阱 5:修改 overflow 链中的某一层而不检查上下游
|
|||
|
|
|
|||
|
|
症状:内容被裁剪、滚动失效、sticky 不工作。
|
|||
|
|
|
|||
|
|
原因:overflow 链是层叠的:viewport(overflow:hidden) → stage(overflow:visible) → ml-wrap(overflow:hidden) → ml-body(overflow-y:auto)。任何一层改了,都会影响滚动和可见范围。
|
|||
|
|
|
|||
|
|
**解法**:改之前理解整个 overflow 链,确认改动不会破坏上下游。
|