refactor(article): 替换CSS动画跑马灯为JS驱动实现

重构文章组件的跑马灯滚动逻辑,移除CSS keyframes动画实现,改用JS控制transform实现滚动,新增支持用户手动拖拽交互,统一连续/步进两种滚动模式的触摸控制逻辑,优化跨端表现和交互体验。
This commit is contained in:
2026-06-29 09:38:10 +08:00
parent 16cfe039ba
commit 1150dad0b5

View File

@@ -3,16 +3,15 @@
<view class="article-wrap" :style="warpWrapStyle">
<!-- 注: :class 用内联数组,与 ns-newform 对齐;computed listClass/itemClass 已移除(避免数组 toString 用逗号 join 的问题) -->
<view :class="['list-wrap', value.style || 'style-1']">
<!-- 自动垂直滚动(CSS animation 跑马灯实现,默认开启)
设计:loopedList 把 list 复制成 A+A 两份;外层容器固定高度(窗口)overflow:hidden,
内层容器用 @keyframes translateY 从 0 → -第一份高度,动画循环无缝接续。
优势:① 无 JS setInterval 驱动 scrollTop,避免 uni-app scroll-view 在 H5/MP 下单位不一致
导致"滚到底后跳变"问题;② 浏览器原生动画,60fps 平滑,跨端一致;
③ 触摸(touchstart)时 animation-play-state=paused,touchend 2s 后恢复。 -->
<!-- 自动垂直滚动(JS 驱动 transform 跑马灯,默认开启,同时支持用户手动拖拽)
设计:loopedList 把 list 复制成 A+A 两份;外层容器固定高度(窗口)overflow:hidden,
内层容器用 JS 控制 transform:translateY,从 0 → -第一份高度后无缝重置。
支持:① 自动连续/步进滚动;② 用户触摸并拖拽可手动滚动;③ 松手 2s 后从当前位置恢复自动滚动。 -->
<view v-if="list.length > 0 && useScroll"
:class="['auto-scroll-list', { 'is-marquee-paused': marqueePaused }]"
class="auto-scroll-list"
:style="autoScrollListStyle"
@touchstart="onMarqueeTouchStart"
@touchmove.stop.prevent="onMarqueeTouchMove"
@touchend="onMarqueeTouchEnd">
<view class="marquee-track" :style="marqueeTrackStyle">
<view v-for="(item, index) in loopedList" :key="index"
@@ -127,11 +126,18 @@ export default {
headHeight: '160rpx',
textRows: 2
},
// ========== 跑马灯状态(CSS animation 驱动,替代 scroll-view + scrollTop)==========
// marqueePaused: true 时给外层加 is-marquee-paused class → animation-play-state: paused
// marqueeResumeTimer: touchend 后延迟恢复
marqueePaused: false,
// ========== 跑马灯状态(JS 驱动 transform,同时支持用户手动拖拽)==========
// marqueeResumeTimer: touchend 后延迟恢复自动滚动
marqueeResumeTimer: null,
// 用户拖拽状态
isUserDragging: false,
dragStartY: 0,
dragStartTranslateY: 0,
// ========== 连续滚动模式字段(marqueeMode === 'continuous')==========
// 驱动 transform: translateY 在 A+A 双份内容的 track 上连续滚动
continuousTranslateY: 0,
continuousRAF: null,
continuousLastTime: 0,
// ========== 步进模式字段(stepPause=true 时启用)==========
// 驱动 transform: translateY 在 A+A 双份内容的 track 上做 item-by-item 滚动
// 注意: 自定义字段不以 _ 开头,避免 Vue 2 不代理到 this 上导致 "is not a function"
@@ -145,16 +151,9 @@ export default {
this.getList();
},
beforeDestroy() {
// 直接清理所有定时器,避免在某些场景(如 HMR)下调用以 _ 开头方法时出现
// 直接清理所有定时器/动画帧,避免在某些场景(如 HMR)下调用以 _ 开头方法时出现
// "is not a function" 的问题
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
if (this.marqueeResumeTimer) {
clearTimeout(this.marqueeResumeTimer);
this.marqueeResumeTimer = null;
}
this.stopAllMarquee();
},
watch: {
// 组件刷新监听
@@ -163,22 +162,29 @@ export default {
},
// list 加载完后,根据当前模式启动滚动
'list.length'(newLen) {
if (newLen > 1 && this.useScroll && this.marqueeMode === 'step') {
if (newLen > 1 && this.useScroll) {
var self = this;
this.$nextTick(function() {
self.startStepMarquee();
if (self.marqueeMode === 'step') {
self.startStepMarquee();
} else {
self.startContinuousMarquee(true);
}
});
}
},
// 模式切换(后端修改 stepPause 后):停止旧的步进定时器,按需重启动
// 模式切换(后端修改 stepPause 后):停止旧的动画,按需重启动
marqueeMode(newMode) {
this.stopStepMarquee();
if (newMode === 'step' && this.list && this.list.length > 1) {
var self = this;
this.$nextTick(function() {
this.stopAllMarquee();
if (!this.useScroll || !this.list || this.list.length <= 1) return;
var self = this;
this.$nextTick(function() {
if (newMode === 'step') {
self.startStepMarquee();
});
}
} else {
self.startContinuousMarquee(true);
}
});
}
},
computed: {
@@ -393,20 +399,19 @@ export default {
return d > 0 ? d : 500;
},
// 内层 .marquee-track 的 inline style
// 连贯模式:走 CSS @keyframes diy-article-marquee-up,inline 只指定 duration/delay
// 步进模式:禁用 CSS 动画,用 JS 控制 transform: translateY + transition 实现逐条滚动
// 统一用 JS 控制 transform: translateY + transition;不再使用 CSS @keyframes,
// 以便用户触摸拖拽时能即时改变滚动位置,并从当前位置恢复自动滚动。
marqueeTrackStyle() {
if (this.marqueeMode === 'step') {
return this.$util.objToStyle({
'animation': 'none',
'transform': 'translateY(-' + this._rpx(this.stepTranslateY) + ')',
'transition': this.isStepSnapping ? 'none' : ('transform ' + this.stepAnimDuration + 'ms ease-in-out')
});
var translateY = this.marqueeMode === 'step' ? this.stepTranslateY : this.continuousTranslateY;
// 步进自动滚动时启用 transition 产生动画;拖拽中/snapping 时禁用,保证即时响应/无跳变
var transition = 'none';
if (this.marqueeMode === 'step' && !this.isUserDragging && !this.isStepSnapping) {
transition = 'transform ' + this.stepAnimDuration + 'ms ease-in-out';
}
// 连贯模式:走 CSS @keyframes
return this.$util.objToStyle({
'animation-duration': this.marqueeDuration + 's',
'animation-delay': '-' + this.marqueeDelay + 's'
'animation': 'none',
'transform': 'translateY(-' + this._rpx(translateY) + ')',
'transition': transition
});
},
// list 中最高 item 的自然高度(动态) — swiperHeight + warpWrapStyle CSS 变量都用它
@@ -652,38 +657,136 @@ export default {
cover_img: ''
});
},
// ========== 跑马灯触摸控制(连贯模式:CSS animation-play-state; 步进模式:清除/恢复 JS 定时器)==========
// 用户触摸 → 暂停滚动
onMarqueeTouchStart() {
this.marqueePaused = true;
if (this.marqueeMode === 'step') {
this.stopStepMarquee();
// ========== 跑马灯触摸控制(连续/步进模式统一:暂停动画,允许用户拖拽手动滚动)==========
// 用户触摸 → 停止自动滚动,记录拖拽起始位置(先不标记为拖拽,避免点击被误识别)
onMarqueeTouchStart(e) {
this.stopAllMarquee();
this.isUserDragging = false;
this.dragStartY = (e.touches && e.touches[0]) ? e.touches[0].clientY : 0;
this.dragStartTranslateY = this.marqueeMode === 'step' ? this.stepTranslateY : this.continuousTranslateY;
},
// 用户拖拽 → 当移动距离超过阈值后标记为拖拽,并根据手指位移即时改变 translateY
onMarqueeTouchMove(e) {
var y = (e.touches && e.touches[0]) ? e.touches[0].clientY : 0;
var deltaY = y - this.dragStartY;
if (!this.isUserDragging) {
// 5px 阈值:只有真正滑动时才进入手动滚动,避免点击触发
if (Math.abs(deltaY) < 5) return;
this.isUserDragging = true;
}
var newTranslateY = this.dragStartTranslateY - deltaY;
var firstH = this.firstListHeight;
if (firstH > 0) {
// 限制在 [0, firstListHeight) 循环区间内
newTranslateY = ((newTranslateY % firstH) + firstH) % firstH;
}
if (this.marqueeMode === 'step') {
this.stepTranslateY = newTranslateY;
} else {
this.continuousTranslateY = newTranslateY;
}
},
// 用户松手 → 若只是点击则立即恢复,否则 2s 后从当前位置恢复自动滚动
onMarqueeTouchEnd() {
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
if (!this.isUserDragging) {
// 纯点击:立即恢复自动滚动,不影响跳转到详情
this.resumeAutoScroll();
return;
}
this.isUserDragging = false;
var self = this;
this.marqueeResumeTimer = setTimeout(function() {
self.resumeAutoScroll();
}, 2000);
},
// 从当前位置恢复自动滚动(由 touchend/点击触发)
resumeAutoScroll() {
if (!this.useScroll) return;
if (this.marqueeMode === 'step') {
this.resumeStepMarquee();
} else {
this.startContinuousMarquee(false);
}
},
// 停止所有自动滚动(连续+步进)及恢复定时器
stopAllMarquee() {
this.stopContinuousMarquee();
this.stopStepMarquee();
if (this.marqueeResumeTimer) {
clearTimeout(this.marqueeResumeTimer);
this.marqueeResumeTimer = null;
}
},
// 用户松手 → 2s 后继续滚动(动画会从暂停位置继续,不会跳变)
onMarqueeTouchEnd() {
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
var self = this;
this.marqueeResumeTimer = setTimeout(function() {
self.marqueePaused = false;
if (self.marqueeMode === 'step') {
// 步进模式:从当前 item 的 pause 阶段继续
self.stepTimer = setTimeout(function() {
self.stepAdvance();
}, self.pauseDuration);
}
}, 2000);
// ========== 连续滚动模式:JS 控制 translateY 平滑滚动 ==========
// 启动:根据 marqueeDelay 初始化偏移后开始逐帧滚动
// resetOffset=true 时从头计算初始偏移(首次启动/模式切换),否则从当前 continuousTranslateY 继续
startContinuousMarquee(resetOffset) {
this.stopContinuousMarquee();
if (!this.list || this.list.length <= 1 || this.isUserDragging) return;
var firstH = this.firstListHeight;
if (firstH > 0 && resetOffset) {
// 根据 marqueeDelay 计算初始偏移,与旧 CSS animation-delay 对齐
var initialOffset = (this.marqueeDelay / this.marqueeDuration) * firstH;
this.continuousTranslateY = initialOffset % firstH;
}
this.continuousLastTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
this.continuousRAF = this._requestAnimationFrame(this.continuousFrame);
},
stopContinuousMarquee() {
if (this.continuousRAF) {
this._cancelAnimationFrame(this.continuousRAF);
this.continuousRAF = null;
}
},
continuousFrame() {
if (!this.useScroll || this.isUserDragging || this.marqueeMode !== 'continuous') return;
var now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
var delta = now - this.continuousLastTime;
this.continuousLastTime = now;
var firstH = this.firstListHeight;
if (firstH <= 0) {
this.continuousRAF = this._requestAnimationFrame(this.continuousFrame);
return;
}
// 速度: firstListHeight / marqueeDuration (rpx/s),根据时间增量推进
var speed = firstH / this.marqueeDuration;
this.continuousTranslateY += speed * delta / 1000;
// 无缝循环:滚完第一份 A 后瞬时跳回 0,由于 A+A 内容相同,视觉上无跳变
if (this.continuousTranslateY >= firstH) {
this.continuousTranslateY = 0;
}
this.continuousRAF = this._requestAnimationFrame(this.continuousFrame);
},
// 跨端 requestAnimationFrame 兼容:H5 直接用 window,小程序/App 用 uni;极端情况回退 setTimeout
_requestAnimationFrame(callback) {
if (typeof uni !== 'undefined' && uni.requestAnimationFrame) {
return uni.requestAnimationFrame(callback);
}
// #ifdef H5
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
return window.requestAnimationFrame(callback);
}
// #endif
return setTimeout(callback, 16);
},
_cancelAnimationFrame(id) {
if (typeof uni !== 'undefined' && uni.cancelAnimationFrame) {
return uni.cancelAnimationFrame(id);
}
// #ifdef H5
if (typeof window !== 'undefined' && window.cancelAnimationFrame) {
return window.cancelAnimationFrame(id);
}
// #endif
return clearTimeout(id);
},
// ========== 步进模式:JS 控制 translateY 逐条滚动 ==========
// 启动:从 item 0 开始,先 pause pauseDuration ms 再滚到 item 1
// 注意: 方法名不以 _ 开头,避免 Vue 2 不代理到 this 上
startStepMarquee() {
this.stopStepMarquee();
if (!this.list || this.list.length <= 1) return;
if (!this.list || this.list.length <= 1 || this.isUserDragging) return;
this.stepIndex = 0;
this.stepTranslateY = 0;
this.isStepSnapping = false;
@@ -699,6 +802,33 @@ export default {
this.stepTimer = null;
}
},
// 用户拖拽结束后,从当前位置吸附到最近的 item,然后继续步进
resumeStepMarquee() {
var offsets = this.itemOffsetArr;
var n = offsets.length;
if (n <= 1) return;
var firstH = this.firstListHeight;
var y = this.stepTranslateY;
// 归一化到 [0, firstH)
y = ((y % firstH) + firstH) % firstH;
// 找到最接近的 offset
var nearestIndex = 0;
var minDiff = Infinity;
for (var i = 0; i < n; i++) {
var diff = Math.abs(offsets[i] - y);
if (diff < minDiff) {
minDiff = diff;
nearestIndex = i;
}
}
this.stepIndex = nearestIndex;
this.stepTranslateY = offsets[nearestIndex];
this.isStepSnapping = false;
var self = this;
this.stepTimer = setTimeout(function() {
self.stepAdvance();
}, this.pauseDuration);
},
// 推进到下一条:
// - 普通下一条:动画滚到 itemOffsetArr[i+1],停 pauseDuration ms,再推进
// - 最后一条:先动画滚到「第二份 item 0」= translateY(-firstListHeight),
@@ -801,14 +931,6 @@ export default {
.marquee-track {
will-change: transform;
animation-name: diy-article-marquee-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// 触摸暂停(见 script 中 onMarqueeTouchStart 加 is-marquee-paused 状态)
&.is-marquee-paused .marquee-track {
animation-play-state: paused;
}
// 关键:loopedList 把 list 复制了一份,最后一条 .item(2N-1)也会被 :last-of-type 命中,
@@ -822,18 +944,6 @@ export default {
}
}
// 关键:marquee-track 内容 = list + list (两份),高度 = 2 × 单份高度
// translateY(-50%) = 平移一份高度,窗口中显示的内容与起点完全一致 → 循环无跳变
// 若写 -100%:两份内容都滚走,窗口变空,是错误的。
@keyframes diy-article-marquee-up {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(0, -50%, 0);
}
}
.item {
margin-bottom: var(--article-item-gap, 16rpx);
/* 背景 / 圆角通过 CSS 变量从 .article-wrap 继承(由 warpWrapStyle 注入) */