小程序點(diǎn)餐頁吸頂滾動(dòng)實(shí)現(xiàn)完整代碼
效果圖


小程序點(diǎn)餐頁的連續(xù)滾動(dòng),關(guān)鍵不是錨點(diǎn),而是滾動(dòng)權(quán)
做點(diǎn)餐頁的時(shí)候,很容易遇到一個(gè)看起來不大的體驗(yàn)問題:頁面剛進(jìn)來時(shí),上面有門店信息、活動(dòng)區(qū)、推薦位;繼續(xù)往下滑,菜單區(qū)域吸頂,左邊是分類,右邊是菜品。產(chǎn)品想要的效果通常是“像外賣小程序那樣順”,手指一路滑下去,內(nèi)容自然收起,菜單自然頂住,右側(cè)菜品繼續(xù)滾。
這個(gè)“順”聽起來簡單,真正寫起來卻很容易卡一下。
最常見的實(shí)現(xiàn)方式是把頁面拆成兩個(gè)滾動(dòng)階段:先讓外層頁面滾動(dòng),等菜單吸頂之后,再讓右側(cè)菜品 scroll-view 開始滾。邏輯上很清楚,但體感上經(jīng)常會(huì)出問題。用戶手指沒有松開,滾動(dòng)權(quán)卻從外層切到了內(nèi)層,小程序需要在這一刻完成邊界判斷、吸頂狀態(tài)切換、內(nèi)部滾動(dòng)接管。只要其中任意一步慢半拍,就會(huì)出現(xiàn)那種很微妙的停頓感。
我后來發(fā)現(xiàn),點(diǎn)餐頁想要絲滑,重點(diǎn)不是先找一個(gè)錨點(diǎn)組件,而是先問清楚一件事:這一整段手勢(shì),到底由誰來滾?
不要在吸頂點(diǎn)切換滾動(dòng)容器
最后采用的方案是把 promo-area、菜單區(qū)域、右側(cè)菜品列表都放進(jìn)同一個(gè)縱向 scroll-view 里。也就是說,從活動(dòng)區(qū)被收上去,到菜單吸頂,再到菜品繼續(xù)滾動(dòng),始終是同一個(gè)容器在接收滾動(dòng)。
頁面結(jié)構(gòu)大概是這樣:
demo-hero fixed
scroll-view
promo-area normal
menu-shell
menu-side sticky + inner scroll-view
menu-content normaldemo-hero 固定在頁面頂部,用來模擬門店頭部或者頂部信息區(qū)。promo-area 是普通內(nèi)容,它會(huì)跟著外層 scroll-view 自然滾走。menu-shell 到頂部之后,左側(cè)分類通過 sticky 留在可視區(qū)里,右側(cè)菜品不單獨(dú)開一個(gè)新的縱向滾動(dòng)容器,而是繼續(xù)跟著外層滾。
這套結(jié)構(gòu)最大的好處是,吸頂只是視覺位置發(fā)生變化,滾動(dòng)權(quán)沒有變化。用戶手指下面一直是同一個(gè) scroll-view,所以不會(huì)在吸頂那一刻出現(xiàn)“外層到頭了,內(nèi)層還沒接上”的斷點(diǎn)。
promo 要能收上去,菜單要能留下來
點(diǎn)餐頁里經(jīng)常會(huì)有活動(dòng)卡片、優(yōu)惠券、推薦入口之類的區(qū)域。它們不應(yīng)該和菜單一起固定住,而應(yīng)該像普通內(nèi)容一樣被收上去。
所以 promo-area 不要做 fixed,也不要做 sticky。它只需要在外層滾動(dòng)容器里正常占位:
<scroll-view class="menu-page__scroll" scroll-y :scroll-top="menuBodyTargetScrollTop">
<view class="menu-scroll-content">
<view class="promo-area">
活動(dòng)區(qū)內(nèi)容
</view>
<view class="menu-shell">
<view class="menu-side">分類</view>
<view class="menu-content">菜品</view>
</view>
</view>
</scroll-view>這樣活動(dòng)區(qū)可以被完整收起,菜單區(qū)域又能自然頂?shù)巾敳啃畔^(qū)下面。整個(gè)過程不需要切換容器,只是內(nèi)容在同一個(gè)滾動(dòng)坐標(biāo)系里移動(dòng)。
左側(cè)分類不能跟著菜品一起滾走
真實(shí)點(diǎn)餐頁的分類通常很多,左側(cè)分類欄不能被外層頁面一起帶走。更自然的狀態(tài)是:右側(cè)菜品繼續(xù)縱向滾動(dòng),左側(cè)分類固定在頂部信息區(qū)下方;如果分類太多,左側(cè)自己內(nèi)部滾。
這里可以讓左側(cè)欄使用 position: sticky,并給它一個(gè)剩余視口高度:
const menuSideStickyStyle = {
top: `${demoHeroHeight + menuShellGap}px`,
height: `calc(100vh - ${demoHeroHeight + menuShellGap}px)`,
}
右側(cè)內(nèi)容則不要給固定寬度,讓它直接吃掉剩余空間:
.menu-section {
display: flex;
align-items: flex-start;
}
.menu-side {
position: sticky;
flex: 0 0 160rpx;
}
.menu-content {
flex: 1 1 0;
min-width: 0;
}
這個(gè)地方有個(gè)小坑:左側(cè)如果不是內(nèi)部滾動(dòng),而是跟著整體內(nèi)容一起撐開,分類多了以后它會(huì)把頁面高度拉得很奇怪;右側(cè)如果寬度寫死,又容易在不同屏幕上變窄。一個(gè)固定寬度的左欄,加一個(gè)自適應(yīng)的右欄,通常更穩(wěn)。
左右聯(lián)動(dòng)其實(shí)就是一張位置表
右側(cè)滾動(dòng)時(shí),左側(cè)要知道當(dāng)前應(yīng)該高亮哪個(gè)分類。這個(gè)邏輯不需要復(fù)雜化,本質(zhì)上就是先測(cè)出每個(gè)分類塊在滾動(dòng)容器里的位置,再用當(dāng)前 scrollTop 去查表。
測(cè)量時(shí),把每個(gè) .menu-group 相對(duì)滾動(dòng)內(nèi)容頂部的位置存下來:
categoryOffsets.value = rects.map((rect, index) => ({
id: menuCategories.value[index].id,
top: Math.max(0, rect.top - contentTop),
}))
滾動(dòng)時(shí),用一個(gè)錨點(diǎn)位置去反推當(dāng)前分類:
const anchorTop = scrollTop + fixedHeaderOffset + 24
for (let index = categoryOffsets.length - 1; index >= 0; index -= 1) {
if (anchorTop >= categoryOffsets[index].top) {
activeCategoryId = categoryOffsets[index].id
break
}
}
這里的 fixedHeaderOffset 很重要。因?yàn)轫敳坑泄潭▍^(qū)域,左側(cè)菜單也要貼在它下面,所以判斷當(dāng)前分類時(shí)不能只看原始 scrollTop。否則右側(cè)標(biāo)題看起來已經(jīng)到位了,左側(cè)高亮卻會(huì)慢半拍。
點(diǎn)擊分類時(shí),別讓三套機(jī)制同時(shí)動(dòng)
點(diǎn)擊左側(cè)分類跳轉(zhuǎn)右側(cè)內(nèi)容,是另一個(gè)容易抖的地方。
一開始我也會(huì)自然地想到 scroll-with-animation、scroll-into-view、scroll-anchoring 這些能力。但在這個(gè)場景里,它們疊在一起反而容易互相搶控制權(quán):外層在動(dòng)畫滾,左側(cè)也在動(dòng)畫滾,滾動(dòng)錨定還可能做一次補(bǔ)償;同時(shí)點(diǎn)擊后如果馬上重新測(cè)量布局,滾動(dòng)事件又可能帶著舊位置回來,把高亮狀態(tài)短暫算回上一個(gè)分類。
更穩(wěn)的做法是:點(diǎn)擊時(shí)先高亮目標(biāo)分類,鎖住自動(dòng)聯(lián)動(dòng),然后使用已經(jīng)緩存好的分類偏移量直接設(shè)置外層 scrollTop。
function jumpCategory(category) {
activeCategoryId.value = category.id
manualScrollLock.value = true
const targetOffset = categoryOffsets.value.find(item => item.id === category.id)
const nextScrollTop = targetOffset.top - fixedHeaderOffset.value
pendingJumpTarget.value = {
categoryId: category.id,
scrollTop: nextScrollTop,
}
menuBodyTargetScrollTop.value = Math.max(0, nextScrollTop)
}
滾動(dòng)事件回來時(shí),如果還沒接近目標(biāo)位置,就不要急著重新計(jì)算高亮。等它到達(dá)目標(biāo)附近,再解鎖自動(dòng)聯(lián)動(dòng):
function handleMenuBodyScroll(event) {
const nextScrollTop = event.detail.scrollTop
if (manualScrollLock.value && pendingJumpTarget.value) {
const reachedTarget = Math.abs(nextScrollTop - pendingJumpTarget.value.scrollTop) <= 8
if (reachedTarget) {
manualScrollLock.value = false
pendingJumpTarget.value = null
}
return
}
syncActiveCategoryByScrollTop(nextScrollTop)
}
這個(gè)鎖很小,但它能明顯減少點(diǎn)擊時(shí)的“抖一下”。目標(biāo)小程序里的思路也類似:點(diǎn)擊分類后先進(jìn)入一個(gè)點(diǎn)擊滾動(dòng)中的狀態(tài),滾到目標(biāo)位置后再恢復(fù)自動(dòng)選中。
滾到底部還要做一次糾偏
還有一個(gè)細(xì)節(jié)是底部分類。
很多菜單頁滾到最底部時(shí),最后一個(gè)分類不一定會(huì)被選中。原因是最后一組內(nèi)容可能不夠高,還沒有真正進(jìn)入錨點(diǎn)判斷區(qū),滾動(dòng)就已經(jīng)到底了。用戶看到的是最后一屏,但左側(cè)可能還停在倒數(shù)第二項(xiàng)。
解決方式很直接:當(dāng) scrollTop 接近最大滾動(dòng)距離時(shí),直接選中最后一個(gè)分類。
const maxScrollTop = menuBodyScrollHeight - menuBodyViewportHeight
if (scrollTop >= maxScrollTop - 4) {
activeCategoryId.value = lastCategoryId
return
}
這不是很炫的技術(shù)點(diǎn),但這種小修正對(duì)點(diǎn)餐頁體感很重要。用戶不會(huì)關(guān)心錨點(diǎn)算法是不是優(yōu)雅,只會(huì)覺得“我都滑到底了,左邊為什么沒選中最后一個(gè)?”
為什么沒有直接用 Sidebar 錨點(diǎn)示例
組件庫里的 sidebar + scroll-view 錨點(diǎn)示例當(dāng)然有價(jià)值,它適合“左側(cè)導(dǎo)航,右側(cè)內(nèi)容定位”的常規(guī)頁面。但這個(gè)點(diǎn)餐頁的問題不只是錨點(diǎn)定位,而是連續(xù)滾動(dòng)手感。
這里同時(shí)有頂部固定區(qū)、可收起的 promo 區(qū)、菜單吸頂、左側(cè)內(nèi)部滾動(dòng)、右側(cè)內(nèi)容聯(lián)動(dòng)。如果直接套一個(gè)錨點(diǎn)示例,通常還是會(huì)回到“外層滾一段,內(nèi)層再滾一段”的結(jié)構(gòu),吸頂處就容易出現(xiàn)那一下停頓。
所以這次我沒有把重點(diǎn)放在組件 API 上,而是先重構(gòu)滾動(dòng)模型:讓整個(gè)頁面只有一個(gè)主要縱向滾動(dòng)容器。只要滾動(dòng)權(quán)穩(wěn)定,后面的吸頂、聯(lián)動(dòng)、點(diǎn)擊跳轉(zhuǎn)、底部糾偏都只是圍繞同一套坐標(biāo)系做計(jì)算。
Demo 放在這里
我做了一個(gè)去掉圖片和業(yè)務(wù)邏輯的 demo,只保留結(jié)構(gòu)和滾動(dòng)聯(lián)動(dòng),方便單獨(dú)看效果:
src/pages-demo/menu-scroll-linkage.vue
如果你也在做類似點(diǎn)餐頁,我的建議是先不要急著套錨點(diǎn)組件。先把滾動(dòng)權(quán)想清楚:誰負(fù)責(zé)主滾動(dòng),誰只是固定在視覺位置上,誰需要內(nèi)部滾。這個(gè)問題想明白之后,頁面就不會(huì)在吸頂那一刻突然“換擋”,手感自然會(huì)順很多。
代碼
<script lang="ts" setup>
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
definePage({
style: {
navigationStyle: 'default',
navigationBarTitleText: '菜單聯(lián)動(dòng) Demo',
},
})
interface PromoCard {
title: string
desc: string
mark: string
bg: string
}
interface DishItem {
id: string
no: string
name: string
desc: string
badge: string
price: string
thumbBackground: string
}
interface MenuCategory {
id: string
name: string
subtitle: string
dishes: DishItem[]
}
interface CategorySeed {
id: string
name: string
subtitle: string
desc: string[]
}
const instance = getCurrentInstance()
const promoCards: PromoCard[] = [
{
title: '連續(xù)滾動(dòng)容器',
desc: 'promo 區(qū)和菜品區(qū)都在同一個(gè) scroll-view 里',
mark: '01',
bg: 'linear-gradient(135deg, #fff5e6 0%, #f7d6a6 100%)',
},
{
title: '菜單吸頂',
desc: 'menu-shell 到頂后只是視覺固定,不切換滾動(dòng)容器',
mark: '02',
bg: 'linear-gradient(135deg, #eef3ff 0%, #c8d6ff 100%)',
},
{
title: '左右聯(lián)動(dòng)',
desc: '右側(cè)滾動(dòng)時(shí),左側(cè)分類會(huì)跟著高亮',
mark: '03',
bg: 'linear-gradient(135deg, #fff0f3 0%, #ffc7d3 100%)',
},
]
const categorySeeds: CategorySeed[] = [
{
id: 'recommend',
name: '推薦',
subtitle: '適合開場的示意內(nèi)容',
desc: ['先看這組,信息最少', '更適合演示首屏節(jié)奏', '無圖片也能看出層次'],
},
{
id: 'popular',
name: '熱賣',
subtitle: '更像真實(shí)點(diǎn)餐頁',
desc: ['這里故意放得稍長一點(diǎn)', '方便看滾動(dòng)時(shí)的聯(lián)動(dòng)', '也方便看底部選中狀態(tài)'],
},
{
id: 'main',
name: '主食',
subtitle: '中段常見分類',
desc: ['文字和色塊代替圖片', '重點(diǎn)只保留結(jié)構(gòu)關(guān)系', '方便直接發(fā)文章'],
},
{
id: 'wok',
name: '小炒',
subtitle: '滾動(dòng)測(cè)試很合適',
desc: ['這一組卡片數(shù)量再多一點(diǎn)', '能更明顯看出吸頂效果', '也能看出左右同步'],
},
{
id: 'rice',
name: '蓋飯',
subtitle: '適合測(cè)試中段定位',
desc: ['分類數(shù)量變多之后', '左側(cè)需要自己內(nèi)部滾動(dòng)', '外層不應(yīng)該帶著它上移'],
},
{
id: 'noodle',
name: '面食',
subtitle: '繼續(xù)拉長菜單',
desc: ['模擬真實(shí)長菜單', '點(diǎn)擊類目后對(duì)齊到頂部', '不要被 sticky 頭部蓋住'],
},
{
id: 'soup',
name: '湯品',
subtitle: '熱乎一點(diǎn)的內(nèi)容',
desc: ['觀察左側(cè)是否穩(wěn)定', '右側(cè)繼續(xù)使用外層滾動(dòng)', '分類狀態(tài)同步高亮'],
},
{
id: 'vegetable',
name: '素菜',
subtitle: '輕量分類示例',
desc: ['這組用于測(cè)試快速滾動(dòng)', '左側(cè) scroll-into-view 要跟上', '右側(cè)錨點(diǎn)要準(zhǔn)確'],
},
{
id: 'extras',
name: '加料',
subtitle: '收尾前的補(bǔ)充分類',
desc: ['短列表用于測(cè)試切換', '防止底部選中失效', '也方便驗(yàn)證最后一項(xiàng)'],
},
{
id: 'snack',
name: '小吃',
subtitle: '補(bǔ)充更多類目',
desc: ['繼續(xù)增加分類數(shù)量', '左側(cè)應(yīng)該只在內(nèi)部滾', '頁面外層負(fù)責(zé)菜品滾動(dòng)'],
},
{
id: 'dessert',
name: '甜品',
subtitle: '接近尾部分類',
desc: ['用于驗(yàn)證底部糾偏', '滾動(dòng)到底仍要選中類目', '不要出現(xiàn)空選中'],
},
{
id: 'drinks',
name: '飲品',
subtitle: '最后一屏的收尾分類',
desc: ['滾到這里就能觸發(fā)底部糾偏', '左側(cè)會(huì)自動(dòng)鎖定最后一項(xiàng)', '手感和真實(shí)頁面一致'],
},
]
const badgeList = ['熱銷', '推薦', '現(xiàn)做', '招牌']
const thumbBackgrounds = [
'linear-gradient(135deg, #d97757 0%, #a63c2f 100%)',
'linear-gradient(135deg, #b84e5b 0%, #7e2636 100%)',
'linear-gradient(135deg, #c88d4a 0%, #9b5a26 100%)',
'linear-gradient(135deg, #5673d8 0%, #334a9a 100%)',
'linear-gradient(135deg, #4ea78f 0%, #216a57 100%)',
]
function buildDishes(seed: CategorySeed, categoryIndex: number) {
const count = 4 + (categoryIndex % 2)
return Array.from({ length: count }, (_, dishIndex) => ({
id: `${seed.id}-${dishIndex + 1}`,
no: String(dishIndex + 1).padStart(2, '0'),
name: `${seed.name}示例 ${dishIndex + 1}`,
desc: seed.desc[dishIndex % seed.desc.length],
badge: badgeList[(categoryIndex + dishIndex) % badgeList.length],
price: (16 + categoryIndex * 2.5 + dishIndex * 1.8).toFixed(1),
thumbBackground: thumbBackgrounds[(categoryIndex + dishIndex) % thumbBackgrounds.length],
}))
}
const menuCategories = ref<MenuCategory[]>(
categorySeeds.map((seed, categoryIndex) => ({
id: seed.id,
name: seed.name,
subtitle: seed.subtitle,
dishes: buildDishes(seed, categoryIndex),
})),
)
const heroChips = ['單滾動(dòng)容器', '菜單吸頂', '左右聯(lián)動(dòng)', '無圖片演示']
const activeCategoryId = ref(menuCategories.value[0]?.id || '')
const leftScrollIntoView = ref(`left-cat-${activeCategoryId.value}`)
const demoHeroTopGap = ref(0)
const demoHeroHeight = ref(0)
const demoHeroBottomGap = ref(0)
const menuShellGap = ref(0)
const menuBodyTargetScrollTop = ref(0)
const menuBodyCurrentScrollTop = ref(0)
const menuBodyViewportHeight = ref(0)
const menuBodyScrollHeight = ref(0)
const categoryOffsets = ref<Array<{ id: string, top: number }>>([])
const manualScrollLock = ref(false)
const pendingJumpTarget = ref<{ categoryId: string, scrollTop: number } | null>(null)
const fixedHeaderOffset = computed(() => demoHeroTopGap.value + demoHeroHeight.value + menuShellGap.value)
const menuScrollContentStyle = computed(() => ({
paddingTop: demoHeroHeight.value
? `${demoHeroTopGap.value + demoHeroHeight.value + demoHeroBottomGap.value}px`
: '430rpx',
}))
const menuSideStickyStyle = computed(() => ({
top: `${fixedHeaderOffset.value}px`,
height: `calc(100vh - ${fixedHeaderOffset.value}px)`,
}))
let measureTimer: ReturnType<typeof setTimeout> | null = null
let scrollTimer: ReturnType<typeof setTimeout> | null = null
function scheduleMenuMeasure() {
if (measureTimer) {
clearTimeout(measureTimer)
}
nextTick(() => {
measureMenuShellGap()
measureDemoHero()
measureCategoryOffsets()
})
measureTimer = setTimeout(() => {
measureMenuShellGap()
measureDemoHero()
measureCategoryOffsets()
}, 160)
}
function measureMenuShellGap() {
const { windowWidth = 375 } = uni.getSystemInfoSync()
demoHeroTopGap.value = 0
demoHeroBottomGap.value = 20 * windowWidth / 750
menuShellGap.value = 16 * windowWidth / 750
}
function measureDemoHero() {
if (!instance?.proxy) {
return
}
const query = uni.createSelectorQuery().in(instance.proxy)
query.select('.demo-hero').boundingClientRect()
query.exec((results: any[]) => {
const heroRect = Array.isArray(results) ? results[0] : null
demoHeroHeight.value = Number(heroRect?.height || demoHeroHeight.value || 0)
})
}
function measureCategoryOffsets() {
if (!instance?.proxy || !menuCategories.value.length) {
categoryOffsets.value = []
return
}
const query = uni.createSelectorQuery().in(instance.proxy)
query.select('.menu-scroll-content').boundingClientRect()
query.selectAll('.menu-group').boundingClientRect()
query.select('.menu-page__scroll').boundingClientRect()
query.exec((results: any[]) => {
const contentRect = Array.isArray(results) ? results[0] : null
const rects = Array.isArray(results?.[1]) ? results[1] : []
const scrollRect = Array.isArray(results) ? results[2] : null
const contentTop = Number(contentRect?.top || 0)
menuBodyViewportHeight.value = Number(scrollRect?.height || menuBodyViewportHeight.value || 0)
menuBodyScrollHeight.value = Number(contentRect?.height || menuBodyScrollHeight.value || 0)
categoryOffsets.value = rects.map((rect: any, index: number) => ({
id: menuCategories.value[index]?.id || String(index),
top: Math.max(0, Number(rect?.top || 0) - contentTop),
}))
if (!manualScrollLock.value) {
syncActiveCategoryByScrollTop(menuBodyCurrentScrollTop.value)
}
})
}
function releaseManualScrollLock() {
if (scrollTimer) {
clearTimeout(scrollTimer)
scrollTimer = null
}
const pendingTarget = pendingJumpTarget.value
manualScrollLock.value = false
pendingJumpTarget.value = null
if (pendingTarget) {
activeCategoryId.value = pendingTarget.categoryId
}
}
function scheduleManualScrollUnlock(delay = 360) {
if (scrollTimer) {
clearTimeout(scrollTimer)
}
scrollTimer = setTimeout(releaseManualScrollLock, delay)
}
function setMenuBodyScrollTop(scrollTop: number) {
const nextScrollTop = Math.max(0, scrollTop - fixedHeaderOffset.value)
const isAlreadyThere = Math.abs(menuBodyCurrentScrollTop.value - nextScrollTop) <= 2
if (isAlreadyThere) {
return nextScrollTop
}
menuBodyCurrentScrollTop.value = nextScrollTop
menuBodyTargetScrollTop.value = menuBodyTargetScrollTop.value === nextScrollTop
? nextScrollTop + 1
: nextScrollTop
return nextScrollTop
}
function queryCategoryScrollTop(categoryId: string, callback: (scrollTop: number) => void) {
if (!instance?.proxy) {
callback(0)
return
}
const query = uni.createSelectorQuery().in(instance.proxy)
query.select('.menu-scroll-content').boundingClientRect()
query.select(`#right-cat-${categoryId}`).boundingClientRect()
query.exec((results: any[]) => {
const contentRect = Array.isArray(results) ? results[0] : null
const targetRect = Array.isArray(results) ? results[1] : null
if (!targetRect) {
callback(0)
return
}
callback(Math.max(0, Number(targetRect.top || 0) - Number(contentRect?.top || 0)))
})
}
function syncActiveCategoryByScrollTop(scrollTop: number) {
if (!categoryOffsets.value.length) {
return
}
const maxScrollTop = Math.max(0, menuBodyScrollHeight.value - menuBodyViewportHeight.value)
if (maxScrollTop > 0 && scrollTop >= maxScrollTop - 4) {
const lastId = categoryOffsets.value[categoryOffsets.value.length - 1].id
if (String(lastId) !== String(activeCategoryId.value)) {
activeCategoryId.value = lastId
leftScrollIntoView.value = `left-cat-${lastId}`
}
return
}
const anchorTop = scrollTop + fixedHeaderOffset.value + 24
let currentId = categoryOffsets.value[0].id
for (let index = categoryOffsets.value.length - 1; index >= 0; index -= 1) {
if (anchorTop >= categoryOffsets.value[index].top) {
currentId = categoryOffsets.value[index].id
break
}
}
if (String(currentId) !== String(activeCategoryId.value)) {
activeCategoryId.value = currentId
leftScrollIntoView.value = `left-cat-${currentId}`
}
}
function handleMenuBodyScroll(event: Record<string, any>) {
const nextScrollTop = Number(event.detail?.scrollTop || 0)
if (manualScrollLock.value) {
const pendingTarget = pendingJumpTarget.value
if (pendingTarget) {
const maxScrollTop = Math.max(0, menuBodyScrollHeight.value - menuBodyViewportHeight.value)
const reachedTarget = Math.abs(nextScrollTop - pendingTarget.scrollTop) <= 8
const reachedBottomTarget = pendingTarget.scrollTop >= maxScrollTop - 4 && nextScrollTop >= maxScrollTop - 4
if (reachedTarget || reachedBottomTarget) {
menuBodyCurrentScrollTop.value = nextScrollTop
activeCategoryId.value = pendingTarget.categoryId
scheduleManualScrollUnlock(80)
}
return
}
menuBodyCurrentScrollTop.value = nextScrollTop
return
}
menuBodyCurrentScrollTop.value = nextScrollTop
syncActiveCategoryByScrollTop(nextScrollTop)
}
function jumpCategory(category: MenuCategory) {
activeCategoryId.value = category.id
manualScrollLock.value = true
if (scrollTimer) {
clearTimeout(scrollTimer)
scrollTimer = null
}
const applyCategoryScrollTop = (scrollTop: number) => {
const nextScrollTop = setMenuBodyScrollTop(scrollTop)
pendingJumpTarget.value = {
categoryId: category.id,
scrollTop: nextScrollTop,
}
scheduleManualScrollUnlock()
}
const targetOffset = categoryOffsets.value.find(item => String(item.id) === String(category.id))
if (targetOffset) {
applyCategoryScrollTop(targetOffset.top)
}
else {
queryCategoryScrollTop(category.id, applyCategoryScrollTop)
}
}
function handleDemoAction(name: string) {
uni.showToast({
title: `演示:${name}`,
icon: 'none',
})
}
onMounted(() => {
scheduleMenuMeasure()
})
onBeforeUnmount(() => {
if (measureTimer) {
clearTimeout(measureTimer)
}
if (scrollTimer) {
clearTimeout(scrollTimer)
}
})
</script>
<template>
<view class="demo-page">
<view class="demo-hero">
<view class="demo-hero__eyebrow">
點(diǎn)餐頁連續(xù)滾動(dòng) Demo
</view>
<view class="demo-hero__title">
一個(gè)滾動(dòng)容器,做出菜單吸頂和左右聯(lián)動(dòng)
</view>
<view class="demo-hero__desc">
這個(gè)頁面故意去掉了圖片,只保留結(jié)構(gòu)、滾動(dòng)和狀態(tài)同步,方便直接寫文章。
</view>
<view class="demo-hero__chips">
<text v-for="chip in heroChips" :key="chip" class="demo-chip">
{{ chip }}
</text>
</view>
</view>
<scroll-view
class="menu-page__scroll"
scroll-y
enhanced
:scroll-top="menuBodyTargetScrollTop"
:show-scrollbar="false"
@scroll="handleMenuBodyScroll"
>
<view class="menu-scroll-content" :style="menuScrollContentStyle">
<view class="promo-area">
<scroll-view class="promo-scroll" scroll-x :show-scrollbar="false">
<view class="promo-list">
<view
v-for="card in promoCards"
:key="card.title"
class="promo-card"
:style="{ background: card.bg }"
>
<view class="promo-card__mark">
{{ card.mark }}
</view>
<view class="promo-card__title">
{{ card.title }}
</view>
<view class="promo-card__desc">
{{ card.desc }}
</view>
</view>
</view>
</scroll-view>
</view>
<view class="menu-shell">
<view class="menu-section">
<view class="menu-side" :style="menuSideStickyStyle">
<scroll-view
class="menu-side__scroll"
scroll-y
:scroll-into-view="leftScrollIntoView"
:show-scrollbar="false"
>
<view
v-for="category in menuCategories"
:id="`left-cat-${category.id}`"
:key="category.id"
class="menu-side__item"
:class="{ 'menu-side__item--active': String(activeCategoryId) === String(category.id) }"
@tap.stop="jumpCategory(category)"
>
<text class="menu-side__item-name">
{{ category.name }}
</text>
<text class="menu-side__item-sub">
{{ category.subtitle }}
</text>
</view>
</scroll-view>
</view>
<view class="menu-content">
<view
v-for="category in menuCategories"
:id="`right-cat-${category.id}`"
:key="category.id"
class="menu-group"
>
<view class="menu-group__head">
<view class="menu-group__title-wrap">
<text class="menu-group__title">
{{ category.name }}
</text>
<text class="menu-group__subtitle">
{{ category.subtitle }}
</text>
</view>
<text class="menu-group__count">
{{ category.dishes.length }} 款
</text>
</view>
<view
v-for="dish in category.dishes"
:key="dish.id"
class="dish-card"
>
<view class="dish-card__thumb" :style="{ background: dish.thumbBackground }">
{{ dish.no }}
</view>
<view class="dish-card__main">
<view class="dish-card__head">
<text class="dish-card__name">
{{ dish.name }}
</text>
<text class="dish-card__badge">
{{ dish.badge }}
</text>
</view>
<text class="dish-card__desc">
{{ dish.desc }}
</text>
<view class="dish-card__foot">
<text class="dish-card__price">
¥{{ dish.price }}
</text>
<view class="dish-card__action" @tap.stop="handleDemoAction(dish.name)">
+
</view>
</view>
</view>
</view>
</view>
<view class="menu-content__bottom-space" />
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<style scoped lang="scss">
.demo-page {
min-height: 100vh;
background: linear-gradient(180deg, #f4efe8 0%, #efebe3 40%, #f7f5f0 100%);
}
.menu-page__scroll {
position: fixed;
inset: 0;
z-index: 10;
}
.menu-scroll-content {
min-height: 100%;
padding: 430rpx 0 44rpx;
box-sizing: border-box;
}
.demo-hero {
position: fixed;
top: 0;
left: 24rpx;
right: 24rpx;
z-index: 40;
padding: 30rpx 28rpx 32rpx;
border-radius: 28rpx;
color: #fff;
background: linear-gradient(135deg, #2f221c 0%, #634232 100%);
box-shadow: 0 20rpx 40rpx rgb(54 39 29 / 14%);
backdrop-filter: blur(10px);
transform: translate3d(0, 0, 0);
}
.demo-hero__eyebrow {
display: inline-flex;
padding: 0 14rpx;
height: 36rpx;
border-radius: 999rpx;
align-items: center;
font-size: 22rpx;
color: #f8d7b6;
background: rgb(255 255 255 / 10%);
}
.demo-hero__title {
margin-top: 18rpx;
font-size: 38rpx;
line-height: 54rpx;
font-weight: 700;
}
.demo-hero__desc {
margin-top: 14rpx;
font-size: 26rpx;
line-height: 40rpx;
color: rgb(255 255 255 / 78%);
}
.demo-hero__chips {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.demo-chip {
display: inline-flex;
align-items: center;
height: 38rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: rgb(255 255 255 / 10%);
color: #fff;
font-size: 22rpx;
}
.promo-area {
margin: 0 24rpx;
position: relative;
z-index: 1;
}
.promo-scroll {
white-space: nowrap;
}
.promo-list {
display: inline-flex;
gap: 18rpx;
padding: 0 4rpx 4rpx;
}
.promo-card {
width: 292rpx;
min-height: 184rpx;
padding: 20rpx;
border-radius: 22rpx;
box-sizing: border-box;
color: #2f221c;
box-shadow: 0 14rpx 28rpx rgb(47 34 28 / 8%);
overflow: hidden;
}
.promo-card__mark {
font-size: 22rpx;
font-weight: 700;
opacity: 0.64;
}
.promo-card__title {
margin-top: 18rpx;
font-size: 26rpx;
line-height: 36rpx;
font-weight: 700;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.promo-card__desc {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 32rpx;
color: rgb(47 34 28 / 72%);
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.menu-shell {
margin-top: 16rpx;
width: 100vw;
background: #f5f1ea;
box-sizing: border-box;
}
.menu-section {
display: flex;
width: 100%;
align-items: flex-start;
overflow: visible;
}
.menu-side {
position: sticky;
flex: 0 0 160rpx;
overflow: hidden;
background: rgba(247, 243, 236, 0.94);
border-right: 1rpx solid #eee5d8;
backdrop-filter: blur(10px);
}
.menu-side__scroll {
height: 100%;
}
.menu-side__item {
position: relative;
min-height: 102rpx;
padding: 18rpx 12rpx 18rpx 18rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6rpx;
color: #6e6258;
}
.menu-side__item::before {
content: '';
position: absolute;
left: 0;
top: 18rpx;
bottom: 18rpx;
width: 6rpx;
border-radius: 999rpx;
background: transparent;
}
.menu-side__item--active {
background: #fff;
color: #2f221c;
}
.menu-side__item--active::before {
background: linear-gradient(180deg, #e5b268 0%, #d08a39 100%);
}
.menu-side__item-name {
font-size: 28rpx;
line-height: 40rpx;
font-weight: 700;
}
.menu-side__item-sub {
font-size: 20rpx;
line-height: 30rpx;
color: inherit;
opacity: 0.74;
}
.menu-content {
flex: 1 1 0;
width: auto;
min-width: 0;
background: #fff;
}
.menu-group {
padding: 22rpx 16rpx 18rpx;
border-bottom: 1rpx solid #f2ece3;
}
.menu-group__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16rpx;
}
.menu-group__title-wrap {
min-width: 0;
}
.menu-group__title {
display: block;
font-size: 32rpx;
line-height: 46rpx;
font-weight: 700;
color: #2f221c;
}
.menu-group__subtitle {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
line-height: 32rpx;
color: #9a8f84;
}
.menu-group__count {
flex-shrink: 0;
font-size: 22rpx;
color: #b59b84;
}
.dish-card {
display: flex;
gap: 14rpx;
padding: 16rpx;
border-radius: 20rpx;
background: #fdfcf8;
border: 1rpx solid #f4eee5;
box-sizing: border-box;
}
.dish-card + .dish-card {
margin-top: 14rpx;
}
.dish-card__thumb {
width: 104rpx;
height: 104rpx;
border-radius: 18rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 30rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.dish-card__main {
flex: 1;
min-width: 0;
}
.dish-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10rpx;
}
.dish-card__name {
font-size: 28rpx;
line-height: 40rpx;
font-weight: 600;
color: #2f221c;
}
.dish-card__badge {
flex-shrink: 0;
padding: 0 10rpx;
height: 34rpx;
border-radius: 8rpx;
background: #f7eee1;
color: #9b5a26;
font-size: 20rpx;
line-height: 34rpx;
}
.dish-card__desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 34rpx;
color: #7f746b;
}
.dish-card__foot {
margin-top: 14rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.dish-card__price {
font-size: 28rpx;
line-height: 40rpx;
font-weight: 700;
color: #d94b3e;
}
.dish-card__action {
width: 44rpx;
height: 44rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: #e60012;
color: #fff;
font-size: 30rpx;
line-height: 44rpx;
}
.menu-content__bottom-space {
height: 180rpx;
}
</style>
總結(jié)
到此這篇關(guān)于小程序點(diǎn)餐頁吸頂滾動(dòng)實(shí)現(xiàn)完整代碼的文章就介紹到這了,更多相關(guān)小程序點(diǎn)餐頁吸頂滾動(dòng)內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
使用JavaScript實(shí)現(xiàn)隨機(jī)曲線之間進(jìn)行平滑切換
今天,我運(yùn)用拉格朗日插值法繪制了一條曲線,然而,我并未止步于靜態(tài)展示,而是引入了一個(gè)定時(shí)器,每隔一段時(shí)間便對(duì)曲線上的點(diǎn)進(jìn)行動(dòng)態(tài)更新,從而賦予曲線生命般的動(dòng)態(tài)變化,本文介紹了使用JavaScript實(shí)現(xiàn)隨機(jī)曲線之間進(jìn)行平滑切換,感興趣的朋友可以參考下2024-11-11
js實(shí)現(xiàn)簡易的單數(shù)字隨機(jī)抽獎(jiǎng)(0-9)
這篇文章主要介紹了js實(shí)現(xiàn)簡易的單數(shù)字0-9隨機(jī)抽獎(jiǎng),可以控制抽取隨機(jī)數(shù)開始與停止,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2015-08-08
教你如何在 Javascript 文件里使用 .Net MVC Razor 語法
文章主要是介紹了通過一個(gè)第三方類庫RazorJS,實(shí)現(xiàn)Javascript 文件里使用 .Net MVC Razor 語法,很巧妙,推薦給大家2014-07-07
JS實(shí)現(xiàn)頭像循環(huán)滾動(dòng)示例
這篇文章主要為大家介紹了JS實(shí)現(xiàn)頭像循環(huán)滾動(dòng)示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-11-11
自己編寫的支持Ajax驗(yàn)證的JS表單驗(yàn)證插件
創(chuàng)建一個(gè)JavaScript表單驗(yàn)證插件,可以說是一個(gè)繁瑣的過程,涉及到初期設(shè)計(jì)、開發(fā)與測(cè)試等等環(huán)節(jié)。實(shí)際上一個(gè)優(yōu)秀的程序員不僅是技術(shù)高手,也應(yīng)該是善假于外物的。本文介紹的這個(gè)不錯(cuò)的JavaScript表單驗(yàn)證插件,支持ajax驗(yàn)證,有需要的小伙伴可以參考下2015-05-05
Fullpage.js固定導(dǎo)航欄-實(shí)現(xiàn)定位導(dǎo)航欄
FullPage.js 是一個(gè)簡單而易于使用的插件,用來創(chuàng)建全屏滾動(dòng)網(wǎng)站(也被稱為單頁網(wǎng)站)。接下來通過本文給大家介紹Fullpage.js固定導(dǎo)航欄-實(shí)現(xiàn)定位導(dǎo)航欄,對(duì)fullpage.js導(dǎo)航欄相關(guān)知識(shí)感興趣的朋友一起學(xué)習(xí)吧2016-03-03

