最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

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

 更新時(shí)間:2026年05月18日 10:32:26   作者:無名路人  
:原生小程序吸頂功能能夠自動(dòng)吸附在頂部,無需用戶手動(dòng)滑動(dòng)返回,提高了用戶瀏覽頁面的流暢度和便捷性,下面這篇文章主要介紹了小程序點(diǎn)餐頁吸頂滾動(dòng)實(shí)現(xiàn)的相關(guā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 normal

demo-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-viewscroll-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)行平滑切換

    使用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ù)字隨機(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 語法

    教你如何在 Javascript 文件里使用 .Net MVC Razor 語法

    文章主要是介紹了通過一個(gè)第三方類庫RazorJS,實(shí)現(xiàn)Javascript 文件里使用 .Net MVC Razor 語法,很巧妙,推薦給大家
    2014-07-07
  • javascript 使用 NodeList需要注意的問題

    javascript 使用 NodeList需要注意的問題

    理解NodeList及其近親NamedNodeMap和HTMLCollection,是從整體上透徹理解DOM的關(guān)鍵所在,這三個(gè)集合都是“動(dòng)態(tài)的”,換句話說,每當(dāng)文檔結(jié)構(gòu)發(fā)生變化時(shí),他們都會(huì)得到更新。
    2013-03-03
  • JS實(shí)現(xiàn)頭像循環(huán)滾動(dòng)示例

    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)證插件

    自己編寫的支持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固定導(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
  • javascript閉包的理解和實(shí)例

    javascript閉包的理解和實(shí)例

    所謂閉包,值得是詞法表示包括不必要計(jì)算的變量的函數(shù),也就是說,該函數(shù)可以使用函數(shù)外定義的變量。
    2010-08-08
  • JS實(shí)現(xiàn)字體選色板實(shí)例代碼

    JS實(shí)現(xiàn)字體選色板實(shí)例代碼

    這篇文章主要介紹了JS實(shí)現(xiàn)字體選色板實(shí)例代碼,有需要的朋友可以參考一下
    2013-11-11
  • 使用正則表達(dá)式的格式化與高亮顯示json字符串

    使用正則表達(dá)式的格式化與高亮顯示json字符串

    這篇文章主要介紹了使用正則表達(dá)式的格式化與高亮顯示json字符串的方法,非常的簡單實(shí)用,需要的朋友可以參考下
    2014-12-12

最新評(píng)論

玉树县| 白城市| 彭水| 凤冈县| 甘谷县| 东城区| 青海省| 海安县| 大足县| 福清市| 三门县| 沙田区| 普兰县| 稷山县| 永顺县| 舟山市| 台南市| 囊谦县| 南召县| 安陆市| 亳州市| 大理市| 逊克县| 腾冲县| 宜州市| 汶上县| 靖宇县| 襄樊市| 兴义市| 增城市| 永仁县| 满城县| 建昌县| 蒙阴县| 三都| 丹阳市| 渭源县| 峨眉山市| 康定县| 迭部县| 呼和浩特市|