iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Build on Google AI

液態玻璃美學:結合 Gemini 與 Antigravity IDE,打造 120FPS、多視窗比例與網格順延推擠佈局的強大商務運算終端系列 第 6

Day 6:【高階商務計算機】仿行動裝置桌面拖曳重排!順延推擠挪位演算法與高幀率排程

  • 分享至 

  • xImage
  •  

昨天我們完成了長算式動態自適應縮放、局部游標精準定位與長按連續退格機制,讓螢幕輸入體驗達到了極致細膩。但在日常與商務運算中,不同使用者對鍵盤佈局有著完全不同的偏好。如果按鍵排列是固定的,便無法發揮最佳輸入效率。然而,許多網頁在實作網格重排時,往往採用生硬的兩鍵直接互換,缺乏原生作業系統般的自然推擠流暢感。

今天我們將正式啟動自訂鍵盤核心演算法的篇章,解構仿 iOS 桌面圖示拖曳重排的順延推擠挪位演算法。透過指標事件捕捉、即時幾何槽位推算、RAF 批次排程避讓動態,以及 100% 視口座標貼合的發光虛線指示器,打造出渾然天成的鍵盤重排體驗。


專案正式上線體驗與開源倉庫

線上體驗 Live Demo:https://410355-collab.github.io/Premium-Business-Calculator/
(強烈建議使用 iPhone Safari 或 Android 手機開啟體驗!)
GitHub 開源倉庫:https://github.com/410355-collab/Premium-Business-Calculator
歡迎提出建議與指導
(開發環境:Google Antigravity IDE + Google Gemini 協同開發)

一、技術痛點:傳統網格拖曳重排的體驗缺陷

在瀏覽器中實作類似手機桌面的多網格圖示拖曳重排,傳統做法通常面臨三大技術瓶頸:

生硬互換破壞連續排版篇:

常見的拖曳庫多半採用單純的兩節點互換(Swap)模式。當拖動按鍵 A 到按鍵 B 的位置時,僅讓 A 與 B 交換位置,其餘按鍵紋絲不動。這種模式破壞了列表或網格的連續流動性,使用者無法直覺預期其他按鍵的流向,視覺感受極為突兀。

DOM 節點頻繁搬移觸發效能崩潰篇:

如果在手指滑動過程中,即時且頻繁地修改真實 DOM 節點的先後順序,瀏覽器會在每秒內反覆觸發強制重排(Reflow)與重繪(Repaint)。在行動裝置中,這會造成嚴重的掉幀與手感卡頓,完全無法維持 120FPS 滿幀響應。

槽位指示器圖層偏差與變形篇:

許多實作將落點指示器作為一個虛擬元素直接插入網格中,這會立刻改變網格其他子元素的幾何佈局,造成畫面劇烈跳動;若採用絕對定位,又常因為父容器的內距、捲軸或變形屬性,導致指示框與目標槽位產生微幅像素位移或圖層錯位。

二、解決方案:順延推擠與絕對幾何貼合系統

為達到原生系統等級的滑順重排效果,我們設計了一套高效的順延排程機制:

順延推擠挪位演算法:

當拖曳按鍵進入新的槽位時,演算法會精確計算起始索引與目標索引之間的相對區間。若往後拖曳,區間內的所有按鍵平滑向左(或上一列)遞補讓位;若往前拖曳,區間內的按鍵則順延向右(或下一列)推擠讓出空位,呈現極度自然的連鎖讓位物理效果。

指標鎖定與獨立懸浮圖層:

拖曳啟動時,將目標按鍵以 setPointerCapture 鎖定指標事件,並在 document.body 建立一個尺寸完全 1:1 等比的獨立懸浮按鍵。透過 position: fixedtranslate3d 進行純 GPU 合成層位移,原網格內的元素則維持佔位狀態,完全不更動真實 DOM 結構。

RAF 批次排程與單向位移快取:

pointerdown 當下即預先快取網格內所有槽位的空間幾何邊界與中心座標。拖曳過程中的所有座標更新與按鍵避讓位移,全部交由 requestAnimationFrame 進行單一幀率合併排程,徹底杜絕多餘的重複計算。

100% 絕對視口貼合之發光虛線指示器:

獨立於網格之外建立全域發光虛線框,當懸停於有效槽位時,直接讀取目標槽位的 getBoundingClientRect 精確幾何數值,以純硬體加速平移至目標位置,完全不干擾網格排版,消除所有圖層與邊距誤差。

三、核心代碼精華:script.js

以下是自訂鍵盤順延推擠挪位演算法與懸浮渲染的核心程式碼:

/* ─── 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');
        }
    }
}

四、深度調校剖析:位移平滑度與釋放落位

在順延推擠演算法的基礎上,我們針對轉場細節進行了多項調校:

CSS 平滑轉場與佔位淡化篇:

網格內被推擠讓位的按鍵套用 .is-shifting 類別,賦予其 transition: transform 0.22s cubic-bezier(0.2, 0, 0, 1)。這使得相鄰按鍵在讓出空間時具備平滑自然的過渡,而非生硬瞬間跳格;原按鍵則藉由 .is-placeholder 降低透明度,清楚呈現當前空出的目標插槽。

放開後的陣列重組與持久化儲存篇:

當使用者放開手指時,系統取得最終確定的目標索引,透過原生陣列操作 splice(sourceIndex, 1) 取出搬移元素,再以 splice(finalIndex, 0, movedItem) 插入目標位置。完成後直接更新本機儲存空間並重繪主鍵盤,整個過程無縫銜接。

零延遲觸覺反饋回饋篇:

每當推擠演算法判定目標槽位發生切換時,系統會觸發一次輕微的觸覺震動,提供使用者明確的空間落點反饋,大幅降低誤放機率。

五、總結

https://ithelp.ithome.com.tw/upload/images/20260909/20183974qNtn5Of9jC.png
透過幾何槽位推算、順延推擠演算法與 RAF 批次排程,我們在瀏覽器中成功復刻了原生系統級的網格重排手感。整個過程不更動 DOM 結構,完全依賴 GPU 合成圖層位移,確保在各類行動裝置上都能維持滿幀流暢運行。

明日預告:空間物理防抖遲滯防抖與視線微抬升

在解決了順延排版的基礎後,手指在兩鍵交界處的微小晃動依然可能導致目標槽位頻繁跳躍,大拇指也容易遮蔽下方的落點細節。

明天我們將深入空間物理防抖機制!剖析如何運用超過 15% 距離優勢的遲滯演算法消除邊界閃爍,並加入觸控視線微抬升 22px 與 Apple 原生彈簧落位物理動畫!


上一篇
Day 5:【高階商務計算機】字級動態縮放與局部游標!徹底解決多位數爆版與長按連續退格
下一篇
Day 7:【高階商務計算機】空間物理防抖—遲滯防抖與視線微抬升、彈簧吸附落位
系列文
液態玻璃美學:結合 Gemini 與 Antigravity IDE,打造 120FPS、多視窗比例與網格順延推擠佈局的強大商務運算終端7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言