tabCard 组件
组件定义:
<!-- 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><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><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>调用位置:
<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>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>其中的指示点、当然,对我来说也是知识点:
- UniApp 遵循微信小程序的事件规范,其中
detail用于传递组件自定义数据:
<!-- 组件触发事件时 -->
<swiper @change="change">
<!-- swiper 组件在变化时会触发事件,并通过 detail 传递当前页码 -->
</swiper>- 自定义事件数据
当组件触发事件时,相关的数据都放在 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) // 可能包含其他信息
}- 统一的数据通道
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>设计原因
- 一致性:所有组件的事件数据都通过
detail传递 - 扩展性:可以在
detail中自由添加数据而不污染事件对象 - 兼容性:与微信小程序、支付宝小程序等保持一致性
所以 e.detail 是 UniApp/小程序生态中的标准做法,不是 JavaScript 原生事件的对象结构。
原生uni-swiper-dot组件的使用
相比之下 - 这个效果更丝滑(需要依赖其他uni-组件),因此,必须使用 hbuild 工具整个安装这个插件包!
其实,参照这个纯手写一个圆点的,也不难,自己手写可以避免一些问题,例如(可能)指示点的定位问题,官方可能依据图片下边距进行相对定位,导致不够细致的用户体验,因为图片比例会有些差异,沃恩手写的话,可以写定,直接相对父盒子进行定位!
<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><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>.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当作普通的页面标签来写了:
<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导航栏的定位方式有它的特殊性,必须与其他页面结构分开(当然,包裹父容器其实并非必须的):
<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>.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%);
}
}
}
}脱离文档流的表现:
.navbar {
position: fixed; /* ✅ 脱离文档流 */
top: 0;
left: 0;
right: 0;
}效果:
- 不占用原空间:后面的元素会向上移动填补位置
- 独立层级:创建新的堆叠上下文
- 相对于视口:不受父元素影响
- 不随页面滚动:固定在屏幕指定位置
WARNING
因为导航栏标签使用了固定定位,因此需要为后面的整体页面结构进行打包,然后在打包后的标签上添加动态属性:
<!-- 主要内容区域 -->
<view
class="content-wrapper"
:style="{ paddingTop: navbarHeight }"
>
<!-- 此处略去原有标签结构 -->
</view>动态导航栏
同样的、首先还是构造一个处理页面滚动的逻辑:
// 处理页面滚动
const scrollTop = ref(0)
// 在页面中直接使用onPageScroll生命周期
onPageScroll(e => {
scrollTop.value = e.scrollTop
console.log('距离顶部:', scrollTop.value, 'px /', scrollTop.value / 2 + 'rpx')
})
// 当前,调试信息生效了,但是还需要进一步完善,我们需要它返回100以内的整数
// 其余滚动的数值不需要返回,当数值大于100时,仍返回100!// 处理页面滚动
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
}
}// 处理页面滚动
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--]
})关键点说明:
Math.round()- 四舍五入取整数Math.min(100, ...)- 限制最大值不超过100Math.max(0, ...)- 确保最小值不小于0(虽然滚动值不会为负)- 标准化范围 - 始终返回0-100的整数
然后,调用位置:
<!-- 固定的导航栏 -->
<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> // 固定导航栏样式
.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>上面的行内样式数值是写死的,需要优化:
<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>.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%);
}
}优化后的行内样式解读:
- 最内层计算:
250 * (normalizedScrollTop.value / 100)
normalizedScrollTop.value / 100:将0-100的滚动值转换为0-1的进度比例250 * 进度:计算基础移动距离,最大250rpx- 作用:根据滚动进度计算要移动的距离
- 中层保护:
Math.max(0, ...)
- 确保移动距离不小于0rpx
- 防止出现负值导致异常
- 作用:设置移动距离的最小值
- 外层限制:
Math.min(121, ...)
- 确保移动距离不超过121rpx
- 即使滚动到100,最大也只移动121rpx
- 作用:设置移动距离的最大值
- 最终定位:
calc(50% - ...rpx)
50%:相对于父容器宽度的50%位置- ...rpx:从居中位置向左移动计算出的距离- 整体效果:元素从居中位置逐渐向左移动
数值变化过程:
| 滚动值 | 计算步骤 | 最终left值 |
|---|---|---|
| 0 | Math.min(121, Math.max(0, 250*0)) = 0 | calc(50% - 0rpx) |
| 25 | Math.min(121, Math.max(0, 250*0.25)) = 62.5 | calc(50% - 62.5rpx) |
| 50 | Math.min(121, Math.max(0, 250*0.5)) = 121 | calc(50% - 121rpx) |
| 100 | Math.min(121, Math.max(0, 250*1)) = 121 | calc(50% - 121rpx) |
视觉效果:
- 初始:元素完美居中
- 滚动中:元素向左平滑移动
- 滚动到50+:元素停在距中心121rpx的位置
自定义导航组件
之前,手写的自定义导航组件,完整的还原了设计稿,但是,页面滚动时看出来问题了 —— 所有内容,包括自定义导航组件在内,都会跟随页面滚动,且滚动内容在滚动时与顶部状态栏重叠!
需要对其进行定位改造,并记录定位方式:
<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"></text>
<text class="info-text">牡丹区</text>
</div>
<div class="shidu">
<text class="iconfont shidu-icon"></text>
<text class="info-text">65%</text>
</div>
<div class="air">
<text class="iconfont air-icon"></text>
<text class="info-text air-text">良好</text>
</div>
</view>
</view>
</template><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>改造后,在原有组件结构外层包裹一层父容器:
<template>
<view class="viewport">
<CustomNavbar ref="navbarRef"></CustomNavbar>
<view
class="page-content"
:style="{
paddingTop: dynamicPaddingTop + 'px',
}"
>
<!-- 此处略去其他标签结构 -->
</view>
</view>
</template><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组件遮挡!
自定义导航组件的动态实现
思路:通过调用页面构造滚动函数,输出标准化数值,然后充分、巧妙的利用该数值!
改造前:
<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><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"></text>
<text class="info-text">牡丹区</text>
</view>
<view class="shidu">
<text class="iconfont shidu-icon"></text>
<text class="info-text">65%</text>
</view>
<view class="air">
<text class="iconfont air-icon"></text>
<text class="info-text air-text">良好</text>
</view>
</view>
</view>
</view>
</template>当获取标准化的滚动数值后,我希望将该数值 同时与 ref="navbarRef" 和 :style="{ paddingTop: paddingTop + 'px' }" 进行关联!
但正如上面展示的那样,与子组件 ref="navbarRef"的关联目前还不是响应式的。
主要问题:
- 子组件暴露的 navbarHeight 不是响应式的 - 您使用了
navbarHeight.value直接暴露值,而不是响应式引用 - 父组件没有将滚动值传递给子组件 - 需要添加 props 或方法调用
因此,需要修改子组件:
<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"></text>
<text class="info-text">牡丹区</text>
</view>
<view class="shidu">
<text class="iconfont shidu-icon"></text>
<text class="info-text">65%</text>
</view>
<view class="air">
<text class="iconfont air-icon"></text>
<text class="info-text air-text">良好</text>
</view>
</view>
</view>
</view>
</template><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>关键改进点:
- 子组件响应式暴露:使用
navbarHeight而不是navbarHeight.value - 添加滚动状态:在子组件中创建
scrollState来接收和处理滚动值 - 双向通信:父组件通过
updateScrollState方法将滚动值传递给子组件 - 动态样式:在子组件和父组件中都根据滚动值应用动态样式
- 延迟获取高度:使用
setTimeout确保在组件渲染完成后获取高度
这样就能实现我们想要的:滚动数值同时与 navbarRef 和 paddingTop 样式进行关联,并且是响应式的。
接下来,就是对一些细节进行调试,当然也包括动画样式、以及,分别为导航栏组件设置初始和缩放后的高度!
📝 总结
computed 保持响应式的条件是:
- 依赖的数据本身是响应式的(来自 data、props、vuex 等)
- 使用正确的数据更新方式
- 不包含异步操作或副作用
📊 参数配置分析
点击查看详情
const sensitivityConfig = {
triggerThreshold: 94, // 很低的值,非常灵敏!
heightRange: {
expanded: 120, // 展开高度
collapsed: 46, // 收起高度
},
transitionCurve: 0.6, // 很小的指数,初始响应极快
}内容区域高度差: 120 - 46 = 74px
🧮 详细计算过程
假设安全区域顶部插入为 44px(iPhone 标准)
const topInset = 44
baseNavbarHeight = 44 + 120 = 164px // 完全展开
minNavbarHeight = 44 + 46 = 90px // 完全收起
总高度变化范围:164px → 90px(变化74px)计算几个关键点的值:
- 滚动值 = 20px(轻微滚动)
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!
- 滚动值 = 47px(一半阈值)
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!
- 滚动值 = 94px(达到阈值)
progress = Math.min(94 / 94, 1) = 1
sensitiveProgress = Math.pow(1, 0.6) = 1
height = 90 + 74 × (1 - 1) = 90px效果: 完全收缩到最小高度
📈 完整响应曲线
| 滚动值 | 进度 | 灵敏进度 | 导航栏高度 | 高度变化 |
|---|---|---|---|---|
| 0px | 0 | 0 | 164px | 起始状态 |
| 10px | 0.106 | 0.251 | 90+74×0.749≈145.4px | ↘18.6px |
| 20px | 0.213 | 0.453 | 90+74×0.547≈130.5px | ↘33.5px |
| 30px | 0.319 | 0.563 | 90+74×0.437≈122.3px | ↘41.7px |
| 47px | 0.5 | 0.660 | 90+74×0.340≈115.2px | ↘48.8px |
| 70px | 0.745 | 0.818 | 90+74×0.182≈103.5px | ↘60.5px |
| 94px | 1 | 1 | 90px | ↘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
完全展开 半收缩 完全收起 保持收起 保持收起🔧 技术原理
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):
triggerThreshold: 94 // 高灵敏度- 动画完成快:滚动94px就完成全部收缩
- 响应灵敏:轻微滚动就有明显效果
- 适合:追求即时反馈的场景
大阈值(如 200):
triggerThreshold: 200 // 低灵敏度- 动画完成慢:需要滚动200px才完全收缩
- 响应平缓:需要更多滚动才能看到变化
- 适合:追求平滑过渡的场景
💡 实际意义
在你的配置中:
- 用户滚动不到 94px(约1/4屏),导航栏就完成从展开到收起的全过程
- 这创造了极快的视觉反馈,用户轻轻一滑就能看到完整动画
- 超过 94px 后,导航栏保持最小高度,不会无限收缩
所以 triggerThreshold 本质上是控制动画响应速度的"灵敏度开关"!
关于scrollState
除此以外,页面中还多处使用了响应式数值scrollState , 这个值的来源为父组件的滚动数值传递!
<!-- 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"></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"></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"></text>
<text class="info-text air-text">良好</text>
</view>
</view>对于不复杂的位移,可以直接在行内样式中进行加工:
- 缩放部分:
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%
- 平移部分:
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
- 线性移动:移动距离与滚动值成正比
- 透明度:
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滚动就完全透明
- 快速消失:元素在相对较小的滚动距离内就完全淡出