昨天我們完成了長算式動態自適應縮放、局部游標精準定位與長按連續退格機制,讓螢幕輸入體驗達到了極致細膩。但在日常與商務運算中,不同使用者對鍵盤佈局有著完全不同的偏好。如果按鍵排列是固定的,便無法發揮最佳輸入效率。然而,許多網頁在實作網格重排時,往往採用生硬的兩鍵直接互換,缺乏原生作業系統般的自然推擠流暢感。
今天我們將正式啟動自訂鍵盤核心演算法的篇章,解構仿 iOS 桌面圖示拖曳重排的順延推擠挪位演算法。透過指標事件捕捉、即時幾何槽位推算、RAF 批次排程避讓動態,以及 100% 視口座標貼合的發光虛線指示器,打造出渾然天成的鍵盤重排體驗。
在瀏覽器中實作類似手機桌面的多網格圖示拖曳重排,傳統做法通常面臨三大技術瓶頸:
常見的拖曳庫多半採用單純的兩節點互換(Swap)模式。當拖動按鍵 A 到按鍵 B 的位置時,僅讓 A 與 B 交換位置,其餘按鍵紋絲不動。這種模式破壞了列表或網格的連續流動性,使用者無法直覺預期其他按鍵的流向,視覺感受極為突兀。
如果在手指滑動過程中,即時且頻繁地修改真實 DOM 節點的先後順序,瀏覽器會在每秒內反覆觸發強制重排(Reflow)與重繪(Repaint)。在行動裝置中,這會造成嚴重的掉幀與手感卡頓,完全無法維持 120FPS 滿幀響應。
許多實作將落點指示器作為一個虛擬元素直接插入網格中,這會立刻改變網格其他子元素的幾何佈局,造成畫面劇烈跳動;若採用絕對定位,又常因為父容器的內距、捲軸或變形屬性,導致指示框與目標槽位產生微幅像素位移或圖層錯位。
為達到原生系統等級的滑順重排效果,我們設計了一套高效的順延排程機制:
當拖曳按鍵進入新的槽位時,演算法會精確計算起始索引與目標索引之間的相對區間。若往後拖曳,區間內的所有按鍵平滑向左(或上一列)遞補讓位;若往前拖曳,區間內的按鍵則順延向右(或下一列)推擠讓出空位,呈現極度自然的連鎖讓位物理效果。
拖曳啟動時,將目標按鍵以 setPointerCapture 鎖定指標事件,並在 document.body 建立一個尺寸完全 1:1 等比的獨立懸浮按鍵。透過 position: fixed 與 translate3d 進行純 GPU 合成層位移,原網格內的元素則維持佔位狀態,完全不更動真實 DOM 結構。
在 pointerdown 當下即預先快取網格內所有槽位的空間幾何邊界與中心座標。拖曳過程中的所有座標更新與按鍵避讓位移,全部交由 requestAnimationFrame 進行單一幀率合併排程,徹底杜絕多餘的重複計算。
獨立於網格之外建立全域發光虛線框,當懸停於有效槽位時,直接讀取目標槽位的 getBoundingClientRect 精確幾何數值,以純硬體加速平移至目標位置,完全不干擾網格排版,消除所有圖層與邊距誤差。
以下是自訂鍵盤順延推擠挪位演算法與懸浮渲染的核心程式碼:
/* ─── 1. 指標捕捉、槽位幾何快取與獨立懸浮層初始化 ─── */
let dragState = null;
let dragRaf = null;
function setupCustomKeyboardDrag() {
const gridEl = document.getElementById('custom-keyboard-grid');
const indicatorEl = document.getElementById('grid-slot-indicator');
if (!gridEl) return;
const onPointerDown = (e) => {
if (e.button && e.button !== 0) return;
if (dragState) return;
const targetKey = e.target.closest('.custom-keyboard-grid .key');
if (!targetKey) return;
const keyId = targetKey.dataset.keyId;
if (!keyId) return;
e.preventDefault();
triggerVibration(false);
const sourceIndex = parseInt(targetKey.dataset.index, 10);
const keyRect = targetKey.getBoundingClientRect();
const gridRect = gridEl.getBoundingClientRect();
// 預先快取所有網格槽位的空間絕對邊界與中心點,避免拖曳時重複觸發 Reflow
const gridKeys = Array.from(gridEl.querySelectorAll('.key'));
const slotRects = gridKeys.map(k => k.getBoundingClientRect());
const slotCenters = slotRects.map(r => ({
x: r.left + r.width / 2,
y: r.top + r.height / 2
}));
const initialX = keyRect.left;
const initialY = keyRect.top;
const targetSize = Math.round(keyRect.width) || 40;
// 建立獨立的 GPU 懸浮拖曳圖層元素 (1:1 鎖定尺寸)
const floatingEl = generateKeyElement(keyId);
floatingEl.classList.add('is-dragging');
floatingEl.style.position = 'fixed';
floatingEl.style.left = '0px';
floatingEl.style.top = '0px';
floatingEl.style.width = `${targetSize}px`;
floatingEl.style.height = `${targetSize}px`;
floatingEl.style.transform = `translate3d(${initialX}px, ${initialY}px, 0)`;
floatingEl.style.zIndex = '999999';
floatingEl.style.pointerEvents = 'none';
document.body.appendChild(floatingEl);
targetKey.classList.add('is-placeholder');
gridEl.classList.add('is-dragging-active');
dragState = {
keyId,
sourceIndex,
sourceEl: targetKey,
floatingEl,
indicatorEl,
gridEl,
gridKeys,
slotRects,
slotCenters,
keyRect,
gridRect,
offsetX: e.clientX - (keyRect.left + keyRect.width / 2),
offsetY: e.clientY - (keyRect.top + keyRect.height / 2),
currentHoverIndex: sourceIndex,
lastHoverIndex: sourceIndex,
isFinalizing: false,
pendingEvent: e
};
try {
targetKey.setPointerCapture(e.pointerId);
} catch (err) {}
targetKey.addEventListener('pointermove', onPointerMove, { passive: true });
targetKey.addEventListener('pointerup', onPointerUp);
targetKey.addEventListener('pointercancel', onPointerUp);
};
gridEl.addEventListener('pointerdown', onPointerDown);
}
/* ─── 2. RAF 批次排程與順延推擠 (Push & Reflow) 核心演算法 ─── */
function processPointerMove() {
if (!dragState || dragState.isFinalizing) return;
const { floatingEl, keyRect, offsetX, offsetY, slotCenters, slotRects, gridRect, pendingEvent } = dragState;
if (!pendingEvent) return;
const targetX = pendingEvent.clientX - keyRect.width / 2 - offsetX;
const targetY = pendingEvent.clientY - keyRect.height / 2 - offsetY;
// 純 GPU 合成層位置更新
floatingEl.style.transform = `translate3d(${targetX}px, ${targetY}px, 0)`;
const floatingCenterX = targetX + keyRect.width / 2;
const floatingCenterY = targetY + keyRect.height / 2;
const isOutsideGrid = (floatingCenterX < gridRect.left - 12 || floatingCenterX > gridRect.right + 12 ||
floatingCenterY < gridRect.top - 12 || floatingCenterY > gridRect.bottom + 12);
let hoveredIndex = -1;
if (!isOutsideGrid && slotCenters && slotCenters.length > 0) {
let minDistance = Infinity;
let closestIdx = -1;
for (let idx = 0; idx < slotCenters.length; idx++) {
const center = slotCenters[idx];
const dist = Math.hypot(floatingCenterX - center.x, floatingCenterY - center.y);
if (dist < minDistance) {
minDistance = dist;
closestIdx = idx;
}
}
const slotW = slotRects[0] ? slotRects[0].width : 40;
hoveredIndex = (minDistance <= slotW * 2.2) ? closestIdx : dragState.sourceIndex;
}
if (hoveredIndex !== dragState.lastHoverIndex) {
if (hoveredIndex >= 0) triggerVibration(false);
dragState.lastHoverIndex = hoveredIndex;
}
dragState.currentHoverIndex = hoveredIndex;
updatePushAndReflow(hoveredIndex);
}
function onPointerMove(e) {
if (!dragState || dragState.isFinalizing) return;
dragState.pendingEvent = e;
if (!dragRaf) {
dragRaf = requestAnimationFrame(() => {
dragRaf = null;
processPointerMove();
});
}
}
/* ─── 3. 網格按鍵即時讓位與發光虛線指示器貼合 ─── */
function updatePushAndReflow(hoverIdx) {
if (!dragState) return;
const { sourceIndex, gridKeys, slotRects, indicatorEl } = dragState;
const N = gridKeys.length;
gridKeys.forEach((keyEl, i) => {
keyEl.classList.add('is-shifting');
if (hoverIdx === -1 || hoverIdx === sourceIndex) {
// 未懸停或回到原位:全部歸位
keyEl.style.transform = 'translate3d(0, 0, 0)';
} else if (sourceIndex < hoverIdx) {
// 往後拖曳:區間 (sourceIndex, hoverIdx] 內的按鍵向前遞補一個槽位
if (i > sourceIndex && i <= hoverIdx) {
const targetSlot = i - 1;
const dx = slotRects[targetSlot].left - slotRects[i].left;
const dy = slotRects[targetSlot].top - slotRects[i].top;
keyEl.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
} else {
keyEl.style.transform = 'translate3d(0, 0, 0)';
}
} else if (sourceIndex > hoverIdx) {
// 往前拖曳:區間 [hoverIdx, sourceIndex) 內的按鍵向後順延一個槽位
if (i >= hoverIdx && i < sourceIndex) {
const targetSlot = i + 1;
const dx = slotRects[targetSlot].left - slotRects[i].left;
const dy = slotRects[targetSlot].top - slotRects[i].top;
keyEl.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
} else {
keyEl.style.transform = 'translate3d(0, 0, 0)';
}
}
});
// 100% 絕對貼合之發光虛線指示器更新
if (indicatorEl) {
if (hoverIdx >= 0 && hoverIdx < N) {
const targetRect = slotRects[hoverIdx];
indicatorEl.style.width = `${targetRect.width}px`;
indicatorEl.style.height = `${targetRect.height}px`;
indicatorEl.style.borderRadius = window.getComputedStyle(gridKeys[hoverIdx]).borderRadius || '50%';
indicatorEl.style.transform = `translate3d(${targetRect.left}px, ${targetRect.top}px, 0)`;
indicatorEl.classList.add('active');
} else {
indicatorEl.classList.remove('active');
}
}
}
在順延推擠演算法的基礎上,我們針對轉場細節進行了多項調校:
網格內被推擠讓位的按鍵套用 .is-shifting 類別,賦予其 transition: transform 0.22s cubic-bezier(0.2, 0, 0, 1)。這使得相鄰按鍵在讓出空間時具備平滑自然的過渡,而非生硬瞬間跳格;原按鍵則藉由 .is-placeholder 降低透明度,清楚呈現當前空出的目標插槽。
當使用者放開手指時,系統取得最終確定的目標索引,透過原生陣列操作 splice(sourceIndex, 1) 取出搬移元素,再以 splice(finalIndex, 0, movedItem) 插入目標位置。完成後直接更新本機儲存空間並重繪主鍵盤,整個過程無縫銜接。
每當推擠演算法判定目標槽位發生切換時,系統會觸發一次輕微的觸覺震動,提供使用者明確的空間落點反饋,大幅降低誤放機率。

透過幾何槽位推算、順延推擠演算法與 RAF 批次排程,我們在瀏覽器中成功復刻了原生系統級的網格重排手感。整個過程不更動 DOM 結構,完全依賴 GPU 合成圖層位移,確保在各類行動裝置上都能維持滿幀流暢運行。
在解決了順延排版的基礎後,手指在兩鍵交界處的微小晃動依然可能導致目標槽位頻繁跳躍,大拇指也容易遮蔽下方的落點細節。
明天我們將深入空間物理防抖機制!剖析如何運用超過 15% 距離優勢的遲滯演算法消除邊界閃爍,並加入觸控視線微抬升 22px 與 Apple 原生彈簧落位物理動畫!