vue实现scroll-view上下滑动右侧左右滑动

vue实现scroll-view上下滑动右侧左右滑动 右侧左右滑动时不会抖动基本操作没什么影响

javascript 复制代码
<template>
	<view class="page-container">
		<input type="text" v-model="rowCount" style="margin-top: 100rpx !important;"/>
		<view class="main-scroll-box">

			<!-- ============ 顶部固定表头:view + transform(与主体同一驱动源) ============ -->
			<view class="fixed-row fixed-row--top">
				<view class="row-left">名称</view>
				<view class="row-right">
					<view :class="['track', { 'track--bounce': bouncing }]" :style="trackStyle">
						<view class="row">
							<view class="cell" v-for="(item, i) in contentList" :key="'th' + i">{{ item }}</view>
						</view>
					</view>
				</view>
			</view>

			<!-- ============ 中间主体:外层纵向 scroll-view + 内层横向手势区 ============ -->
			<scroll-view class="main-scroll" scroll-y :show-scrollbar="false">
				<view class="content-wrap">
					<!-- 左侧固定列:跟随外层纵向滚动 -->
					<view class="col-left">
						<view class="menu-item" v-for="(item, i) in menuList" :key="'m' + i">{{ item }}</view>
					</view>

					<!--
						横向区域:普通 view,不是 scroll-view
						手势由 onTouchStart/Move/End 接管,位移走 transform
						三个区域共用同一个 offsetX → 同一帧渲染 → 不可能错位
					-->
					<view class="col-right" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"
						@touchcancel="onTouchEnd">
						<view class="track track--body" :class="{ 'track--bounce': bouncing }" :style="trackStyle">
							<view class="row" v-for="(r, ri) in rowList" :key="'r' + ri">
								<view class="cell" v-for="(c, ci) in contentList" :key="ri + '-' + ci">{{ c }}</view>
							</view>
						</view>
					</view>
				</view>
			</scroll-view>

			<!-- ============ 底部固定表尾:view + transform ============ -->
			<view class="fixed-row fixed-row--bottom">
				<view class="row-left">名称</view>
				<view class="row-right">
					<view :class="['track', { 'track--bounce': bouncing }]" :style="trackStyle">
						<view class="row">
							<view class="cell" v-for="(item, i) in contentList" :key="'tf' + i">{{ item }}</view>
						</view>
					</view>
				</view>
			</view>

		</view>

		<!-- 调试用:打印 maxScroll(上线可删) -->
		<!-- <view class="debug" v-if="debugInfo">{{ debugInfo }}</view> -->
	</view>
</template>

