Skip to content

tabCard 组件

组件定义:

vue
<!-- components/tabCard/tabCard.vue -->
<template>
  <view class="tab-container theme">
    <!-- 顶部 Tab 区域 -->
    <view class="tab-header">
      <view
        v-for="(tab, index) in tabList"
        :key="index"
        :class="['tab-item', activeIndex === index ? 'active' : '']"
        @click="switchTab(index)"
      >
        <view class="tab-content-wrapper">
          <text class="tab-text">{{ tab.title }}</text>

          <!-- 提示 icon -->
          <view
            v-if="tab.badge || tab.dot || tab.new"
            :class="['tab-badge', tab.dot ? 'dot' : '', tab.new ? 'new-badge' : '']"
          >
            <text
              v-if="tab.badge"
              class="dot"
            >
              {{ tab.badge }}
            </text>
            <text
              v-else-if="tab.new"
              class="new-badge"
            >
              NEW
            </text>
          </view>
        </view>

        <!-- 指示条 -->
        <view
          v-if="activeIndex === index"
          class="tab-indicator"
        ></view>
      </view>
    </view>

    <!-- 内容区域 -->
    <view class="tab-content">
      <view class="content-wrapper">
        <view
          v-for="index in tabList.length"
          :key="index"
          v-show="activeIndex === index - 1"
          :class="['content-item', getSlideDirection(index - 1)]"
        >
          <slot :name="`tab${index - 1}`" />
        </view>
      </view>
    </view>
  </view>
</template>
ts
<script setup lang="ts">
import { defineEmits, defineProps, ref } from 'vue'

// ✅ 定义 Tab 类型接口
interface TabItem {
  title: string
  badge?: number // 数字角标
  dot?: boolean // 红点提示
  new?: boolean // 新标签
  // 其他自定义图标
  // icon?: string;
  // iconActive?: string;
}

// ✅ Props 定义
const props = defineProps({
  tabList: {
    type: Array as () => TabItem[],
    default: () => [{ title: '标签1' }, { title: '标签2' }, { title: '标签3' }],
  },
  initialTab: {
    type: Number,
    default: 0,
  },
  animationType: {
    type: String as () => 'slide' | 'fade' | 'zoom',
    default: 'slide',
    validator: (val: string) => ['slide', 'fade', 'zoom'].includes(val),
  },
})

// ✅ Emits 定义
const emit = defineEmits(['tab-change'])

// 当前选中索引
const activeIndex = ref(props.initialTab)
// 上一个激活的索引,用于判断滑动方向
const prevIndex = ref(props.initialTab)

// ✅ 切换函数
const switchTab = (index: number) => {
  if (activeIndex.value === index) return // 防止重复点击

  prevIndex.value = activeIndex.value
  activeIndex.value = index
  emit('tab-change', { index, tab: props.tabList[index] })
}

// 获取滑动方向类名
const getSlideDirection = (index: number) => {
  if (activeIndex.value !== index) return ''

  const direction = index > prevIndex.value ? 'slide-left' : 'slide-right'
  return `${props.animationType}-${direction}`
}
</script>
scss
<style lang="scss" scoped>
/* -------------------- 通用容器 -------------------- */
.tab-container {
  width: 100%;
  overflow: hidden;
  border-radius: 20rpx;

  .tab-header {
    display: flex;
    background: #fff;
    border-bottom: 1rpx solid #f0f0f0;

    .tab-item {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 20rpx 0;
      position: relative;
      transition: transform 0.3s ease;
      will-change: transform;

      &:active {
        opacity: 0.6;
        transform: scale(0.98);
        transition: all 0.1s ease;
      }

      .tab-text {
        font-size: 28rpx;
        color: #666;
      }

      &.active {
        .tab-text {
          color: var(--vp-c-brand-3);
          font-weight: 600;
          transform: translateY(-2rpx);
        }
      }

      .tab-indicator {
        position: absolute;
        bottom: 0;
        width: 60rpx;
        height: 4rpx;
        border-radius: 2rpx;
        background: #007aff;
        animation: indicatorSlide 0.3s ease;
        will-change: transform, opacity;
      }

      @keyframes indicatorSlide {
        0% {
          transform: scaleX(0.8);
          opacity: 0;
        }
        100% {
          transform: scaleX(1);
          opacity: 1;
        }
      }

      .tab-content-wrapper {
        display: flex;
        position: relative;

        /* -------------------- 角标 / 红点 / NEW -------------------- */

        // 1️⃣ 数字角标
        .tab-badge.badge {
          position: absolute;
          top: 10rpx;
          right: 10rpx;
          min-width: 32rpx;
          height: 32rpx;
          border-radius: 16rpx;
          padding: 0 8rpx;
          display: flex;
          align-items: center;
          justify-content: center;
          background: #ff3b30;

          .badge-text {
            color: #fff;
            font-size: 16rpx;
            font-weight: 500;
            line-height: 1;
          }
        }

        // 2️⃣ 红点
        .tab-badge.dot {
          position: absolute;
          top: 2rpx;
          right: -20rpx;
          width: 14rpx;
          height: 14rpx;
          border-radius: 50%;
          background: #ff3b30;
          padding: 0;
        }

        // 3️⃣ NEW 提示
        .tab-badge.new-badge {
          position: absolute;
          top: -8rpx;
          right: -42rpx;
          padding: 0 6rpx;
          height: 20rpx;
          border-radius: 10rpx;
          background: #0bb23d;
          color: #fff;
          font-size: 14rpx;
          line-height: 20rpx;
          display: flex;
          align-items: center;
          justify-content: center;
        }
      }
    }
  }

  /* -------------------- 内容区域 -------------------- */
  .tab-content {
    min-height: 570rpx;
    background: #fff;
    position: relative;
    overflow: hidden;

    .content-wrapper {
      position: relative;
      width: 100%;
      height: 100%;

      .content-item {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        transition: all 0.3s ease;
        will-change: transform, opacity;

        &.slide-slide-left {
          animation: slideInLeft 0.3s ease forwards;
        }

        &.slide-slide-right {
          animation: slideInRight 0.3s ease forwards;
        }

        &.zoom-slide-left,
        &.zoom-slide-right {
          animation: zoomIn 0.3s ease forwards;
        }
      }
    }
  }

  @keyframes slideInLeft {
    0% {
      transform: translateX(100%);
      opacity: 0;
    }
    100% {
      transform: translateX(0);
      opacity: 1;
    }
  }

  @keyframes slideInRight {
    0% {
      transform: translateX(-100%);
      opacity: 0;
    }
    100% {
      transform: translateX(0);
      opacity: 1;
    }
  }

  @keyframes zoomIn {
    0% {
      transform: scale(0.95);
      opacity: 0;
    }
    100% {
      transform: scale(1);
      opacity: 1;
    }
  }
}
</style>

