
嗯這句話幾乎原樣出現(xiàn)在我們項目的群里。產(chǎn)品經(jīng)理指著設(shè)計稿上那個滾輪選擇器ScrollWheel問我這里面的卡片有的是一行文字有的是兩行文字加一張小圖能不能直接放進同一個滾輪里我第一反應(yīng)是能啊給每個組件設(shè)置不同的高度不就行了。然后我就收獲了一堆奇奇怪怪的 Bug文字重疊、滾動位置錯亂、點擊命中不準。這篇文章就把這個問題的完整鏈路寫清楚為什么滾輪組件天然排斥不同高度的子項、實測會踩到哪些坑、幾種可行的破解方案、不同框架下的落地代碼以及我最后在項目里到底怎么選的。1. 滾輪組件默認只認等高子項問題并非你的錯覺1.1 一個看似簡單卻反直覺的需求先還原一下需求。我們當時做的是一個“目標設(shè)定”頁面用戶需要在一個類似飛盤轉(zhuǎn)輪的滾輪里選擇“每周閱讀時長”“每周運動次數(shù)”這類選項。大部分選項只有一行字但其中有一個選項需要附上說明文字還有一個小圖標。設(shè)計稿上它比別的選項高出一截。在我接手之前大家的想法都很一致ScrollWheel 既然是滾輪那就是一個豎向滾動的容器里面放多個組件天然應(yīng)該支持不同高度。但實際一跑所有的組件都被壓成了同一個高度有的內(nèi)容被截斷了有的周圍出現(xiàn)了一大片空白。這不是我們代碼寫錯了而是滾輪組件的基本運行邏輯決定的。1.2 滾輪的底層布局假設(shè)所有子項共享同一個高度絕大多數(shù)滾輪組件不管叫什么名字底層布局模型都遵循一個極簡規(guī)則把內(nèi)容區(qū)當成一條“傳送帶”每一項占用的高度是同一個固定值滾輪的位置通過“索引 × 固定高度”計算出來。舉個例子。iOS 里 UIPickerView 的rowHeight就是全局設(shè)定的同一時間所有行都遵守這個高度。Flutter 里L(fēng)istWheelScrollView的itemExtent也是整個列表統(tǒng)一的單位長度。Web 端常見的 picker 類組件更是直接把 item 高度寫成了一個常量比如 32px 或者 40px。為什么要這么設(shè)計因為它讓整個布局計算變得極其簡單滾動偏移量可以直接用索引算出來不需要實時測量每一個子項滾輪運動過程中的放大縮小效果強烈依賴“中心位置”概念中心位置如果用統(tǒng)一高度計算一幀之內(nèi)就能完成命中測試也簡單比如當前顯示的五個 item哪個在中間只需要計算偏移量落在哪個區(qū)間即可。一旦打破“所有子項等高”這個假設(shè)上面這套計算全部要重寫每個子項的位置變成累加高度滾動到中間時該對齊哪個點的語義變得模糊點擊命中測試也必須根據(jù)每個子項實際坐標來算。這不是改一個參數(shù)就能解決的問題而是改變整個布局模型的問題。想清楚這一點就明白“Cant the ScrollWheel set components of different heights”這句疑問答案不是“不能”而是“默認模型不支持需要換一種實現(xiàn)思路”。2. 實測第一版不同高度組件在滾輪里的真實表現(xiàn)2.1 現(xiàn)象清單比你想的更離譜知道原理是一回事實際踩坑又是一回事。我在當時直接做了一個最小 demo強制給滾輪里的某個組件設(shè)置了不同高度的 frame。結(jié)果是這樣的高度被視覺壓縮。滾輪為了維持“每個 item 占位一致”會把所有內(nèi)容強制放進同一個矩形區(qū)域高組件的內(nèi)容被壓扁或裁掉。文本重疊。低組件和高組件相鄰時高組件的下半部分會疊到下一個 item 的繪制區(qū)域里視覺上直接亂掉。滾動停止位置錯亂。滾輪在慣性滾動結(jié)束后會找一個“穩(wěn)定點”穩(wěn)定點的計算以統(tǒng)一高度為周期。如果某個 item 實際高度是其他 item 的兩倍滾輪停下來的位置經(jīng)常是兩個 item 的交界處沒有一個 item 真正居中。點擊命中不準。用戶明明點的是第三個 item命中測試卻命中了第二個因為命中區(qū)域還是按統(tǒng)一高度算的。這些現(xiàn)象單獨出現(xiàn)還能忍一起出現(xiàn)的時候基本就屬于“功能不可用”。我自己的測試結(jié)論是在不修改布局模型的前提下試圖通過“直接改高度”讓滾輪支持不同高度的組件是一條死路。2.2 根因追蹤三處計算都建立在“假高度”上我把問題拆了一遍發(fā)現(xiàn)滾輪組件里有三處硬編碼的高度假設(shè)。只要有一個子項高度不同三處全崩。第一處是偏移量換算。滾輪組件維護一個當前偏移量然后靠“偏移量 / 固定高度”反推出當前中心索引。當固定高度是個假高度時索引和內(nèi)容的對齊關(guān)系就錯了。比如第五個 item 實際位置在 200 像素處而組件以為它在 180 像素處滾動就會錯位。第二處是視覺變換。滾輪效果通常會給每個 item 加上縮放、傾斜、透明度越遠離中心越小越淡。這個效果的強弱依賴 item 到中心的距離而距離的計算又依賴統(tǒng)一高度。如果 item 高度不一致離中心同樣像素距離的兩個 item視覺上放大倍率卻完全不同看起來就像在抖動。第三處是 hit test。滾輪組件會根據(jù)每個 item 的相對位置計算點擊區(qū)域計算邏輯通常是從中心開始上下等距劃分。高度不一致后這個等距劃分的區(qū)域與真實渲染區(qū)域不重合點擊自然不準。這三處問題不是靠樣式 hack 能解決的必須從根源上改變布局模型。所以后文講的幾種方案本質(zhì)上都是在“繞開默認布局模型”。3. 破解思路等高網(wǎng)格化、動態(tài)測量、切換容器三選一3.1 方案一等高網(wǎng)格化——把不同內(nèi)容塞進同一個固定高度這個方法最省事思路是“強迫每個 item 在滾輪里占同一個高度但內(nèi)容在 item 內(nèi)部自由布局”。比如統(tǒng)一高度設(shè)為 88 像素一行文字的 item 就在垂直方向居中顯示兩行文字加一張小圖的 item 也在 88 像素內(nèi)部排列內(nèi)容。優(yōu)點很明顯完全不需要改滾輪組件的布局邏輯所有默認效果都還在改動量小穩(wěn)定性高。缺點也同樣明顯會出現(xiàn)大量空白尤其是內(nèi)容很少的 item 占著很高的位置視覺密度不均勻。它適合什么場景呢選項數(shù)量不多內(nèi)容差異不大且 UI 對間距容忍度較高的頁面。比如設(shè)置頁里的滾輪選擇器選項都是“低/中/高”這種差異化小時可以用。但如果設(shè)計稿明確要求不同 item 高度直接不同比如有的是卡片、有的是文字條目這個方法就會被斃掉因為空白太扎眼。我當時的最初版就是用的這個方案順利上線但產(chǎn)品反饋“看起來每個格子都太大”于是才被迫想后面的方案。3.2 方案二動態(tài)測量——讓每個 item 獲得真實尺寸動態(tài)測量的邏輯比較直白在把 item 放進滾輪之前先測量內(nèi)容得到真實高度然后把高度傳給滾輪布局系統(tǒng)。聽起來完美但有一個前提滾輪布局系統(tǒng)必須支持“每個 item 單獨設(shè)置尺寸”。如果用的是 UIPickerView 或ListWheelScrollView這種寫死itemExtent的組件連傳不同高度的入口都沒有那動態(tài)測量就只能搭配“自定義滾輪布局”使用。自定義滾輪怎么做核心是把原來一步到位的布局拆成兩步第一步計算每個 item 的高度。可以提前測量也可以在 item 渲染完成后通過回調(diào)拿到實際高度再刷新布局。第二步自己維護一個內(nèi)容總高度和一個累積偏移表。任何位置計算都不再使用“索引 × 固定高度”而是先遍歷累積偏移表找到當前偏移量對應(yīng)的是哪個 item以及 item 內(nèi)部的相對偏移位置。滾動效果也要自己處理每個 item 到中心的距離需要基于 item 真實位置減去當前偏移量來計算再根據(jù)距離做縮放和透明度的動畫。這套方案能做到視覺上的完美適配但工程量不小。它適合 item 內(nèi)容差異大、數(shù)量不多比如少于 10 個、且交互要求接近原生滾輪的場景。如果內(nèi)容數(shù)量很多動態(tài)測量加自定義布局的計算量會明顯上升后面性能篇會細說。3.3 方案三切換容器——不再用滾輪而是用普通列表加居中高亮很多場景下用戶真正需要的并不是“滾輪的 3D 旋轉(zhuǎn)效果”而是“上下滑動選擇當前選中的項高亮”。既然滾輪對等高有硬性要求那就干脆換成普通列表。普通列表天然支持不同高度的子項因為它的布局模型本身就是線性的每個子項用自己的實際 frame。然后只需要在列表中間畫一條高亮分隔線或者給當前居中的 item 加一個放大效果用戶照樣能感知到“正在選擇哪一項”。這個方案的優(yōu)點是實現(xiàn)簡單、支持任意高度、性能穩(wěn)定、后續(xù)要加多行文本或復(fù)雜卡片都沒問題。缺點也很現(xiàn)實失去了滾輪那種“兩邊逐漸縮小消失”的酷炫效果產(chǎn)品上如果很看重這個視覺特征就得做取舍。我在后面的落地代碼里會展示這個方案的一個具體形態(tài)它是我最終選型的基礎(chǔ)。4. 不同框架的落地代碼參考4.1 Flutter從 ListWheelScrollView 遷移到居中列表Flutter 里L(fēng)istWheelScrollView是標準的滾輪組件它的itemExtent必須固定。如果非要支持不同高度一種替代方案是自己寫 Linear 列表 滾動定位。可以先定義一個控制器監(jiān)聽滾動偏移量實時計算當前中心項索引然后對中間項做放大和透明度的處理class CenterListPage extends StatefulWidget { const CenterListPage({super.key}); override StateCenterListPage createState() _CenterListPageState(); } class _CenterListPageState extends StateCenterListPage { final ScrollController _controller ScrollController(); final Listdouble _itemHeights [56, 88, 56, 120, 56, 72]; override Widget build(BuildContext context) { return Stack( children: [ ListView.builder( controller: _controller, itemCount: _itemHeights.length, itemBuilder: (context, index) { return Container( height: _itemHeights[index], alignment: Alignment.center, decoration: BoxDecoration( color: index.isEven ? Colors.blueGrey : Colors.blueGrey[100], ), child: Text(選項 ${index 1}), ); }, ), Center( child: Container( height: 2, color: Colors.orange, ), ), ], ); } }這個例子用了一個_itemHeights數(shù)組來模擬不同高度實際項目中這個數(shù)組應(yīng)該來自對內(nèi)容測量后的真實結(jié)果。ListView.builder 支持每個 item 不同高度代碼核心就這么簡單一個普通列表加上一條居中指示線。如果想保留滾輪的“中間放大”感覺可以在滾動回調(diào)中根據(jù) item 到中心的像素距離修改 item 的 scale_controller.addListener(() { final offset _controller.offset; for (int i 0; i _itemHeights.length; i) { final itemCenter _getItemCenter(i) - offset; final distance (itemCenter - screenCenter).abs(); final scale (1 - distance / 400).clamp(0.7, 1.0); // 根據(jù) index 用 GlobalKey 找到對應(yīng) item更新 scale } });需要注意Flutter 里L(fēng)istView的 item 并不保證在所有幀都能拿到自己的 RenderObject如果 item 滑出屏幕再通過 GlobalKey 找就可能為空。所以不要在每個滾動幀里做強依賴只做視覺比例更新或者用AnimatedScale配合索引變化來控制。4.2 SwiftUI用 ScrollView 加 scrollTargetBehavior 解決SwiftUI 里以前做滾輪選擇器普遍用UIPickerView包裝實際還是固定高度模型。iOS 17 之后有了scrollTargetBehavior配合 fixedSize 可以實現(xiàn)類似“滾輪吸附”的效果而且 item 高度可以不同。基本思路是普通 ScrollView每個子項設(shè)置自己的高度滾動行為用.scrollTargetBehavior(.viewAligned)讓列表在停止時自動對齊到最近的子項ScrollView { LazyVStack(spacing: 8) { ForEach(items) { item in Text(item.title) .padding() .frame(height: item.height) .scrollTargetLayout() } } } .scrollTargetBehavior(.viewAligned) .frame(height: 300)這里面有兩個關(guān)鍵點。第一scrollTargetLayout()要加在子項的公共容器上讓滾動系統(tǒng)知道對齊的目標是哪個區(qū)域。第二frame(height:)按 item 自己的數(shù)據(jù)設(shè)置不再使用 UIPickerView 那種固定行高。如果項目還停留在低版本 iOS可以用onChange(of: scrollPosition)監(jiān)聽滾動偏移手動計算當前中心 item然后把居中 item 做放大處理。但需要知道手動方案里 item 的真實 y 坐標也要自己維護等于把動態(tài)測量那套邏輯在 SwiftUI 里再實現(xiàn)一遍。我建議能用新 API 就用新 API省心很多。4.3 Web 前端CSS scroll-snap 一行解決Web 端做不同高度的滾輪選擇器最簡單的方式是 CSSscroll-snap-type它允許每個子項有自己的高度同時保持滾動吸附的交互。HTML 結(jié)構(gòu)大致如下div classwheel styleheight: 240px; overflow-y: scroll; scroll-snap-type: y proximity; div classitem styleheight: 56px;選項 A/div div classitem styleheight: 120px; 選項 Bbr/包含兩行說明文字 /div div classitem styleheight: 72px;選項 C/div /divCSS 部分.wheel { scroll-snap-type: y proximity; } .wheel .item { scroll-snap-align: center; }scroll-snap-align: center保證了每次滾動停止時子項盡量對準滾動容器的中間位置。不同 item 的高度可以任意設(shè)置瀏覽器會自動計算對齊位置。這個方案比 JS 定義滾輪組件省力得多而且在移動端和桌面端現(xiàn)代瀏覽器上支持穩(wěn)定。唯一的痛點是“慣性滾動的力度不好控制”有的瀏覽器滾動停止位置距離預(yù)期較遠還是需要一點滾動力度補償邏輯但絕大多數(shù)情況下夠用。5. 團隊項目里的避坑清單與性能底線5.1 測量結(jié)果一定要緩存不管是動態(tài)測量還是普通列表方案都要面對“高度從哪來”的問題。很多人會直接在build方法里實時測量結(jié)果每次滾動都觸發(fā)一輪文本尺寸計算列表一長就掉幀。正確做法是把測量結(jié)果放進一個緩存字典鍵是 item 的唯一標識值是高度。只在 item 內(nèi)容變化時重新測量。如果是圖片異步加載導(dǎo)致的尺寸變化等圖片加載完成后再更新對應(yīng)高度并主動刷新那一個 item而不是刷新整個列表。我這里有一個常見的反面案例列表有 30 個 item每個都包含不等長的文字和一張圖片圖片尺寸又不一樣。直接實時測量時每次滾動都卡卡到明顯掉幀。加了緩存并只在圖片加載完成后更新對應(yīng)行的高度后滾動的流暢度恢復(fù)到接近原生。5.2 滾動過程中的跳動問題用普通列表模擬滾輪時最容易被忽視的問題是“滾動操作中更新 item 高度”。如果用戶正在快速滑動某個 item 因為圖片加載完畢而突然變高整個列表內(nèi)容高度變化滾動位置就會瞬間跳動視覺上像頁面閃了一下。處理思路有兩個方向。一是延遲更新圖片加載完成后不直接改高度而是等頁面靜置超過 300ms 后再更新。這樣用戶滑動過程中不會被打斷。二是保留舊高度如果新舊高度差距不大可以簡單保留舊值等下次布局時再改。大多數(shù)場景都不需要像素級精確體驗優(yōu)先。我自己偏向用“延遲更新”方案因為它不需要處理復(fù)雜的狀態(tài)同步實現(xiàn)起來最直接。5.3 底部留白與慣性邊界普通滾輪的慣性結(jié)束位置通常會被組件自己糾正到最近的一個 item 中心點。但普通列表的慣性是自由滾動最后可能停在任意位置比如停在兩個 item 的交界處或者最后幾個 item 上方還露出很大空白。解決底部空白的方法是給列表內(nèi)容底部補一段 padding讓最后一個 item 也能滾到中心位置。具體數(shù)值大約是容器高度的一半減去最后一個 item 高度的一半。同理頂部也要補一段否則第一個 item 會頂在容器頂部無法居中。如果要實現(xiàn)“自動吸附到最近 item”需要監(jiān)聽滾動結(jié)束事件用animateTo把偏移量移到目標 item 的中心位置。這一步在 Web 端可以直接用 scroll-snap在原生端就手動處理代碼量不大但很關(guān)鍵否則用戶會覺得這個“滾輪”手感怪怪的。5.4 命中測試與無障礙動態(tài)測量方案里自定義命中測試不要自己實現(xiàn)直接用組件系統(tǒng)給出的 hitTest 結(jié)果前提是 item 的真實 frame 已更新。普通列表方案則天然命中正確因為每個 item 的 frame 就是實際 frame。無障礙也要注意滾輪組件一般會給輔助功能標記一個“可調(diào)節(jié)值”但不同 item 高度下輔助功能可能讀不出當前選中的是哪一項。在普通列表方案里最好手動設(shè)置 accessibility 的 value值就是當前高亮的 item 文本。6. 我最終的選型建議和實際心得折騰完這一輪我給團隊寫了一個選型判斷表按需求強度來推薦方案。需求特征推薦方案原因選項內(nèi)容長度差異小UI 允許大量留白等高網(wǎng)格化改動最小、穩(wěn)定性最高選項內(nèi)容差異大但數(shù)量少10 個動態(tài)測量 自定義滾輪視覺最貼近滾輪效果選項內(nèi)容差異大數(shù)量可能很多普通列表 居中高亮性能最穩(wěn)支持任意高度Web 端移動端兼容為主CSS scroll-snap一行代碼解決吸附只想要“能展示不同高度”且不要求 3D 滾輪效果普通列表 高亮線最省事接受度最高我最終在項目里選的是普通列表加居中高亮。原因很簡單選項里有兩行文字和圖片高度差異大而且列表整體不超過 8 個 item不需要 3D 旋轉(zhuǎn)來節(jié)省空間。用戶實際感知上“中間有一條高亮線”和“滾輪停到中間”的區(qū)別非常小但維護成本差很多。回頭看這個問題的本質(zhì)ScrollWheel 的等高設(shè)計是為了用空間換取計算效率。而產(chǎn)品需求是變化的內(nèi)容永遠往更多樣化的方向走。所以與其糾結(jié)“怎么讓滾輪支持不同高度”不如想清楚“滾輪效果是不是這個頁面必需的”。很多時候用戶需要的只是一個能上下滑動、當前項清晰可見的選擇控件而不是一個嚴格意義上的轉(zhuǎn)盤。如果哪天產(chǎn)品經(jīng)理說你一定要保留 3D 滾輪效果那再去自定義布局也不遲——但請做好連續(xù)調(diào)參的準備縮放比例、透明度漸變、遠處 item 的最小尺寸每一個參數(shù)在高矮混合的列表里都要重新校準。這一步?jīng)]有捷徑只能拿真機反復(fù)試。最后再分享一個小技巧無論用哪種方案一開始就做一個“高度差異化調(diào)試頁”把所有極端情況最長文本、最短文本、帶圖片、超長說明塞進去跑一遍。等上線后才發(fā)現(xiàn)某個特殊內(nèi)容導(dǎo)致滾動跳位那就真的晚了。