<script>
	/**
	 * 终极方案:三区域全 view + transform,手势与惯性自绘
	 *
	 * 为什么这样彻底不抖:
	 *   三个区域不再各自持有滚动状态,而是渲染同一个变量 offsetX。
	 *   没有 scroll 事件、没有受控属性回写、没有跨线程事件回传延迟,
	 *   同一次 Vue 更新里三处 transform 一起变 → 帧级别一致,不存在错位的可能。
	 *
	 * 为什么放弃 scroll-view 做横向:
	 *   App 端 scroll-view 滚动发生在原生层,scroll 事件跨线程回传天然滞后,
	 *   跟随端永远慢一拍,"追赶"的过程就是肉眼看到的抖动。自绘则无此延迟。
	 *
	 * 纵向仍用外层 scroll-view:纵向是原生滚动,性能好、惯性自然,没必要自己造。
	 */

	const FRICTION = 0.97 // 惯性衰减:每 16ms 速度乘以该值,越小停得越快
	const MIN_VELOCITY = 0.02 // px/ms,低于此值停止惯性
	const OVER_DRAG = 0.35 // 越界拖拽阻尼,手指越界时位移打折
	const BOUNCE_MS = 260 // 越界回弹时长,需与样式中的 transition 保持一致
	const AXIS_THRESHOLD = 4 // px,判定滑动方向所需的最小位移

	/* rAF 安全封装:部分 App WebView 不存在该 API */
	const HAS_RAF = typeof requestAnimationFrame === 'function'
	const HAS_CAF = typeof cancelAnimationFrame === 'function'
	const HOST = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : null
	const raf = HAS_RAF ? requestAnimationFrame.bind(HOST) : cb => setTimeout(cb, 16)
	const caf = HAS_CAF ? cancelAnimationFrame.bind(HOST) : id => clearTimeout(id)

	/* touch 坐标兼容:优先 clientX,老基础库退化为 pageX */
	function pointX(e) {
		const t = e.touches && e.touches[0]
		if (!t) return null
		return t.clientX != null ? t.clientX : t.pageX
	}

	function pointY(e) {
		const t = e.touches && e.touches[0]
		if (!t) return null
		return t.clientY != null ? t.clientY : t.pageY
	}

	export default {
		data() {
			return {
				rowCount: 100, // 主体行数
				contentList: Array.from({
					length: 10
				}, (_, i) => `内容${i + 1}`),

				offsetX: 0, // 唯一驱动源:三条轨道共用
				bouncing: false, // 回弹中 → 开启 transition
				debugInfo: ''
			}
		},

		computed: {
			menuList() {
				return Array.from({
					length: this.rowCount
				}, (_, i) => `分类${i + 1}`)
			},
			rowList() {
				return Array.from({
					length: this.rowCount
				}, (_, i) => i)
			},
			trackStyle() {
				const t = `translate3d(${-this.offsetX}px, 0, 0)`
				return {
					transform: t,
					'-webkit-transform': t
				}
			}
		},

		created() {
			// 非响应式字段:高频更新不让 Vue 追踪
			this._raw = 0 // 真实位移量(含小数)
			this._maxScroll = 0 // 可滚动最大距离
			this._axis = '' // 本次手势方向:'' | 'x' | 'y'
			this._startX = 0
			this._startY = 0
			this._startRaw = 0
			this._lastX = 0
			this._lastT = 0
			this._velocity = 0 // px/ms
			this._raf = 0
			this._bounceTimer = null
			this._tickFn = null
		},

		mounted() {
			this.measure()
		},

		onReady() {
			this.$nextTick(() => this.measure())
		},

		beforeDestroy() {
			this.stopInertia()
			if (this._bounceTimer) clearTimeout(this._bounceTimer)
		},

		methods: {
			/* ---------------- 手势接管 ---------------- */

			onTouchStart(e) {
				this.stopInertia()
				this.clearBounce()

				const x = pointX(e)
				const y = pointY(e)
				if (x == null) return

				this._axis = ''
				this._startX = x
				this._startY = y
				this._lastX = x
				this._lastT = Date.now()
				this._velocity = 0
				this._startRaw = this._raw
			},

			onTouchMove(e) {
				const x = pointX(e)
				const y = pointY(e)
				if (x == null) return

				// 方向锁:首次移动判定方向,锁定后本次手势不再改变
				if (!this._axis) {
					const dx = Math.abs(x - this._startX)
					const dy = Math.abs(y - this._startY)
					if (dx < AXIS_THRESHOLD && dy < AXIS_THRESHOLD) return
					this._axis = dx > dy ? 'x' : 'y'
				}
				// 判定为纵向 → 完全放手,交给外层 scroll-view
				if (this._axis !== 'x') return

				const now = Date.now()
				const dt = Math.max(now - this._lastT, 1)
				// 手指左移 → 内容左移 → offset 增大
				let delta = this._lastX - x

				// 越界阻尼:超出可滚动范围时位移打折,手感更自然
				const next = this._startRaw + (this._startX - x)
				if (next < 0 || next > this._maxScroll) {
					delta *= OVER_DRAG
				}

				this._raw += delta
				this._raw = Math.max(-60, Math.min(this._maxScroll + 60, this._raw)) // 允许少量越界

				// 速度平滑:避免单点抖动导致松手时惯性突兀
				const v = (this._lastX - x) / dt
				this._velocity = this._velocity * 0.3 + v * 0.7

				this._lastX = x
				this._lastT = now
				this.offsetX = Math.round(this._raw)
			},

			onTouchEnd() {
				if (this._axis !== 'x') {
					this._axis = ''
					return
				}
				this._axis = ''

				// 越界 → 回弹
				if (this._raw < 0 || this._raw > this._maxScroll) {
					this.bounceTo(this._raw < 0 ? 0 : this._maxScroll)
					return
				}

				// 速度太小 → 不启动惯性
				if (Math.abs(this._velocity) < MIN_VELOCITY) return

				this.startInertia()
			},

			/* ---------------- 惯性动画 ---------------- */

			startInertia() {
				this.stopInertia()
				if (!this._tickFn) this._tickFn = this.tick.bind(this)
				this._lastT = Date.now()
				this._raf = raf(this._tickFn)
			},

			tick() {
				this._raf = 0
				const now = Date.now()
				const dt = Math.min(Math.max(now - this._lastT, 1), 32)
				this._lastT = now

				this._velocity *= Math.pow(FRICTION, dt / 16)
				this._raw += this._velocity * dt

				// 撞边界 → 回弹并结束
				if (this._raw < 0 || this._raw > this._maxScroll) {
					this.stopInertia()
					this.bounceTo(this._raw < 0 ? 0 : this._maxScroll)
					return
				}

				if (Math.abs(this._velocity) < MIN_VELOCITY) {
					this.stopInertia()
					return
				}

				this.offsetX = Math.round(this._raw)
				this._raf = raf(this._tickFn)
			},

			stopInertia() {
				if (this._raf) {
					caf(this._raf)
					this._raf = 0
				}
				this._velocity = 0
			},

			/* ---------------- 越界回弹 ---------------- */

			bounceTo(target) {
				this.clearBounce()
				this._raw = target
				this.bouncing = true // 开启 transition,由 CSS 完成缓动
				this.offsetX = Math.round(target)
				this._bounceTimer = setTimeout(() => {
					this.bouncing = false
				}, BOUNCE_MS)
			},

			clearBounce() {
				if (this._bounceTimer) {
					clearTimeout(this._bounceTimer)
					this._bounceTimer = null
				}
				this.bouncing = false
			},

			/* ---------------- 测量与工具 ---------------- */

			/**
			 * 测量可滚动最大距离:maxScroll = 内容宽 - 容器宽
			 * 内容或列宽变化后需重新调用
			 */
			measure() {
				const query = uni.createSelectorQuery().in(this)
				let containerW = 0
				let contentW = 0

				query.select('.col-right').boundingClientRect(res => {
					containerW = res ? res.width : 0
				})
				query.select('.track--body').boundingClientRect(res => {
					contentW = res ? res.width : 0
				})
				query.exec(() => {
					this._maxScroll = Math.max(0, Math.round(contentW - containerW))
					this.debugInfo = `max=${this._maxScroll} content=${contentW} box=${containerW}`
					console.log('[scroll-sync]', this.debugInfo)
				})
			},

			// 程序化回到最左
			resetScroll() {
				this.stopInertia()
				this.bounceTo(0)
			}
		}
	}
