refactor(article): 替换CSS动画跑马灯为JS驱动实现
重构文章组件的跑马灯滚动逻辑,移除CSS keyframes动画实现,改用JS控制transform实现滚动,新增支持用户手动拖拽交互,统一连续/步进两种滚动模式的触摸控制逻辑,优化跨端表现和交互体验。
This commit is contained in:
@@ -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 注入) */
|
||||
|
||||
Reference in New Issue
Block a user