fix(island): 开合不再震动——去掉弹簧过冲 + 锚点整数化

问题(用户反馈"灵动岛开合的时候不要震动"):
之前虽然有「收回不抖动」断言,但它只看窗口宽度单调收缩,漏掉了两处真实回弹。
逐帧采样卡片真实屏幕矩形后复现:
- 展开时窗口冲到 411x329(目标 404x316)→ 主进程用了 easeOutBack 弹簧缓动,尺寸超过目标再弹回
- 卡片被放大到 400.6x311.6(目标 396x310)→ 卡片入场用 cubic-bezier(0.34,1.56,0.64,1) 缩放,先超过 1 再回落
- 卡片中心左右波动 1.00px、上边缘波动 2.88px → 逐帧 Math.round 在 0.5 边界跳变 + transform 位移

修复:
1. 缓动单调化: 删除 easeOutBack,展开/收回统一 easeOutCubic(窗口与卡片都不再过冲)
2. 锚点整数化且与尺寸解耦: 新增 resolveAnchor(),停靠位置决定不动的那条边/中心,动画前算一次
3. 卡片宽度取偶数 + boundsFor 统一推导窗口矩形(消除逐帧 0.5px 抖动)
4. 卡片入场去掉 scale/translateY,只做 180ms 淡入,形变完全交给窗口尺寸
5. 顺带修掉新探针暴露的真实缺陷: 隐藏后再弹出胶囊时透明度被硬编码为 1,忽略了个性化设置

验证(verify:desktop 49/49,修复前后对比):
- 展开: 卡片中心波动 0.00px / 上边缘 0.00px,最大窗口 404x316(=目标)、最大卡片 396x308,单调=true
- 收回: 0.00px / 0.00px,最小窗口 268,宽度序列单调无回跳
- 透明度 0.62 生效;四个停靠位置、置顶、动画开关/速度、持久化全部通过
- verify:e2e 133/133、verify:web 19/19 不受影响
文档: README 形变动画章节更新为「单调不过冲 + 整数锚点」,并记录修复前后实测数据
This commit is contained in:
LoveChengke committed 2026-09-12 16:41:01 +08:00
1 parent 97a89de944
commit 9c9ac69ac6
4 files changed
+295 -81

No files matched your search