调用位置:

vue
<tab-card
  :tabList="tabs"
  animation-type="slide"
  @tab-change="onTabChange"
>
  <!-- ✅ 必须用 #插槽 写法 -->
  <template #tab0>
    <!-- 绑定单个属性时有简写 : -->
    <!-- 但是绑定整个对象时,就不可以使用简写了 -->
    <ImgCard v-bind="singleCardData"></ImgCard>
  </template>
  <template #tab1>
    <!-- 进度条组件 -->
    <ProgressBar
      :total-days="120"
      :current-day="49"
      :stages="cropStages"
      ref="progressBarRef"
    />
  </template>
  <template #tab2>
    <ShouyiCard></ShouyiCard>
  </template>
</tab-card>
ts
const tabs = [
  { title: '产品详情' },
  { title: '当前阶段', new: true },
  { title: '预计收益', dot: true },
]
const onTabChange = (e: { index: number; title: string }) => {
  if (progressBarRef.value) {
    if (e.index === 1) {
      progressBarRef.value.scrollToStage()
    }
  }
  console.log('切换到:', e)
}

其中,animation-type="slide" 在调用位置直接传值即可!因为预先在组件中定义不同的动画切换方式。

图片展示组件

轮播组件

uniapp 的内置组件 swiper ,自带的滑动圆点不支持样式设置和点击切换 item,已下是详细实现代码:

<script setup lang="ts">
import { ref } from 'vue'

const indicatorDots = ref(false) // 隐藏原生的
const autoplay = ref(true)
const interval = ref(4000)
const duration = ref(500)

const current = ref(0)

const change = (e: any) => {
  current.value = e.detail.current
}

const clickDot = (index: number) => {
  current.value = index
}
</script>

<template>
  <view class="card-inner">
    <view class="img-head">
      <image
        class="head-left"
        src="http://lychee.iooio.cn:9527/uploads/original/4d/0d/2bdbd7bd407907f53282aeff8083.png"
      ></image>

      <view class="head-right">
        <swiper
          class="head-swiper"
          circular
          :indicator-dots="false"
          :current="current"
          @change="change"
          :autoplay="autoplay"
          :interval="interval"
          :duration="duration"
        >
          <swiper-item v-for="(item, index) in 3" :key="index">
            <image
              class="img-swiper"
              mode="aspectFill"
              src="http://lychee.iooio.cn:9527/uploads/original/4d/0d/2bdbd7bd407907f53282aeff8083.png"
            ></image>
          </swiper-item>
        </swiper>

        <!-- 自定义指示点 -->
        <view class="dots">
          <view
            v-for="(dot, index) in 3"
            :key="index"
            class="dot"
            :class="{ active: current === index }"
            @click="clickDot(index)"
          ></view>
        </view>
      </view>
    </view>

    <view class="card-dec">文字描述</view>
  </view>
</template>

<style lang="scss" scoped>
.img-head {
  padding-top: 10rpx;
  display: flex;
  justify-content: space-between;

  .head-left {
    width: 250rpx;
    height: auto;
    margin-right: 16rpx;
    border-radius: 12rpx;
    box-sizing: border-box;
  }

  .head-right {
    width: 100%;
    position: relative;
    height: 250rpx;
    overflow: hidden;
    border-radius: 12rpx;

    .img-swiper {
      width: 100%;
      height: 250rpx;
      display: block;
    }

    .dots {
      position: absolute;
      bottom: 12rpx;
      left: 50%;
      transform: translateX(-50%);
      display: flex;
      gap: 12rpx;

      .dot {
        width: 14rpx;
        height: 14rpx;
        border-radius: 50%;
        background: rgba(255, 255, 255, 0.4);
      }

      .active {
        background: rgba(255, 255, 255, 1);
      }
    }
  }
}

.card-dec {
  margin-top: 20rpx;
  color: #333;
  font-size: 28rpx;
}
</style>

其中的指示点、当然,对我来说也是知识点:

  1. UniApp 遵循微信小程序的事件规范,其中 detail 用于传递组件自定义数据:
<!-- 组件触发事件时 -->
<swiper @change="change">
  <!-- swiper 组件在变化时会触发事件,并通过 detail 传递当前页码 -->
</swiper>
  1. 自定义事件数据

当组件触发事件时,相关的数据都放在 detail 中:

// swiper 组件变化时
const change = (e) => {
  console.log(e.detail.current) // 当前页码
}

// picker 组件选择时
const pickerChange = (e) => {
  console.log(e.detail.value)   // 选中的值
}

// 按钮点击时
const buttonClick = (e) => {
  console.log(e.detail)         // 可能包含其他信息
}
  1. 统一的数据通道

detail 提供了一个统一的属性来传递事件相关数据,而不是直接在事件对象上添加各种属性。

实际示例

<template>
  <!-- 各种组件的事件都使用 detail -->
  <swiper @change="onSwiperChange">
  <picker @change="onPickerChange">
  <button @click="onButtonClick">
  <input @input="onInput">
</template>

<script>
export default {
  methods: {
    onSwiperChange(e) {
      console.log(e.detail.current) // 轮播图当前索引
    },
    onPickerChange(e) {
      console.log(e.detail.value)   // 选择器选中的值
    },
    onButtonClick(e) {
      console.log(e.detail)         // 可能包含其他信息
    },
    onInput(e) {
      console.log(e.detail.value)   // 输入框的值
    }
  }
}
</script>

设计原因

  1. 一致性:所有组件的事件数据都通过 detail 传递
  2. 扩展性:可以在 detail 中自由添加数据而不污染事件对象
  3. 兼容性:与微信小程序、支付宝小程序等保持一致性

所以 e.detail 是 UniApp/小程序生态中的标准做法,不是 JavaScript 原生事件的对象结构。

原生uni-swiper-dot组件的使用

相比之下 - 这个效果更丝滑(需要依赖其他uni-组件),因此,必须使用 hbuild 工具整个安装这个插件包!

