feat(webui): 日历翻页过渡动画(方向跟随手势/按钮)
用户:「日历滑动页面为什么没有切换动画?」。
## 做法
`shift()` 里记下方向(手势与"上一页/下一页"按钮**都走这一个函数** ⇒ 方向只有一个来源),
容器用 **key + 声明的动画类**:
<div key={`${anchor.getTime()}-${scale}`} className={`flex-1 min-h-0 flex ${slideClass}`}>
## 为什么不是"命令式加类"
第一版用 `el.classList.add()`(与视图切换动画同一套写法),**类根本没进 DOM**。
原因:切月时 `loading` 会把网格整块换成加载态、再换回来,命令式加上的类会被
React 的重渲染与那次重挂载抹掉。key 变化 ⇒ 新元素自带动画类出现 ⇒ 必然重放。
## 顺带修掉一个守卫 bug
`firstRender` 守卫原先只在 `el` 存在时才消费。挂载时 `loading=true` ⇒ 容器还没渲染
⇒ 守卫一直留着 ⇒ **用户第一次真正翻页的动画被吞掉**(实测:点第一下不动、第二下才动)。
现在无条件消费。
## 判据
`test/manual/calendar-swipe-verify.mjs` 增加三条(与滑动手势同一条线索):
反向对照"刚进页面不跑动画"(animationName=none)、翻页时 `cal-slide-next` +
`cal-in-next` 且时长 >0、反向翻页是 `cal-slide-prev`(方向正确性)。
**实测**:静止 none → 点下一页 `cal-slide-next`/`cal-in-next` 0.2s → 上一页 `cal-slide-prev`/`cal-in-prev`。
This commit is contained in:
@ -112,7 +112,29 @@ export default function CalendarView() {
|
|||||||
|
|
||||||
const byDay = useMemo(() => bucketByDay(events), [events]);
|
const byDay = useMemo(() => bucketByDay(events), [events]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 翻页过渡(2026-09-14 用户:「日历滑动页面为什么没有切换动画?」)。
|
||||||
|
*
|
||||||
|
* 用**声明式** key + 类,而不是"命令式加类":切月时 loading 会把网格整块换成
|
||||||
|
* 加载态再换回来,命令式加上的类会被 React 的重渲染与那次重挂载抹掉
|
||||||
|
* (实测:类根本没进 DOM)。key 变化 ⇒ 新元素带着动画类出现 ⇒ 一定会重放。
|
||||||
|
*
|
||||||
|
* 首次进入不动画(`navigated` 为 false),否则一打开日历就滑一下很怪。
|
||||||
|
*/
|
||||||
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const slideDir = useRef<1 | -1>(1);
|
||||||
|
const navigated = useRef(false);
|
||||||
|
const slideClass = navigated.current
|
||||||
|
? slideDir.current === 1
|
||||||
|
? 'cal-slide-next'
|
||||||
|
: 'cal-slide-prev'
|
||||||
|
: '';
|
||||||
|
|
||||||
function shift(dir: 1 | -1) {
|
function shift(dir: 1 | -1) {
|
||||||
|
// 记下方向:过渡动画要往正确的方向推(下一段从右边进、上一段从左边进)。
|
||||||
|
// 手势和"上一页/下一页"按钮都走这里 ⇒ 方向只有一个来源。
|
||||||
|
slideDir.current = dir;
|
||||||
|
navigated.current = true;
|
||||||
if (scale === 'month') setAnchor(a => addMonths(a, dir));
|
if (scale === 'month') setAnchor(a => addMonths(a, dir));
|
||||||
else if (scale === 'week') setAnchor(a => addDays(a, dir * 7));
|
else if (scale === 'week') setAnchor(a => addDays(a, dir * 7));
|
||||||
else setAnchor(a => addDays(a, dir));
|
else setAnchor(a => addDays(a, dir));
|
||||||
@ -322,7 +344,16 @@ export default function CalendarView() {
|
|||||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||||
<SpinnerIcon className="w-6 h-6 animate-spin" />
|
<SpinnerIcon className="w-6 h-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : scale === 'month' ? (
|
) : (
|
||||||
|
// 翻页动画的挂载点:三种刻度共用同一个容器,动画只有一处实现。
|
||||||
|
// key 里带 anchor/scale:一变就是新元素 ⇒ 动画必然重放(见上面的说明)。
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
key={`${anchor.getTime()}-${scale}`}
|
||||||
|
className={`flex-1 min-h-0 flex ${slideClass}`}
|
||||||
|
data-slide={slideClass || 'none'}
|
||||||
|
>
|
||||||
|
{scale === 'month' ? (
|
||||||
<MonthGrid
|
<MonthGrid
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
selectedDay={selectedDay}
|
selectedDay={selectedDay}
|
||||||
@ -340,13 +371,15 @@ export default function CalendarView() {
|
|||||||
onPickEvent={pickEvent}
|
onPickEvent={pickEvent}
|
||||||
onCreateAt={startCreate}
|
onCreateAt={startCreate}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DayGrid
|
<DayGrid
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
events={byDay.get(dayKey(anchor)) ?? []}
|
events={byDay.get(dayKey(anchor)) ?? []}
|
||||||
onPickEvent={pickEvent}
|
onPickEvent={pickEvent}
|
||||||
onCreateAt={startCreate}
|
onCreateAt={startCreate}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1087,3 +1087,47 @@ html.view-switch .glass-control,
|
|||||||
backdrop-filter: blur(var(--bg-blur-panel)) saturate(1.2);
|
backdrop-filter: blur(var(--bg-blur-panel)) saturate(1.2);
|
||||||
-webkit-backdrop-filter: blur(var(--bg-blur-panel)) saturate(1.2);
|
-webkit-backdrop-filter: blur(var(--bg-blur-panel)) saturate(1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 日历翻页过渡(2026-09-14 用户:「日历滑动页面为什么没有切换动画?」)。
|
||||||
|
*
|
||||||
|
* 方向由 shift() 记录:下一段从右边推进来、上一段从左边推进来 —— 与手势方向一致。
|
||||||
|
* 位移比整屏小(12% / 上限 56px):日历是密排网格,整屏平移会显得晃。
|
||||||
|
*/
|
||||||
|
@keyframes cal-in-next {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(12%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cal-in-prev {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-12%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-slide-next {
|
||||||
|
animation: cal-in-next 200ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-slide-prev {
|
||||||
|
animation: cal-in-prev 200ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cal-slide-next,
|
||||||
|
.cal-slide-prev {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 日历左右滑动手势验收(2026-09-14 用户:「日历页面还不支持左右滑动手势」)。
|
* 日历左右滑动手势验收(2026-09-14 用户:「日历页面还不支持左右滑动手势」)。
|
||||||
*
|
*
|
||||||
* 三条判据必须一起看,否则"滑一下就翻页"也能算过:
|
* 判据必须一起看,否则"滑一下就翻页"也能算过:
|
||||||
* ① 左滑 → 下一段(月/周/日三种刻度都走同一个 shift())
|
* ① 左滑 → 下一段(月/周/日三种刻度都走同一个 shift())
|
||||||
* ② 右滑 → 回到上一段(可逆)
|
* ② 右滑 → 回到上一段(可逆)
|
||||||
* ③ **反向对照**:纵向拖动不翻页 —— 移动端最容易犯的手势错误就是把滚动当翻页
|
* ③ **反向对照**:纵向拖动不翻页 —— 移动端最容易犯的手势错误就是把滚动当翻页
|
||||||
@ -77,6 +77,44 @@ try {
|
|||||||
const afterVertical = await title();
|
const afterVertical = await title();
|
||||||
record('③ 反向对照:纵向拖动不翻页', afterVertical === afterRight, `仍为 ${afterVertical}`);
|
record('③ 反向对照:纵向拖动不翻页', afterVertical === afterRight, `仍为 ${afterVertical}`);
|
||||||
|
|
||||||
|
// ⑤ 翻页过渡动画(用户后续追问:「日历滑动页面为什么没有切换动画?」)
|
||||||
|
const slide = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const el = document.querySelector('[data-slide]');
|
||||||
|
if (!el) return null;
|
||||||
|
return {
|
||||||
|
slide: el.dataset.slide,
|
||||||
|
anim: getComputedStyle(el).animationName,
|
||||||
|
dur: getComputedStyle(el).animationDuration
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const rest = await slide();
|
||||||
|
record(
|
||||||
|
'⑤ 反向对照:刚进页面时不跑翻页动画',
|
||||||
|
!!rest && rest.anim === 'none',
|
||||||
|
`animationName=${rest?.anim}`
|
||||||
|
);
|
||||||
|
await page.click('button[aria-label="下一页"]');
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await page.click('button[aria-label="下一页"]');
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
const during = await slide();
|
||||||
|
record(
|
||||||
|
'⑤ 翻页时有方向正确的过渡动画',
|
||||||
|
during?.slide === 'cal-slide-next' && during?.anim === 'cal-in-next' && parseFloat(during.dur) > 0,
|
||||||
|
`class=${during?.slide} 动画=${during?.anim} ${during?.dur}`
|
||||||
|
);
|
||||||
|
// 反方向:上一页应当是 cal-slide-prev(方向只有一个来源 = shift())
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.click('button[aria-label="上一页"]');
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
const backDuring = await slide();
|
||||||
|
record(
|
||||||
|
'⑤ 反向翻页的方向也是对的(prev)',
|
||||||
|
backDuring?.slide === 'cal-slide-prev' && backDuring?.anim === 'cal-in-prev',
|
||||||
|
`class=${backDuring?.slide}`
|
||||||
|
);
|
||||||
|
|
||||||
// ④ 回到今天不该被手势判断干扰(按钮仍在)
|
// ④ 回到今天不该被手势判断干扰(按钮仍在)
|
||||||
const todayBtn = await page.locator('button:has-text("今天")').count();
|
const todayBtn = await page.locator('button:has-text("今天")').count();
|
||||||
record('④ 手势没有吃掉「今天」按钮', todayBtn > 0, `找到 ${todayBtn} 个`);
|
record('④ 手势没有吃掉「今天」按钮', todayBtn > 0, `找到 ${todayBtn} 个`);
|
||||||
|
|||||||
Reference in New Issue
Block a user