@@ -832,26 +832,27 @@ body,
opacity: 0;
}
/* 展开态:弹簧缩放入场(配合窗口尺寸缓动) */
/*
* 展开 / 收回:只做轻微淡入淡出,**不做缩放与位移**。
*
* 之前用 `cubic-bezier(0.34, 1.56, 0.64, 1)` + `scale(0.88) translateY(-6px)` 做弹簧入场,
* 缩放会先超过 1 再回落(实测卡片被放大到 400.6px 再回到 396px),观感就是"开合时震一下"。
* 现在形变完全交给主进程的窗口尺寸缓动(单调 easeOutCubic),卡片保持固定尺寸与固定锚点,
* 由窗口"揭开"卡片,全程无色块回弹、无位移抖动。
*/
.island-pop-enter-active {
transition:
opacity 0.2s ease,
transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
transition: opacity 0.18s ease-out;
}
.island-pop-leave-active {
transition:
opacity 0.14s ease,
transform 0.2s ease;
transition: opacity 0.12s ease-out;
}
.island-pop-enter-from {
opacity: 0;
transform: scale(0.88) translateY(-6px);
}
.island-pop-leave-to {
opacity: 0;
transform: scale(0.92);
}
</style>
+116 -35
View File
@@ -485,32 +485,71 @@ class IslandController {
}
}
private computeBounds(size: { width: number; height: number }): {
x: number;
y: number;
width: number;
height: number;
} {
/**
* 屏幕锚点:由停靠位置决定的"不动的那条边/中心",全程使用**整数**。
*
* 关键点(修复"开合时震动"):
* - 锚点只跟屏幕工作区与停靠位置有关,与窗口尺寸无关 → 形变过程中不会逐帧漂移;
* - 全部取整,避免 `Math.round` 在 0.5 边界上左右跳变(那会表现为 ±0.5px 的横向抖动)。
*/
private resolveAnchor(): IslandAnchor {
const area = screen.getPrimaryDisplay().workArea;
const margin = 8;
let x = Math.round(area.x + (area.width - size.width) / 2);
let y = area.y + margin;
const margin = ISLAND_MARGIN;
const center = Math.round(area.x + area.width / 2);
switch (this.appearance.position) {
case 'top-left':
x = area.x + margin;
break;
return { hMode: 'left', hValue: area.x + margin, vMode: 'top', vValue: area.y + margin };
case 'top-right':
x = area.x + area.width - size.width - margin;
break;
return {
hMode: 'right',
hValue: area.x + area.width - margin,
vMode: 'top',
vValue: area.y + margin,
};
case 'bottom-center':
y = area.y + area.height - size.height - margin;
return {
hMode: 'center',
hValue: center,
vMode: 'bottom',
vValue: area.y + area.height - margin,
};
default:
return { hMode: 'center', hValue: center, vMode: 'top', vValue: area.y + margin };
}
}
/**
* 由锚点 + 目标尺寸推出窗口矩形。
*
* 卡片宽度强制取**偶数**:卡片始终在窗口内水平居中,偶宽保证卡片中心落在整数像素上,
* 于是形变过程中卡片中心恒定不动(实测波动 0.00px),不会出现"左右轻微振动"。
*/
private boundsFor(size: { width: number; height: number }, anchor: IslandAnchor): Bounds {
const cardWidth = Math.max(2, evenWidth(Math.round(size.width) - CARD_PAD_X * 2));
const cardHeight = Math.max(2, Math.round(size.height) - CARD_PAD_Y * 2);
const width = cardWidth + CARD_PAD_X * 2;
const height = cardHeight + CARD_PAD_Y * 2;
let x: number;
switch (anchor.hMode) {
case 'left':
x = anchor.hValue;
break;
case 'right':
x = anchor.hValue - width;
break;
default:
x = anchor.hValue - cardWidth / 2 - CARD_PAD_X;
break;
}
return { x, y, width: size.width, height: size.height };
const y = anchor.vMode === 'bottom' ? anchor.vValue - height : anchor.vValue;
return { x, y, width, height };
}
private computeBounds(size: { width: number; height: number }): Bounds {
return this.boundsFor(size, this.resolveAnchor());
}
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
@@ -568,7 +607,8 @@ class IslandController {
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画
if (size === SIZES.pill) {
this.cancelFade();
this.win.setOpacity(1);
// 必须使用个性化透明度:这里以前硬编码 1,导致"隐藏后再弹出胶囊"会忽略透明度设置
this.win.setOpacity(this.appearance.opacity);
this.win.setBounds(pillBounds);
this.win.showInactive();
return;
@@ -646,14 +686,17 @@ class IslandController {
*
* 用**单一 requestAnimationFrame 循环**驱动(需求 4):
* - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动
* - 锚点固定:横向/纵向都由 computeBounds 决定,形变过程中卡片上边缘(或下边缘)不动
* - 锚点固定:锚点在动画开始前算一次(整数),过程中只让"对侧边"移动
* - 缓动**单调不过冲**:早期版本用 easeOutBack 弹簧缓动,窗口尺寸会超过目标再弹回来,
* 观感就是"开合时震一下";现在统一用 easeOutCubic,尺寸/位置只朝目标单向变化
* - 每帧只用整数像素、卡片宽度取偶数,卡片中心与不动的那条边全程恒定
* - 关闭动画(appearance.animations = false)时直接落到目标尺寸
* - `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感
*/
private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void {
if (!this.win || this.win.isDestroyed()) return;
const from = this.win.getBounds();
const to = this.computeBounds(size);
const anchor = this.resolveAnchor();
const to = this.boundsFor(size, anchor);
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
this.cancelBoundsAnimation();
@@ -664,26 +707,39 @@ class IslandController {
return;
}
const spring = options.spring === true;
const duration = (spring ? 340 : 260) / Math.max(0.25, this.appearance.speed);
// 形变时长:展开稍长一点(视觉上更"从容"),收回更快;倍速来自个性化设置
const duration = Math.round(
(options.spring === true ? 300 : 220) / Math.max(0.25, this.appearance.speed),
);
const fromCardWidth = Math.max(2, evenWidth(from.width - CARD_PAD_X * 2));
const fromCardHeight = Math.max(2, from.height - CARD_PAD_Y * 2);
const toCardWidth = Math.max(2, evenWidth(to.width - CARD_PAD_X * 2));
const toCardHeight = Math.max(2, to.height - CARD_PAD_Y * 2);
const startedAt = Date.now();
const frame = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
const progress = Math.min(1, (Date.now() - startedAt) / duration);
const eased = spring ? easeOutBack(progress) : 1 - Math.pow(1 - progress, 3); // easeOutCubic
const bounds = {
x: Math.round(from.x + (to.x - from.x) * eased),
y: Math.round(from.y + (to.y - from.y) * eased),
width: Math.max(1, Math.round(from.width + (to.width - from.width) * eased)),
height: Math.max(1, Math.round(from.height + (to.height - from.height) * eased)),
};
// easeOutCubic:单调递减斜率,永不越过 1(因此不会有回弹)
const eased = 1 - Math.pow(1 - progress, 3);
const cardWidth = Math.max(
2,
evenWidth(Math.round(fromCardWidth + (toCardWidth - fromCardWidth) * eased)),
);
const cardHeight = Math.max(2, Math.round(fromCardHeight + (toCardHeight - fromCardHeight) * eased));
// 注意:boundsFor 接收的是**窗口**尺寸(内部自行扣掉卡片内边距)
const bounds = this.boundsFor(
{ width: cardWidth + CARD_PAD_X * 2, height: cardHeight + CARD_PAD_Y * 2 },
anchor,
);
this.win.setBounds(bounds);
if (progress < 1) {
this.boundsFrame = scheduleFrame(frame);
return;
}
// 收尾对齐到最终尺寸,避免四舍五入留下 1px 误差(会让卡片边缘"抖一下")
// 收尾对齐到最终尺寸,避免四舍五入留下误差(会让卡片边缘"抖一下")
this.win.setBounds(to);
this.boundsFrame = null;
this.applyFocusable();
@@ -773,12 +829,37 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
};
}
/** 回弹缓动(easeOutBack):进度超过 1 形成轻微过冲,营造弹簧感 */
function easeOutBack(progress: number): number {
const c1 = 1.15;
const c3 = c1 + 1;
const p = progress - 1;
return 1 + c3 * p * p * p + c1 * p * p;
/**
* 说明:早期版本这里有一个 easeOutBack 弹簧缓动(进度过冲到 1 以上再回落),
* 会让窗口尺寸"超过目标再弹回来",观感就是开合时震一下,因此已移除;
* 现在所有形变统一走单调的 easeOutCubic(见 animateBounds),不再有任何回弹。
*/
/** 卡片相对窗口的内边距(必须与渲染进程 CSS 的 4px / 3px 一致) */
const CARD_PAD_X = 4;
const CARD_PAD_Y = 3;
/** 灵动岛与屏幕工作区边缘的留白 */
const ISLAND_MARGIN = 8;
interface Bounds {
x: number;
y: number;
width: number;
height: number;
}
/** 屏幕锚点:由停靠位置决定的"不动的那条边/中心"(整数) */
interface IslandAnchor {
hMode: 'center' | 'left' | 'right';
hValue: number;
vMode: 'top' | 'bottom';
vValue: number;
}
/** 卡片宽度取偶数:保证水平居中的卡片中心落在整数像素上,形变时不左右跳变 */
function evenWidth(value: number): number {
const safe = Math.max(2, Math.round(value));
return safe - (safe % 2);
}
export const island = new IslandController();
+117
View File
@@ -654,6 +654,123 @@ async function runIslandChecks(
`(期望 ${currentAppearance.width}x${currentAppearance.height})`,
);
// 10.5) 「开合不震动」实测:把窗口矩形与卡片的**真实屏幕矩形**逐帧采样,
// 断言 ①卡片的水平中心与上边缘绝对不动(锚点稳定,不左右/上下跳动)
// ②卡片与窗口的尺寸单调变化且**从不过冲**(不再有"弹一下"的回弹)
const motionSamples: {
winW: number;
winH: number;
cardCx: number;
cardTop: number;
cardW: number;
cardH: number;
}[] = [];
const sampleCardRect = async (): Promise<void> => {
const bounds = islandWindow?.getBounds();
const rect = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return null;
const r = card.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()`,
)
.catch(() => null);
if (!bounds || !rect) return;
motionSamples.push({
winW: bounds.width,
winH: bounds.height,
cardCx: bounds.x + rect.left + rect.width / 2,
cardTop: bounds.y + rect.top,
cardW: rect.width,
cardH: rect.height,
});
};
const runMotionProbe = async (action: 'expand' | 'collapse'): Promise<void> => {
motionSamples.length = 0;
island.handleAction({ action });
const deadline = Date.now() + 900;
while (Date.now() < deadline) {
await sampleCardRect();
await sleep(12);
}
};
island.handleAction({ action: 'dismiss' });
await sleep(250);
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
await sleep(350);
const expandedCard = { width: ISLAND_SIZES.expanded.width - 8, height: ISLAND_SIZES.expanded.height - 6 };
const pillCard = { width: ISLAND_SIZES.pill.width - 8, height: ISLAND_SIZES.pill.height - 6 };
await runMotionProbe('expand');
const expandSamples = [...motionSamples];
const cxSpread = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardCx)) -
Math.min(...expandSamples.map((item) => item.cardCx))
: 99;
const topSpreadExpand = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardTop)) -
Math.min(...expandSamples.map((item) => item.cardTop))
: 99;
const maxCardW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardW)) : 0;
const maxCardH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardH)) : 0;
const maxWinW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winW)) : 0;
const maxWinH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winH)) : 0;
const expandMonotonic = expandSamples.every(
(item, index) => index === 0 || item.winW >= (expandSamples[index - 1]?.winW ?? 0) - 1,
);
const expandNoOvershoot =
maxCardW <= expandedCard.width + 0.5 &&
maxCardH <= expandedCard.height + 0.5 &&
maxWinW <= ISLAND_SIZES.expanded.width + 1 &&
maxWinH <= ISLAND_SIZES.expanded.height + 1;
record(
'展开过程不震动(锚点不动 + 尺寸不过冲)',
expandSamples.length >= 5 &&
cxSpread <= 0.5 &&
topSpreadExpand <= 0.5 &&
expandMonotonic &&
expandNoOvershoot,
`采样=${expandSamples.length} 卡片中心波动=${cxSpread.toFixed(2)}px 上边缘波动=${topSpreadExpand.toFixed(2)}px ` +
`最大卡片=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height})` +
` 最大窗口=${maxWinW}x${maxWinH}(目标 ${ISLAND_SIZES.expanded.width}x${ISLAND_SIZES.expanded.height}) 单调=${expandMonotonic}`,
);
await runMotionProbe('collapse');
const collapseSamples = [...motionSamples];
const cxSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardCx)) -
Math.min(...collapseSamples.map((item) => item.cardCx))
: 99;
const topSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardTop)) -
Math.min(...collapseSamples.map((item) => item.cardTop))
: 99;
const minWinW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.winW)) : 0;
const minCardW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.cardW)) : 0;
const collapseMonotonic = collapseSamples.every(
(item, index) =>
index === 0 || item.winW <= (collapseSamples[index - 1]?.winW ?? Number.MAX_SAFE_INTEGER) + 1,
);
const collapseNoOvershoot = minCardW >= pillCard.width - 0.5 && minWinW >= ISLAND_SIZES.pill.width - 1;
record(
'收回过程不震动(锚点不动 + 尺寸不过冲)',
collapseSamples.length >= 5 &&
cxSpreadCollapse <= 0.5 &&
topSpreadCollapse <= 0.5 &&
collapseMonotonic &&
collapseNoOvershoot,
`采样=${collapseSamples.length} 卡片中心波动=${cxSpreadCollapse.toFixed(2)}px 上边缘波动=${topSpreadCollapse.toFixed(2)}px ` +
`最小卡片宽=${minCardW.toFixed(1)}(目标 ${pillCard.width}) 最小窗口宽=${minWinW}(目标 ${ISLAND_SIZES.pill.width}) 单调=${collapseMonotonic}`,
);
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
await sleep(250);
// 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
const workArea = screen.getPrimaryDisplay().workArea;