其实,参照这个纯手写一个圆点的,也不难,自己手写可以避免一些问题,例如(可能)指示点的定位问题,官方可能依据图片下边距进行相对定位,导致不够细致的用户体验,因为图片比例会有些差异,沃恩手写的话,可以写定,直接相对父盒子进行定位!

html
<view class="custom-swiper-container">
  <uni-swiper-dot
    :info="info"
    :current="current"
    field="content"
    mode="default"
    @clickItem="clickItem"
  >
    <swiper
      class="swiper-box"
      :current="current"
      @change="change"
      :autoplay="autoplay"
      :interval="interval"
      :duration="duration"
      circular
    >
      <swiper-item
        v-for="(item, index) in info"
        :key="index"
      >
        <view
          class="swiper-item"
          :class="item.colorClass"
        >
          <image
            :src="item.url"
            mode="aspectFill"
            class="swiper-image"
          />
          <text class="swiper-text">{{ item.content }}</text>
        </view>
      </swiper-item>
    </swiper>
  </uni-swiper-dot>
</view>
js
<script setup lang="ts">
import { ref } from 'vue'

const indicatorDots = ref<boolean>(true)
const autoplay = ref<boolean>(true)
const interval = ref<number>(4000)
const duration = ref<number>(500)

// 添加 current 响应式变量
const current = ref<number>(0)

const info = [
  {
    colorClass: 'uni-bg-red',
    url: 'https://qiniu-web-assets.dcloud.net.cn/unidoc/zh/shuijiao.jpg',
    content: '内容 A',
  },
  {
    colorClass: 'uni-bg-green',
    url: 'https://qiniu-web-assets.dcloud.net.cn/unidoc/zh/shuijiao.jpg',
    content: '内容 B',
  },
  {
    colorClass: 'uni-bg-blue',
    url: 'https://qiniu-web-assets.dcloud.net.cn/unidoc/zh/shuijiao.jpg',
    content: '内容 C',
  },
]

// 添加 change 方法
const change = (e: { detail: { current: number } }) => {
  current.value = e.detail.current
  console.log('swiper 切换:', current.value)
}

// 添加 clickItem 方法
const clickItem = (index: number) => {
  current.value = index
  console.log('点击指示点:', index)
}
</script>
css
.custom-swiper-container {
  margin-top: 40rpx;
  position: relative;

  .swiper-box {
    height: 400rpx;
    border-radius: 12rpx;
    overflow: hidden;
  }

  .swiper-item {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
  }

  .swiper-image {
    width: 100%;
    height: 100%;
  }

  .swiper-text {
    position: absolute;
    bottom: 30rpx;
    left: 30rpx;
    color: white;
    font-size: 36rpx;
    font-weight: bold;
    text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
  }
}

// 背景色类
.uni-bg-red {
  background-color: #ff5a5f;
}

.uni-bg-green {
  background-color: #09bb07;
}

.uni-bg-blue {
  background-color: #007aff;
}

导航栏专属坑位

固定定位

直接写页面导航栏是万万不能的,编写了导航栏组件,需要对其中的定位做特殊的安排!

原始页面没有专门针对navBar的安排,页面结构无序、不规范,无任何参考意义,总结一句话,把navbar当作普通的页面标签来写了:

vue
<template>
  <view
    class="navbar"
    :style="{ paddingTop: (safeAreaInsets?.top ?? 0) + 26 + 'px' }"
  >
    <!-- top文字 -->
    <view class="top">
      <view class="top-left">
        <text class="title">登海605D (抗虫版)</text>
        <view class="dec">
          <view class="dec-left">
            <text style="padding-bottom: 14rpx">面积:1800亩</text>
            <text>栽培方式:宽窄行</text>
          </view>
          <view class="dec-right">
            <text style="padding-bottom: 14rpx">位置:12号地块</text>
            <text>密度:5500株/亩</text>
          </view>
        </view>
      </view>
      <image
        src="https://lychee.iooio.cn:9528/uploads/original/91/75/684b95f8f9d86ab96b283a4cb4b7.png"
        class="top-right"
        mode="heightFix"
      >
        .
      </image>
    </view>

    <view class="dikuai-info">
      <view class="info-group">
        <text>14℃</text>
        <text>土壤温度</text>
      </view>
      <view class="info-group">
        <text>57%</text>
        <text>土壤湿度</text>
      </view>
      <view class="info-group">
        <text>7.2</text>
        <text>土壤PH</text>
      </view>
      <view class="info-group">
        <text>1.3m/s</text>
        <text>风速</text>
      </view>
    </view>

    <uni-notice-bar
      :speed="30"
      show-icon
      showClose
      scrollable
      single
      text="您购买的登海605d,由登海公司授权德丰农资总经销,大同德丰农资 - 为您提供从种到收的全过程服务!联系电话:0530-5551905"
    ></uni-notice-bar>

    <tab-card
      :tabList="tabs"
      animation-type="slide"
      @tab-change="onTabChange"
    >
      <!-- ✅ 必须用 #插槽 写法 -->
      <template #tab0>
        <!-- 绑定单个属性时有简写 : -->
        <!-- 但是绑定整个对象时,就不可以使用简写了 -->
        <ImgCard v-bind="singleCardData"></ImgCard>
      </template>
      <template #tab1>
        <!-- 进度条组件 -->
        <ProgressBar
          :total-days="120"
          :current-day="49"
          :stages="cropStages"
          ref="progressBarRef"
        />
      </template>
      <template #tab2>
        <ShouyiCard></ShouyiCard>
      </template>
    </tab-card>
  </view>
  <view class="main-page">
    <view class="info-days theme-card">
      <view class="info-days-top">
        <view class="bozhong-days">
          <text>已播种(天)</text>
          <text>25</text>
        </view>
        <view class="shouhuo-days">
          <text>预计85天后</text>
        </view>
      </view>
      <view class="info-days-bottom"></view>
    </view>
    <button>数据详情</button>
  </view>
</template>

显然,需要改造!

首先,对于navBar标签,外层使用一个父容器包裹,这是为了让页面结构整体更加直观、递进,也因为nav导航栏的定位方式有它的特殊性,必须与其他页面结构分开(当然,包裹父容器其实并非必须的):