</script>

<style scoped lang="scss">
	/* ========== 布局基线:列宽必须三处完全一致,否则"同步但错位" ========== */
	$cell-w: 160rpx; // 单元格宽度,改这里即可
	$body-row-h: 80rpx; // 主体行高
	$fixed-row-h: 70rpx; // 表头/表尾行高
	$left-w: 240rpx; // 左侧固定列宽度

	.page-container {
		width: 100%;
		height: 100vh;
		overflow: hidden;
	}

	.main-scroll-box {
		position: relative;
		width: 95%;
		height: 1200rpx;
		margin: 50rpx auto 0;
		padding: $fixed-row-h 0; // 给上下固定行留出位置
		overflow: hidden;
		border: 1rpx solid #ddd;
		box-sizing: border-box;
		// margin-top: 100rpx !important;
	}

	/* ---------- 上下固定行(表头 / 表尾) ---------- */
	.fixed-row {
		position: absolute;
		left: 0;
		width: 100%;
		height: $fixed-row-h;
		display: flex;
		align-items: flex-start;
		background-color: #ddd;
		z-index: 2;
		overflow: hidden;

		&--top {
			top: 0;
		}

		&--bottom {
			bottom: 0;
		}

		.row-left {
			flex: 0 0 $left-w;
			width: $left-w;
			height: $fixed-row-h;
			line-height: $fixed-row-h;
			font-size: 28rpx;
			padding-left: 20rpx;
			box-sizing: border-box;
		}

		.row-right {
			position: relative;
			flex: 1;
			height: $fixed-row-h;
			overflow: hidden;
		}
	}

	/* ---------- 中间主体 ---------- */
	.main-scroll {
		width: 100%;
		height: 100%;
		overflow: hidden;
	}

	.content-wrap {
		display: flex;
		align-items: flex-start; // 两列按内容高度撑开
		width: 100%;
	}

	.col-left {
		flex: 0 0 $left-w;
		width: $left-w;

		.menu-item {
			height: $body-row-h;
			line-height: $body-row-h;
			font-size: 28rpx;
			padding-left: 20rpx;
			box-sizing: border-box;
			border-bottom: 1rpx solid #f2f2f2;
		}
	}

	.col-right {
		flex: 1;
		height: auto;
		overflow: hidden;
		/* 避免长按选中干扰手势 */
		-webkit-user-select: none;
		user-select: none;
	}

	/* ---------- 横向内容轨道 ---------- */
	.track {
		display: inline-flex;
		flex-direction: column;
		align-items: stretch;
		will-change: transform;
		backface-visibility: hidden;
	}

	/* 仅回弹时开启过渡,拖拽与惯性期间必须关闭,否则会滞后 */
	.track--bounce {
		transition: transform 260ms cubic-bezier(0.25, 0.8, 0.25, 1);
		-webkit-transition: -webkit-transform 260ms cubic-bezier(0.25, 0.8, 0.25, 1);
	}

	.row {
		display: flex;
		flex-direction: row;
		flex-wrap: nowrap; // 绝不允许换行,否则内容宽度计算错误
		height: $body-row-h;
		line-height: $body-row-h;
	}

	.fixed-row .row {
		height: $fixed-row-h;
		line-height: $fixed-row-h;
	}

	.cell {
		flex: 0 0 $cell-w; // 不压缩、不放大,保证内容宽 = 列数 × 列宽
		width: $cell-w;
		height: 100%;
		line-height: inherit;
		font-size: 28rpx;
		padding-left: 12rpx;
		box-sizing: border-box;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
		border-bottom: 1rpx solid #f2f2f2;
		border-left: 1rpx solid #eee;
	}

	::-webkit-scrollbar {
		width: 0;
		height: 0;
		display: none;
	}

	.debug {
		padding: 10rpx;
		font-size: 22rpx;
		color: #999;
	}
</style>
相关推荐
志尊宝1 小时前
Vue3 零基础每日笔记(011):生命周期钩子——在正确的时间做正确的事
javascript·vue.js·笔记
苏三的开发日记2 小时前
npm EPERM:无法写入 `.npmrc` 的排查与解决
前端
YWL2 小时前
OpenLayers地图分享:URL参数同步+分享链接生成,让用户一键分享地图视角
前端·javascript·vue·openlayers
枫叶梨花2 小时前
Electrobun透明窗口在Windows上的输入穿透排查
前端·electron·bun
卤蛋fg62 小时前
vue 甘特图组件 vxe-gantt 依赖连接线可视化拖拽创建与双击删除
vue.js
梦醒沉醉2 小时前
5、表达式与运算符
javascript
云上工程笔记3 小时前
AstraFlow + React/Vite:如何用自然语言开发并部署一个 AI 咖啡网站原型
前端·人工智能·react.js
Q一件事4 小时前
ArcGIS中TypeError: can‘t multiply sequence by non-int of type ‘str‘错误
前端·javascript·arcgis
美狐美颜SDK开放平台4 小时前
从视频处理到实时渲染:直播APP中视频美颜SDK的关键技术解析
前端·人工智能·架构·实时互动·音视频·视频美颜sdk