不是沒接捲動,是根本不能捲——三行算術推翻「已確認」的診斷
實機驗收跑出一個症狀,Claude 當場給出診斷。那個診斷到現在看還是寫得漂亮:症狀、機制、修法三段扣合,筆記上標的是「已確認」。同一天稍後寫修復計畫、逐檔核對時,它被三行算術推翻——診斷描述的缺陷在幾何上根本不可能發生,提議的修法在解一個不存在的問題。
這個專案怎麼驗收
跟這個專案其他故事同一個背景:Android TV app 重寫,舊版(Android 9、Java)重寫成新版(Android 16、Kotlin、Jetpack Compose)。每個階段做完,在實驗室的電視盒上跑實機驗收——我拿遙控器操作,Claude 判讀 log,逐條驗收項目對驗。這次的階段是系統設定。
設定清單的畫面是一個共用元件 SettingCardGrid:底層是 LazyVerticalGrid,配 rememberLazyGridState(),哪個項目持有焦點(focusedIndex)也一路有追蹤。這些背景在後面會變得很重要。
症狀
系統設定清單七個項目、單欄七列。我的原始回報是這句話:
往下走之後,選項沒有捲上來,所以就走到畫面外了。
按 DOWN,焦點往下走,但畫面不跟著捲——底下兩個項目(CA狀態、STB資料回傳設定)走出可見區,從頭到尾沒進過畫面。實拍起來,畫面上看得到的就是五列,counter 顯示 01 / 07。
這直接卡掉兩條驗收:目標選項在畫面外,沒有辦法目視確認功能對不對。walkthrough 只好先記下「待修好補驗」,繼續往下走。
當場的診斷
Claude 當場給出根因,修法同步寫好。診斷是:清單沒有把「焦點移動」接到「捲動」上,缺這麼一段:
LaunchedEffect(focusedIndex){ gridState.scrollToItem(focusedIndex) }
——也就是「無 focus→bringIntoView 接線」。修法就是把這段補上。
回頭看,這個診斷的說服力來自它的結構:症狀是「不捲動」,機制是「沒有把捲動接上焦點」,修法是「把線接上」。三段扣得太順,而它缺的恰恰是最前面那個問題:這條線,需要人接嗎?
推翻,分三步
同一天稍後(額度中斷後續接的下午),Claude 著手寫修復計畫,逐檔核對。診斷在這裡被推翻。
第一步,撞上框架行為的矛盾。 設定卡片走 TvFocusableItem 這個封裝,底層是 androidx.compose.foundation.focusable(interactionSource = …)。而 foundation 的 focusable 內建 bringIntoView——focusable 項目拿到焦點時,框架自己會把它帶進視野。「缺接線」這個診斷跟框架行為直接矛盾:如果只是缺一條手寫的線,它本來就會捲。
第二步,找到真正的位置。 問題在 SettingCardGrid.kt:109:
Modifier.offset(x = 186.px, y = 282.px).width(1656.px)
這段 modifier 把 grid 往右推 186px、往下推 282px,寬度設成 1656px——唯獨沒有約束高度。而 Modifier.offset 的佈局語意是:它只在做完量測之後平移擺放位置,不會改變量測時拿到的約束。所以 grid 在量測階段拿到的 maxHeight,是父層 fillMaxSize() 給的完整 1080px——它以為自己有一整個螢幕那麼高。
第三步,算術定局。 七個項目、每列 132px,加上六個 24px 的間距:
7 × 132 + 6 × 24 = 1068px < 1080px
內容比約束短。grid 的判斷是「內容完全放得下」,可捲距離 maxScrollOffset = 0——這個 grid 根本不可捲。不是沒接捲動,是無捲可捲。
同一組數字繼續算下去,連症狀的每個細節都對上:grid 被往下推了 282px,真正可見的高度只剩 798px,看得下五列(282 + 5×156 = 1062)——和實拍的「五列、01 / 07」一字不差。
算術在這裡同時做了兩件事:證偽了診斷(可捲距是零,「缺接線」在解一個不存在的問題),也給出正向解釋(為什麼剛好看得到五列)。而原本那個「已確認」的診斷,連「為什麼是五列」都沒有回答過。
照原修法做,不只無效
原修法 scrollToItem 加了也沒用——可捲距離本來就是零,沒有東西可以捲。
更糟的是它會疊出一個新問題:顯式 scrollToItem 和框架內建的 bringIntoView 語意不同——bringIntoView 是「用最小距離把項目帶進視野」,scrollToItem 是「把這項對齊到視窗頂端」。兩個一起跑,每按一次方向鍵,畫面就把焦點項硬拉到頂端,跳動一次。修復計畫最後把「不得加 scrollToItem」寫進範圍外的硬禁止清單——就是防著實作端照舊筆記的推測寫。
錯誤的診斷從來不是中性的:它不只擋住正解,還會在路上長出自己的副作用。
解法
把垂直方向的位移從 offset 換成 padding:
.offset(x = 186.px).padding(top = 282.px).width(1656.px)
padding 和 offset 的差別,剛好就在出事的那一點上:padding 參與量測,會把 grid 拿到的 maxHeight 縮減成 798px,畫面位置看起來不變。內容 1068px 超出 798px,maxScrollOffset 不再是零,grid 恢復可捲——focusable 內建的 bringIntoView 從此有東西可以帶。
實作交給 Gemini 3.1 Pro,獨立 review PASS,實機補驗全數通過,包括本來被卡在畫面外的那兩條。
算術既然對了,就回頭多算一遍
這次還有個附帶收穫。算術既然對上了,Claude 把同一套算式回頭套到其他層的清單上:設定第一層有九個項目、兩欄五列,906px——同樣超出 798px 的可見區。第九項「Google 設定」其實一直幾乎看不見:列高 162px,只露 54px。這個缺陷沒有人回報過,這次一併修好、驗收通過。
至於為什麼前面幾個階段都沒炸:更早的清單多半五項以內,不超出視窗。缺陷從元件寫好的那天就在,只是內容長度一直沒踩到它。
結語
這件事的提醒是:診斷要能被算術檢驗。「症狀→機制→修法」三段扣合的完整感,是敘事的性質,不是證據的性質——它告訴你這個故事講得通,不告訴你這個機制存在。
具體化成一個習慣,就一句話:動手修之前,先算「這個缺陷在幾何上可能發生嗎」。1068 < 1080,一個小於號,就足以判死一個標著「已確認」的診斷——而算出它不需要任何工具,只要肯把數字寫出來。