vue
<template>
  <view class="dikuai-details-page">
    <!-- 完整导航栏结构 -->
    <view
      class="navbar"
      :style="{
        height: navbarHeight,
        paddingTop: (safeAreaInsets?.top ?? 0) + 'px',
      }"
    >
      <!-- 顶部导航的主主结构,包含title和返回按钮 -->
      <view
        class="nav-content"
        style="height: 40px"
      >
        <uni-icons
          type="left"
          size="20"
          @click="back"
        ></uni-icons>

        <view
          class="nav-title"
          :style="{
            transform: `translateX(calc(-50% - ${Math.min(242, Math.max(500 * (normalizedScrollTop / 100)))}rpx))`,
            transition: 'padding-left 0.3s ease',
          }"
        >
          地块详情
        </view>
      </view>
      
      <!-- 主要内容区域 -->
      <view
        class="content-wrapper"
        :style="{ paddingTop: navbarHeight }"
      >
        <!-- 此处略去页面中的其他标签结构 -->
      </view>
      
    </view>
  </view>
</template>
scss
.dikuai-details-page {
  position: relative; /* 为内部元素创建定位上下文, 目前没发现有用 */
  min-height: 100vh;  /* 这个其实也无必要 */
  background: linear-gradient(180deg, #4fdba0 20%, #f9fafa 90%);

  // 固定导航栏样式
  .navbar {
    position: fixed;  /* ✅ 脱离文档流, 相对于浏览器视口定位,不是相对于外层容器 */
    top: 0;
    left: 0;
    right: 0;
    background: rgba(79, 219, 160);
    z-index: 9999;
    box-sizing: border-box;

    .nav-content {
      padding-left: 16rpx;
      position: relative;
      display: flex;
      align-items: center;
      // justify-content: center;

      .nav-title {
        font-size: 34rpx;
        font-weight: 600;
        color: #333;
        padding-bottom: 4rpx;
        position: fixed;
        left: 50%;
        transform: translateX(-50%);
      }
    }
  }
}

脱离文档流的表现:

css
.navbar {
  position: fixed;  /* ✅ 脱离文档流 */
  top: 0;
  left: 0;
  right: 0;
}

效果:

  1. 不占用原空间:后面的元素会向上移动填补位置
  2. 独立层级:创建新的堆叠上下文
  3. 相对于视口:不受父元素影响
  4. 不随页面滚动:固定在屏幕指定位置

WARNING

因为导航栏标签使用了固定定位,因此需要为后面的整体页面结构进行打包,然后在打包后的标签上添加动态属性:

vue
<!-- 主要内容区域 -->
<view
  class="content-wrapper"
  :style="{ paddingTop: navbarHeight }"
>
	<!-- 此处略去原有标签结构 -->
</view>

动态导航栏

同样的、首先还是构造一个处理页面滚动的逻辑:

js
// 处理页面滚动
const scrollTop = ref(0)

// 在页面中直接使用onPageScroll生命周期
onPageScroll(e => {
  scrollTop.value = e.scrollTop
  console.log('距离顶部:', scrollTop.value, 'px /', scrollTop.value / 2 + 'rpx')
}) 

// 当前,调试信息生效了,但是还需要进一步完善,我们需要它返回100以内的整数
// 其余滚动的数值不需要返回,当数值大于100时,仍返回100!
ts
// 处理页面滚动
const navTitlePadding = ref(250)
const scrollTop = ref(0)
const normalizedScrollTop = ref(0) // 标准化后的滚动值 // [!code++]

// 在页面中直接使用onPageScroll生命周期
onPageScroll(e => {
  scrollTop.value = e.scrollTop
  const scrollTopRpx = e.scrollTop / 2
  
  // 标准化滚动值:限制在0-100之间,取整数 // [!code++]
  normalizedScrollTop.value = Math.min(100, Math.max(0, Math.round(scrollTopRpx))) // [!code++]
  
  console.log('📊 滚动信息:', {
    '原始值(px)': e.scrollTop,
    '转换值(rpx)': scrollTopRpx.toFixed(1), // [!code++]
    '标准化值': normalizedScrollTop.value // [!code++]
  })
  
  // 更新导航栏内边距 // [!code++]
  updateNavPadding(normalizedScrollTop.value) // [!code++]
})

// 更新导航栏内边距的函数
const updateNavPadding = (scrollValue: number) => {
  if (scrollValue <= 100) {
    const progress = scrollValue / 100
    navTitlePadding.value = 250 - (200 * progress)
  } else {
    navTitlePadding.value = 50
  }
}
ts
// 处理页面滚动
const navTitlePadding = ref(250) // 250 是标签位置的实际原始值 // [!code--]
const normalizedScrollTop = ref(0) // 标准化后的滚动值(0-100)

// 在页面中直接使用onPageScroll生命周期
onPageScroll(e => {
  const scrollTopRpx = e.scrollTop / 2

  normalizedScrollTop.value = Math.min(100, Math.round(scrollTopRpx))

  console.log('🎯 标准化滚动值:', normalizedScrollTop.value)

  // 使用非线性函数提高灵敏度 // [!code--]
  const progress = Math.pow(normalizedScrollTop.value / 100, 0.5) // 平方根函数,前期变化更快 // [!code--]
  navTitlePadding.value = 250 - 200 * progress // [!code--]
})

关键点说明:

  1. Math.round() - 四舍五入取整数
  2. Math.min(100, ...) - 限制最大值不超过100
  3. Math.max(0, ...) - 确保最小值不小于0(虽然滚动值不会为负)
  4. 标准化范围 - 始终返回0-100的整数

然后,调用位置:

html
<!-- 固定的导航栏 -->
    <view
      class="navbar"
      :style="{
        height: navbarHeight,
        paddingTop: (safeAreaInsets?.top ?? 0) + 'px',
      }"
    >
      <!-- 这里可以添加导航内容,比如返回按钮 -->
      <view
        class="nav-content"
        style="height: 40px"
      >
        <uni-icons
          type="left"
          size="20"
          @click="back"
        ></uni-icons>

        <text
          class="nav-title"
          :style="{
            paddingLeft: Math.max(10, 250 - 500 * (normalizedScrollTop / 100)) + 'rpx',
            transition: 'padding-left 0.3s ease',
          }"
        >
          地块详情
        </text>
      </view>
    </view>
