怪乙口類手機APP體驗的做法

有人以為 怪乙口(GEAIKOU) 的手機版是包了殼的 App。其實不是。側滑返回、底部操作列、拉動重整、鍵盤不會蓋住輸入框,手感做得很像原生,但它連 service worker 都沒有,也不能離線。那份「App 感」到底哪來的,這篇來拆。

先說清楚它不是什麼

盤點的時候我自己也愣了一下:整個專案裡沒有 service worker、沒有 vite-plugin-pwa、沒有 workbox,也沒有 beforeinstallprompt 那套自訂安裝流程,連 navigator.vibrate 的觸覺回饋都沒有。PWA 教學文章裡那些關鍵字,一個都沒用到。

它有的只是一個很基本的 site.webmanifestdisplay: standalone、一組 maskable 圖示、背景色黑),加上 index.html 裡一行 viewport:

<meta name="viewport" content="width=device-width, initial-scale=1.0,
  maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />

這一行其實一次做了三件事:user-scalable=nomaximum-scale=1.0 關掉雙擊與捏合縮放(縮放一旦發生,所有手刻的手勢與版面都會亂);viewport-fit=cover 才能讓後面 env(safe-area-inset-*) 拿得到瀏海與 home indicator 的安全區。

換句話說,這個網站可以被裝到桌面、開起來沒有網址列,但那份 App 感全部來自前端,跟 service worker 沒有半點關係。手感是一小塊一小塊跟瀏覽器預設行為磨出來的,沒有一個開關可以按。

手勢:側滑返回,難在它要跟捲動搶同一根手指

最花工夫的一塊是側滑返回(useSwipeToClose)。難題不是「偵測滑動」,而是橫向的關閉手勢和縱向的頁面捲動,用的是同一根手指。判斷錯了,使用者想往下捲,畫面卻被關掉;或者想關閉,畫面卻在捲。

做法是一個「一次性、不可逆」的方向鎖。手指按下後先給一個 5px 的死區,位移不到 5px 什麼都不做:

// 移動距離不足,還不判斷方向
if (absCumDeltaX < 5 && absCumDeltaY < 5) return

一旦超過死區,就比較這一段到底是橫的多還是縱的多,然後鎖定——鎖進「關閉手勢」或「正常捲動」其中一個,這一整段手勢就不再翻覆:

  • 判定為關閉手勢:把 document.documentElement.style.overflow 存起來、設成 hidden 擋掉背景捲動,preventDefault,接下來只跟著手指走 translateX,放開時才播動畫。
  • 判定為正常捲動:直接把 isNormalScrollActiveRef 設起來,之後的 touchmove 一律早退,讓瀏覽器自己捲。

關鍵在「不可逆」這三個字。早期的版本會每一幀重新判斷方向,結果手指稍微斜著抖一下,畫面就在「關閉」和「捲動」之間跳來跳去。鎖定之後就穩了——寧可偶爾判斷得保守一點,也不要中途變卦。這個 hook 後來附了一份三百多行的 README,把橫縱判斷、preventDefault 的時機、拖動時要不要 transition 全寫進去,因為每一條都是踩過才知道的。

viewport:100vh 在手機是一句謊話

桌機上 100vh 就是整個視窗高度。手機不是——網址列會縮放、鍵盤會彈出,100vh 常常比「你真正看得到的高度」還高,於是版面底部被切掉、按鈕掉到摺線下面。

解法是不信任 vh,改用 visualViewportuseDynamicViewport 監聽 visualViewport 的 resize 與 scroll,把它回報的真實高度寫進一個 CSS 變數 --vh,版面改用 calc(var(--vh) * 100)。另一個 hook useVisualViewport 則專門處理鍵盤:鍵盤彈出時 visualViewport 的高度和 pageTop 會變,把這兩個值存成 state,就能把貼在底部的輸入列重新頂到鍵盤上方,而不是被鍵盤蓋住。

背景捲動鎖定(useLockBodyScroll)也比想像中麻煩。iOS Safari 上光是 overflow: hidden 鎖不住,得對 htmlbody 同時下 position: fixedoverscroll-behavior: none,還要逐一把原本的 inline 樣式存起來、解鎖時還原。

這裡有個沒補完的洞:這版的 scroll-lock 沒有存解鎖前的 scrollY。position: fixed 會讓頁面視覺上彈回頂端,解鎖之後若不把捲動位置捲回去,使用者就被丟回最上面。當時我沒想到,後來才發現很多教學也都漏了這步。「鎖住背景」聽起來是一句話的需求,真要做對,其實有四、五個細節要顧。

Radix 給你一個殼,原生手感要自己補完

底部彈出的 sheet 全部用 shadcn / Radix 的 Sheet。但 Radix 給的是「一個會從底部滑上來的面板」,離「像 App 的 sheet」還有一段距離,中間這段全是手工:

  • 拖動關閉的物理感BottomSheet 自己追 touchY,只允許向下位移,拖動的當下把 transition 關掉讓它黏著手指,放開才用 160ms ease-out 收尾,超過 80px 才真的關閉。
  • 擋掉 iOS 的自動 focus:sheet 一打開,iOS 會自動 focus 裡面的 input、把鍵盤彈出來,畫面瞬間跳動。解法是給 onOpenAutoFocus={e => e.preventDefault()},讓使用者自己點才彈鍵盤。
  • 消滅「流氓 transition」:底部操作列在「可用/禁用」狀態之間切換時,因為子元素各自帶了 transition,會出現一種很廉價的「推擠、延展」動畫。最後是在好幾層巢狀元素上補 transition: none 才壓下來——這種 bug 很難查,因為它不是你寫的動畫,是你沒關掉的動畫。
  • 安全區:底部列的 paddingBottomcalc(env(safe-area-inset-bottom) + ...),才不會被 home indicator 壓到。

刻意沒做的事

也有一些「原生 App 標配」是我看過、想過、然後決定不做的:

沒有底部 tab bar——手機導覽仍然是漢堡選單開側欄。也沒有頁面之間的 push / pop 轉場堆疊,framer-motion 只用在開場動畫和後台,一般頁面切換就是直接換。原因很單純:tab bar 和轉場堆疊都需要一套自己的「導覽狀態」,維護成本不低,而這個網站的核心動線(查卡、看卡、組牌)用側欄就夠順了。把力氣花在「單一畫面裡的手感」(手勢、sheet、鍵盤)回報比較高,因為那是使用者每一秒都在碰的東西。

最後

沒有哪一項是關鍵技術。關掉縮放、不信任 vh、鎖背景還要記得把位置還回去、幫 Radix 的殼補上手感、把那些沒關掉的 transition 收乾淨,湊起來才有這份手感。至於 service worker,這個工具本來就不太需要離線,所以一直也沒去補。