css
  // 固定导航栏样式
  .navbar {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    background: rgba(79, 219, 160);
    z-index: 9999;
    box-sizing: border-box;

    .nav-content {
      padding-left: 16rpx;

      display: flex;
      align-items: center;
      // justify-content: center;

      .nav-title {
        font-size: 34rpx;
        font-weight: 600;
        color: #333;
        padding-bottom: 4rpx;
      }
    }
  }

其中:

<text
  class="nav-title"
  :style="{
    paddingLeft: Math.max(10, 250 - 500 * (normalizedScrollTop / 100)) + 'rpx',
    transition: 'padding-left 0.3s ease',
  }"
>
  地块详情
</text>

上面的行内样式数值是写死的,需要优化:

html
<view
  class="nav-title"
  :style="{
    transform: `translateX(calc(-50% - ${Math.min(242, Math.max(0, 500 * (normalizedScrollTop / 100)))}rpx))`,
    transition: 'padding-left 0.3s ease',
  }"
>
  地块详情
</view>
css
.nav-content {
  padding-left: 16rpx;
  position: relative;
  display: flex;
  align-items: center;
  // justify-content: center;

  .nav-title {
    font-size: 34rpx;
    font-weight: 600;
    color: #333;
    padding-bottom: 4rpx;
    position: fixed;
    left: 50%;
    transform: translateX(-50%);
  }
}

优化后的行内样式解读:

  1. 最内层计算250 * (normalizedScrollTop.value / 100)
  • normalizedScrollTop.value / 100:将0-100的滚动值转换为0-1的进度比例
  • 250 * 进度:计算基础移动距离,最大250rpx
  • 作用:根据滚动进度计算要移动的距离
  1. 中层保护Math.max(0, ...)
  • 确保移动距离不小于0rpx
  • 防止出现负值导致异常
  • 作用:设置移动距离的最小值
  1. 外层限制Math.min(121, ...)
  • 确保移动距离不超过121rpx
  • 即使滚动到100,最大也只移动121rpx
  • 作用:设置移动距离的最大值
  1. 最终定位calc(50% - ...rpx)
  • 50%:相对于父容器宽度的50%位置
  • - ...rpx:从居中位置向左移动计算出的距离
  • 整体效果:元素从居中位置逐渐向左移动

数值变化过程:

滚动值计算步骤最终left值
0Math.min(121, Math.max(0, 250*0)) = 0calc(50% - 0rpx)
25Math.min(121, Math.max(0, 250*0.25)) = 62.5calc(50% - 62.5rpx)
50Math.min(121, Math.max(0, 250*0.5)) = 121calc(50% - 121rpx)
100Math.min(121, Math.max(0, 250*1)) = 121calc(50% - 121rpx)

视觉效果:

  • 初始:元素完美居中
  • 滚动中:元素向左平滑移动
  • 滚动到50+:元素停在距中心121rpx的位置

自定义导航组件

之前,手写的自定义导航组件,完整的还原了设计稿,但是,页面滚动时看出来问题了 —— 所有内容,包括自定义导航组件在内,都会跟随页面滚动,且滚动内容在滚动时与顶部状态栏重叠!

需要对其进行定位改造,并记录定位方式:

vue
<template>
  <view
    class="navbar"
    :style="{ paddingTop: safeAreaInsets?.top + 'px' }"
  >
    <!-- logo文字 -->
    <view class="logo">
      <image
        class="logo-image"
        src="@/static/images/yun-logo.png"
      ></image>
      <text class="logo-image-text">云峰农服</text>
      <!-- <text class="logo-text">快捷 · 易用 · 高效</text> -->
    </view>

    <!-- 搜索条 -->
    <!-- <view class="search">
      <text class="icon-search">搜索商品</text>
      <text class="icon-scan"></text>
    </view> -->

    <!-- navBar-bottom -->
    <view class="navbar-bottom">
      <div class="tianqi">
        <text class="tianqi-temp">{{ weatherStore.weatherData?.tem }}°</text>
        <text class="tianqi-desc">晴</text>
        <image
          class="tianqi-icon"
          src="@/static/icon/天气-晴.svg"
        ></image>
      </div>
      <div class="location">
        <text class="iconfont location-icon">&#xe790;</text>
        <text class="info-text">牡丹区</text>
      </div>
      <div class="shidu">
        <text class="iconfont shidu-icon">&#xe682;</text>
        <text class="info-text">65%</text>
      </div>
      <div class="air">
        <text class="iconfont air-icon">&#xe60e;</text>
        <text class="info-text air-text">良好</text>
      </div>
    </view>
  </view>
</template>
scss
<style lang="scss">
/* 自定义导航条 */
.navbar {
  background-image: url(@/static/images/svg/background.svg);
  background-size: cover;
  position: relative;
  display: flex;
  flex-direction: column;
  padding-top: 10rpx;

  // 其他样式
}
</style>

改造后,在原有组件结构外层包裹一层父容器:

vue
<template>
  <view class="viewport">
    <CustomNavbar ref="navbarRef"></CustomNavbar>
    <view
      class="page-content"
      :style="{
        paddingTop: dynamicPaddingTop + 'px',
      }"
    >
      <!-- 此处略去其他标签结构 -->
    </view>
  </view>
</template>
scss
<style scoped lang="scss">
.viewport {
  height: 100%;
  display: flex;
  background-color: #f5f7fa;
  flex-direction: column;
  overflow-x: hidden;

  .page-content {
    // flex: 1,占据所有剩余空间
    flex: 1;
    // 因为滚动本来就是实时的,且属性没有发生离散变化,所以不需要过渡效果
    // transition: padding-top 0.3s ease-out; // 添加过渡效果
  }

  .main-list {
    font-size: 30rpx;
    color: #333;
    font-weight: 600;
    display: flex;
    padding: 0 30rpx;
    margin: 20rpx 0;
  }
}
</style>

关键改动,其实就是外层包裹父容器,然后父容器固定定位!也就说,包不包父容器,很可能没啥影响 0.0

相比之下,使用父容器包一层,更主要的目的在于:为了区分navbar 标签其他标签

经过上述定位安排之后,调用页面的其他其他标签,就需要为其设置一个动态的padding-top,否则,页面结构会被相对视口固定的navbar组件遮挡!

自定义导航组件的动态实现

思路:通过调用页面构造滚动函数,输出标准化数值,然后充分、巧妙的利用该数值!

改造前:

vue
<template>
  <view class="viewport">
    <CustomNavbar ref="navbarRef"></CustomNavbar>
    <view
      class="page-content"
      :style="{ paddingTop: paddingTop + 'px' }"
    >
      <indexVideo></indexVideo>
      <text class="main-list">实时画面(4)</text>

      <videoList></videoList>
      <text class="main-list">异常信息(2)</text>
      <infoList></infoList>
      <WeatherStation></WeatherStation>
    </view>
  </view>
</template>

<script setup lang="ts">
import { onPageScroll } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import CustomNavbar from './components/CustomNavbar.vue'
import indexVideo from './components/indexVideo.vue'
import infoList from './components/infoList.vue'
import videoList from './components/videoList.vue'
import WeatherStation from './components/weatherStation.vue'
const navbarRef = ref()
const paddingTop = ref() // 默认值

// 处理页面滚动
const navBarPadding = ref()
const normalizedScrollTop = ref(0) // 标准化后的滚动值(0-100)

// 在页面中直接使用onPageScroll生命周期
onPageScroll(e => {
  const scrollTopRpx = e.scrollTop / 2

  normalizedScrollTop.value = Math.min(100, Math.round(scrollTopRpx))

  console.log('🎯 标准化滚动值:', normalizedScrollTop.value)
})

onMounted(() => {
  if (navbarRef.value?.navbarHeight) {
    paddingTop.value = navbarRef.value.navbarHeight
  }
})
</script>

<style scoped lang="scss">
.viewport {
  height: 100%;
  display: flex;
  background-color: #f5f7fa;
  flex-direction: column;
  overflow-x: hidden; /* 防止横向溢出 */

  .main-list {
    font-size: 30rpx;
    color: #333;
    font-weight: 600;
    display: flex;
    padding: 0 30rpx;
  }
}
</style>
vue
<script setup lang="ts">
import { computed } from 'vue'
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 计算导航栏总高度
const navbarHeight = computed(() => {
  const topInset = safeAreaInsets?.top || 0
  const contentHeight = 120 // 导航栏内容高度,根据实际情况调整
  return topInset + contentHeight
})

// 暴露高度给父组件
defineExpose({
  navbarHeight: navbarHeight.value,
})
console.log(safeAreaInsets)

import { useWeatherStore } from '@/stores'
const weatherStore = useWeatherStore() // 全局天气状态
</script>

<template>
  <view
    class="navbar-container"
    :style="{ height: navbarHeight + 'px' }"
  >
    <view
      class="navbar"
      :style="{ paddingTop: safeAreaInsets?.top + 'px' }"
    >
      <!-- logo文字 -->
      <view class="logo">
        <image
          class="logo-image"
          src="@/static/images/yun-logo.png"
        ></image>
        <text class="logo-image-text">云峰农服</text>
        <!-- <text class="logo-text">快捷 · 易用 · 高效</text> -->
      </view>

      <!-- navBar-bottom -->
      <view class="navbar-bottom">
        <view class="tianqi">
          <text class="tianqi-temp">{{ weatherStore.weatherData?.tem }}°</text>
          <text class="tianqi-desc">晴</text>
          <image
            class="tianqi-icon"
            src="@/static/icon/天气-晴.svg"
          ></image>
        </view>
        <view class="location">
          <text class="iconfont location-icon">&#xe790;</text>
          <text class="info-text">牡丹区</text>
        </view>
        <view class="shidu">
          <text class="iconfont shidu-icon">&#xe682;</text>
          <text class="info-text">65%</text>
        </view>
        <view class="air">
          <text class="iconfont air-icon">&#xe60e;</text>
          <text class="info-text air-text">良好</text>
        </view>
      </view>
    </view>
  </view>
</template>

当获取标准化的滚动数值后,我希望将该数值 同时与 ref="navbarRef" 和 :style="{ paddingTop: paddingTop + 'px' }" 进行关联!

但正如上面展示的那样,与子组件 ref="navbarRef"的关联目前还不是响应式的。

主要问题:

  1. 子组件暴露的 navbarHeight 不是响应式的 - 您使用了 navbarHeight.value 直接暴露值,而不是响应式引用
  2. 父组件没有将滚动值传递给子组件 - 需要添加 props 或方法调用

因此,需要修改子组件:

vue
<script setup lang="ts">
import { computed, ref } from 'vue'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()

// 计算导航栏总高度 - 使用 computed 保持响应式
const navbarHeight = computed(() => {
  const topInset = safeAreaInsets?.top || 0
  const contentHeight = 120 // 导航栏内容高度
  return topInset + contentHeight
})

// 添加响应式的滚动状态
const scrollState = ref(0)

// 接收滚动值的方法
const updateScrollState = (scrollValue: number) => {
  scrollState.value = scrollValue
  console.log('📊 导航栏收到滚动值:', scrollValue)
  // 在这里可以根据滚动值实现导航栏的样式变化
  // 例如:背景透明度、标题显示等
}

// 暴露给父组件
defineExpose({
  navbarHeight,
  updateScrollState
})

import { useWeatherStore } from '@/stores'
const weatherStore = useWeatherStore()
</script>

<template>
  <view
    class="navbar-container"
    :style="{ 
      height: navbarHeight + 'px',
      // 根据滚动值添加动态样式
      opacity: 1 - scrollState / 100,
      background: scrollState > 50 ? 'rgba(255,255,255,0.9)' : 'transparent'
    }"
  >
    <view
      class="navbar"
      :style="{ paddingTop: safeAreaInsets?.top + 'px' }"
    >
      <!-- 原有内容保持不变 -->
      <view class="logo">
        <image
          class="logo-image"
          src="@/static/images/yun-logo.png"
        ></image>
        <text class="logo-image-text">云峰农服</text>
      </view>

      <view class="navbar-bottom">
        <view class="tianqi">
          <text class="tianqi-temp">{{ weatherStore.weatherData?.tem }}°</text>
          <text class="tianqi-desc">晴</text>
          <image
            class="tianqi-icon"
            src="@/static/icon/天气-晴.svg"
          ></image>
        </view>
        <view class="location">
          <text class="iconfont location-icon">&#xe790;</text>
          <text class="info-text">牡丹区</text>
        </view>
        <view class="shidu">
          <text class="iconfont shidu-icon">&#xe682;</text>
          <text class="info-text">65%</text>
        </view>
        <view class="air">
          <text class="iconfont air-icon">&#xe60e;</text>
          <text class="info-text air-text">良好</text>
        </view>
      </view>
    </view>
  </view>
</template>
vue
<template>
  <view class="viewport">
    <CustomNavbar ref="navbarRef"></CustomNavbar>
    <view
      class="page-content"
      :style="{ 
        paddingTop: dynamicPaddingTop + 'px'
      }"
    >
      <indexVideo></indexVideo>
      <text class="main-list">实时画面(4)</text>
      <videoList></videoList>
      <text class="main-list">异常信息(2)</text>
      <infoList></infoList>
      <WeatherStation></WeatherStation>
    </view>
  </view>
</template>

<script setup lang="ts">
import { onPageScroll } from '@dcloudio/uni-app'
import { onMounted, ref, computed } from 'vue'
import CustomNavbar from './components/CustomNavbar.vue'
import indexVideo from './components/indexVideo.vue'
import infoList from './components/infoList.vue'
import videoList from './components/videoList.vue'
import WeatherStation from './components/weatherStation.vue'

const navbarRef = ref()
const normalizedScrollTop = ref(0)

// 动态计算paddingTop,与导航栏高度同步
const dynamicPaddingTop = computed(() => {
  if (!navbarRef.value?.navbarHeight) return 0
  return navbarRef.value.navbarHeight
})

// 处理页面滚动
onPageScroll(e => {
  const scrollTopRpx = e.scrollTop / 2
  normalizedScrollTop.value = Math.min(100, Math.round(scrollTopRpx))
  
  console.log('🎯 标准化滚动值:', normalizedScrollTop.value)
  
  // 将滚动值传递给子组件
  if (navbarRef.value?.updateScrollState) {
    navbarRef.value.updateScrollState(normalizedScrollTop.value)
  }
})

onMounted(() => {
  // 等待下一帧确保组件已渲染
  setTimeout(() => {
    if (navbarRef.value?.navbarHeight) {
      console.log('📏 初始导航栏高度:', navbarRef.value.navbarHeight)
    }
  }, 100)
})
</script>

<style scoped lang="scss">
.viewport {
  height: 100%;
  display: flex;
  background-color: #f5f7fa;
  flex-direction: column;
  overflow-x: hidden;

  .page-content {
    flex: 1;
    transition: padding-top 0.3s ease-out; // 添加过渡效果
  }

  .main-list {
    font-size: 30rpx;
    color: #333;
    font-weight: 600;
    display: flex;
    padding: 0 30rpx;
    margin: 20rpx 0;
  }
}
</style>

关键改进点:

  1. 子组件响应式暴露:使用 navbarHeight 而不是 navbarHeight.value
  2. 添加滚动状态:在子组件中创建 scrollState 来接收和处理滚动值
  3. 双向通信:父组件通过 updateScrollState 方法将滚动值传递给子组件
  4. 动态样式:在子组件和父组件中都根据滚动值应用动态样式
  5. 延迟获取高度:使用 setTimeout 确保在组件渲染完成后获取高度

这样就能实现我们想要的:滚动数值同时与 navbarRefpaddingTop 样式进行关联,并且是响应式的。

接下来,就是对一些细节进行调试,当然也包括动画样式、以及,分别为导航栏组件设置初始和缩放后的高度!

📝 总结

computed 保持响应式的条件是:

  • 依赖的数据本身是响应式的(来自 data、props、vuex 等)
  • 使用正确的数据更新方式
  • 不包含异步操作或副作用

📊 参数配置分析

点击查看详情
js
const sensitivityConfig = {
  triggerThreshold: 94,     // 很低的值,非常灵敏!
  heightRange: {
    expanded: 120,          // 展开高度
    collapsed: 46,          // 收起高度
  },
  transitionCurve: 0.6,     // 很小的指数,初始响应极快
}

内容区域高度差: 120 - 46 = 74px

🧮 详细计算过程

假设安全区域顶部插入为 44px(iPhone 标准)

js
const topInset = 44

baseNavbarHeight = 44 + 120 = 164px  // 完全展开
minNavbarHeight = 44 + 46 = 90px     // 完全收起
总高度变化范围:164px → 90px(变化74px)

计算几个关键点的值:

  1. 滚动值 = 20px(轻微滚动)
js
progress = Math.min(20 / 94, 1) = 0.213
sensitiveProgress = Math.pow(0.213, 0.6) ≈ 0.453
height = 90 + (164 - 90) × (1 - 0.453)
       = 90 + 74 × 0.547 ≈ 130.5px

效果: 轻微滚动20px,高度从164px → 130.5px,下降33.5px!

  1. 滚动值 = 47px(一半阈值)
js
progress = Math.min(47 / 94, 1) = 0.5
sensitiveProgress = Math.pow(0.5, 0.6) ≈ 0.660
height = 90 + 74 × (1 - 0.660)
       = 90 + 74 × 0.340 ≈ 115.2px

效果: 滚动一半距离,高度已下降48.8px!

  1. 滚动值 = 94px(达到阈值)
js
progress = Math.min(94 / 94, 1) = 1
sensitiveProgress = Math.pow(1, 0.6) = 1
height = 90 + 74 × (1 - 1) = 90px

效果: 完全收缩到最小高度

📈 完整响应曲线

滚动值进度灵敏进度导航栏高度高度变化
0px00164px起始状态
10px0.1060.25190+74×0.749≈145.4px↘18.6px
20px0.2130.45390+74×0.547≈130.5px↘33.5px
30px0.3190.56390+74×0.437≈122.3px↘41.7px
47px0.50.66090+74×0.340≈115.2px↘48.8px
70px0.7450.81890+74×0.182≈103.5px↘60.5px
94px1190px↘74px

🎯 灵敏度设计特点

超灵敏响应:

  • 阈值仅94px:滚动不到一屏就完成全部动画
  • 初始响应极快:前20px滚动就完成45%的高度变化
  • 指数曲线0.6:让前段变化更剧烈,后段平缓

用户体验:

  • 即时反馈:用户轻轻滚动就能看到明显效果
  • 快速完成:不需要大量滚动就能看到完整动画
  • 视觉舒适:指数曲线让动画有"缓入"效果

总结

你的配置创造了极其灵敏的响应式导航栏

  • 前10px滚动:完成25%的高度变化
  • 前20px滚动:完成45%的高度变化
  • 94px内完成:全部收缩动画

这样的设计适合追求即时视觉反馈的应用场景!用户轻轻一动就能获得强烈的交互反馈。🚀

关于-triggerThreshold

triggerThreshold滚动阈值,它决定了导航栏收缩动画的"完成距离"。

🎯 简单理解

triggerThreshold = 94 意味着:

  • 当用户滚动到 94px 时,导航栏完全收缩到最小高度
  • 滚动 0-94px 的过程,导航栏高度从最大平滑过渡到最小
  • 超过 94px 后,导航栏保持最小高度不变

📊 可视化理解

滚动距离:   0px ──── 47px ──── 94px ──── 188px ──── ∞
导航栏高度: 164px → 115px →  90px  →   90px   →  90px
          完全展开    半收缩   完全收起   保持收起   保持收起

🔧 技术原理

js
const progress = Math.min(scrollState.value / sensitivityConfig.triggerThreshold, 1)
// 当 scrollState.value = 94 时:
// progress = Math.min(94 / 94, 1) = 1 → 动画完成100%

// 当 scrollState.value = 47 时:
// progress = Math.min(47 / 94, 1) = 0.5 → 动画完成50%

// 当 scrollState.value = 188 时:
// progress = Math.min(188 / 94, 1) = Math.min(2, 1) = 1 → 动画完成100%

🎚️ 阈值的影响

小阈值(如 94):

js
triggerThreshold: 94 // 高灵敏度
  • 动画完成快:滚动94px就完成全部收缩
  • 响应灵敏:轻微滚动就有明显效果
  • 适合:追求即时反馈的场景

大阈值(如 200):

js
triggerThreshold: 200 // 低灵敏度
  • 动画完成慢:需要滚动200px才完全收缩
  • 响应平缓:需要更多滚动才能看到变化
  • 适合:追求平滑过渡的场景

💡 实际意义

在你的配置中:

  • 用户滚动不到 94px(约1/4屏),导航栏就完成从展开到收起的全过程
  • 这创造了极快的视觉反馈,用户轻轻一滑就能看到完整动画
  • 超过 94px 后,导航栏保持最小高度,不会无限收缩

所以 triggerThreshold 本质上是控制动画响应速度的"灵敏度开关"

关于scrollState

除此以外,页面中还多处使用了响应式数值scrollState , 这个值的来源为父组件的滚动数值传递!

vue
<!-- logo文字 -->
<view
  class="logo"
  :style="{
    transform: `translateY(-${Math.min(scrollState * 0.4, 10)}px)`,
  }"
>
  <image
    class="logo-image"
    src="@/static/images/yun-logo.png"
  ></image>
  <text class="logo-image-text">云峰农服</text>
  <!-- <text class="logo-text">快捷 · 易用 · 高效</text> -->
</view>

<!-- navBar-bottom -->
<view
  class="navbar-bottom"
  :style="{
    transform: `translateY(-${scrollState * 0.8}px)`,
    opacity: 1 - scrollState / 30,
  }"
>
  <!-- 温度向右移动,幅度大 -->
  <view
    class="tianqi"
    :style="{
      transform: `scale(${1 - scrollState / 80}) translateX(${scrollState * 3.5}px)`,
      opacity: 1 - scrollState / 25,
    }"
  >
    <text class="tianqi-temp">{{ weatherStore.weatherData?.tem }}°</text>
    <text class="tianqi-desc">晴</text>
    <image
      class="tianqi-icon"
      src="@/static/icon/天气-晴.svg"
    ></image>
  </view>

  <!-- 位置信息向右移动,幅度小 -->
  <view
    class="location"
    :style="{
      transform: `scale(${1 - scrollState / 100}) translateX(${scrollState * 1.8}px)`,
      opacity: 1 - scrollState / 35,
    }"
  >
    <text class="iconfont location-icon">&#xe790;</text>
    <text class="info-text">牡丹区</text>
  </view>

  <!-- 湿度向左移动,幅度小 -->
  <view
    class="shidu"
    :style="{
      transform: `scale(${1 - scrollState / 100}) translateX(${-scrollState * 1.8}px)`,
      opacity: 1 - scrollState / 35,
    }"
  >
    <text class="iconfont shidu-icon">&#xe682;</text>
    <text class="info-text">65%</text>
  </view>

  <!-- 空气质量向左移动,幅度大 -->
  <view
    class="air"
    :style="{
      transform: `scale(${1 - scrollState / 80}) translateX(${-scrollState * 3.5}px)`,
      opacity: 1 - scrollState / 30,
    }"
  >
    <text class="iconfont air-icon">&#xe60e;</text>
    <text class="info-text air-text">良好</text>
  </view>
</view>

对于不复杂的位移,可以直接在行内样式中进行加工:

  1. 缩放部分:scale(${1 - scrollState / 80})
// scrollState = 0 时:
scale(1 - 0/80) = scale(1)    // 原始大小

// scrollState = 40 时:
scale(1 - 40/80) = scale(0.5) // 缩小到50%

// scrollState = 80 时:
scale(1 - 80/80) = scale(0)   // 缩小到0(消失!)

// scrollState = 100 时:
scale(1 - 100/80) = scale(-0.25) // 负值,可能产生反向效果

缩放特点:

  • 分母80:控制缩放速度,80px滚动就缩放到0
  • 线性缩小:每滚动1px,缩小 1/80 ≈ 1.25%
  1. 平移部分:translateX(${scrollState * 3.5}px)
// scrollState = 0 时:
translateX(0 * 3.5) = translateX(0px)    // 原位

// scrollState = 40 时:
translateX(40 * 3.5) = translateX(140px) // 向右140px

// scrollState = 100 时:
translateX(100 * 3.5) = translateX(350px) // 向右350px

平移特点:

  • 系数3.5:每滚动1px,向右移动3.5px
  • 线性移动:移动距离与滚动值成正比
  1. 透明度:opacity: 1 - scrollState / 30
// scrollState = 0 时:
1 - 0/30 = 1.0      // 完全不透明

// scrollState = 10 时:
1 - 10/30 = 0.667   // 约67%透明度

// scrollState = 20 时:
1 - 20/30 = 0.333   // 约33%透明度

// scrollState = 30 时:
1 - 30/30 = 0       // 完全透明

// scrollState > 30 时:
1 - 40/30 = -0.333  // 负值,但opacity会钳制在0-1之间

透明度特点:

  • 分母30:控制淡出速度,30px滚动就完全透明
  • 快速消失:元素在相对较小的滚动距离内就完全淡出

Released under the MIT License.