refactor(article): 替换CSS动画跑马灯为JS驱动实现
重构文章组件的跑马灯滚动逻辑,移除CSS keyframes动画实现,改用JS控制transform实现滚动,新增支持用户手动拖拽交互,统一连续/步进两种滚动模式的触摸控制逻辑,优化跨端表现和交互体验。
This commit is contained in:
@@ -3,16 +3,15 @@
|
|||||||
<view class="article-wrap" :style="warpWrapStyle">
|
<view class="article-wrap" :style="warpWrapStyle">
|
||||||
<!-- 注: :class 用内联数组,与 ns-newform 对齐;computed listClass/itemClass 已移除(避免数组 toString 用逗号 join 的问题) -->
|
<!-- 注: :class 用内联数组,与 ns-newform 对齐;computed listClass/itemClass 已移除(避免数组 toString 用逗号 join 的问题) -->
|
||||||
<view :class="['list-wrap', value.style || 'style-1']">
|
<view :class="['list-wrap', value.style || 'style-1']">
|
||||||
<!-- 自动垂直滚动(CSS animation 跑马灯实现,默认开启)
|
<!-- 自动垂直滚动(JS 驱动 transform 跑马灯,默认开启,同时支持用户手动拖拽)
|
||||||
设计:loopedList 把 list 复制成 A+A 两份;外层容器固定高度(窗口)overflow:hidden,
|
设计:loopedList 把 list 复制成 A+A 两份;外层容器固定高度(窗口)overflow:hidden,
|
||||||
内层容器用 @keyframes translateY 从 0 → -第一份高度,动画循环无缝接续。
|
内层容器用 JS 控制 transform:translateY,从 0 → -第一份高度后无缝重置。
|
||||||
优势:① 无 JS setInterval 驱动 scrollTop,避免 uni-app scroll-view 在 H5/MP 下单位不一致
|
支持:① 自动连续/步进滚动;② 用户触摸并拖拽可手动滚动;③ 松手 2s 后从当前位置恢复自动滚动。 -->
|
||||||
导致"滚到底后跳变"问题;② 浏览器原生动画,60fps 平滑,跨端一致;
|
|
||||||
③ 触摸(touchstart)时 animation-play-state=paused,touchend 2s 后恢复。 -->
|
|
||||||
<view v-if="list.length > 0 && useScroll"
|
<view v-if="list.length > 0 && useScroll"
|
||||||
:class="['auto-scroll-list', { 'is-marquee-paused': marqueePaused }]"
|
class="auto-scroll-list"
|
||||||
:style="autoScrollListStyle"
|
:style="autoScrollListStyle"
|
||||||
@touchstart="onMarqueeTouchStart"
|
@touchstart="onMarqueeTouchStart"
|
||||||
|
@touchmove.stop.prevent="onMarqueeTouchMove"
|
||||||
@touchend="onMarqueeTouchEnd">
|
@touchend="onMarqueeTouchEnd">
|
||||||
<view class="marquee-track" :style="marqueeTrackStyle">
|
<view class="marquee-track" :style="marqueeTrackStyle">
|
||||||
<view v-for="(item, index) in loopedList" :key="index"
|
<view v-for="(item, index) in loopedList" :key="index"
|
||||||
@@ -127,11 +126,18 @@ export default {
|
|||||||
headHeight: '160rpx',
|
headHeight: '160rpx',
|
||||||
textRows: 2
|
textRows: 2
|
||||||
},
|
},
|
||||||
// ========== 跑马灯状态(CSS animation 驱动,替代 scroll-view + scrollTop)==========
|
// ========== 跑马灯状态(JS 驱动 transform,同时支持用户手动拖拽)==========
|
||||||
// marqueePaused: true 时给外层加 is-marquee-paused class → animation-play-state: paused
|
// marqueeResumeTimer: touchend 后延迟恢复自动滚动
|
||||||
// marqueeResumeTimer: touchend 后延迟恢复
|
|
||||||
marqueePaused: false,
|
|
||||||
marqueeResumeTimer: null,
|
marqueeResumeTimer: null,
|
||||||
|
// 用户拖拽状态
|
||||||
|
isUserDragging: false,
|
||||||
|
dragStartY: 0,
|
||||||
|
dragStartTranslateY: 0,
|
||||||
|
// ========== 连续滚动模式字段(marqueeMode === 'continuous')==========
|
||||||
|
// 驱动 transform: translateY 在 A+A 双份内容的 track 上连续滚动
|
||||||
|
continuousTranslateY: 0,
|
||||||
|
continuousRAF: null,
|
||||||
|
continuousLastTime: 0,
|
||||||
// ========== 步进模式字段(stepPause=true 时启用)==========
|
// ========== 步进模式字段(stepPause=true 时启用)==========
|
||||||
// 驱动 transform: translateY 在 A+A 双份内容的 track 上做 item-by-item 滚动
|
// 驱动 transform: translateY 在 A+A 双份内容的 track 上做 item-by-item 滚动
|
||||||
// 注意: 自定义字段不以 _ 开头,避免 Vue 2 不代理到 this 上导致 "is not a function"
|
// 注意: 自定义字段不以 _ 开头,避免 Vue 2 不代理到 this 上导致 "is not a function"
|
||||||
@@ -145,16 +151,9 @@ export default {
|
|||||||
this.getList();
|
this.getList();
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
// 直接清理所有定时器,避免在某些场景(如 HMR)下调用以 _ 开头方法时出现
|
// 直接清理所有定时器/动画帧,避免在某些场景(如 HMR)下调用以 _ 开头方法时出现
|
||||||
// "is not a function" 的问题
|
// "is not a function" 的问题
|
||||||
if (this.stepTimer) {
|
this.stopAllMarquee();
|
||||||
clearTimeout(this.stepTimer);
|
|
||||||
this.stepTimer = null;
|
|
||||||
}
|
|
||||||
if (this.marqueeResumeTimer) {
|
|
||||||
clearTimeout(this.marqueeResumeTimer);
|
|
||||||
this.marqueeResumeTimer = null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
// 组件刷新监听
|
// 组件刷新监听
|
||||||
@@ -163,22 +162,29 @@ export default {
|
|||||||
},
|
},
|
||||||
// list 加载完后,根据当前模式启动滚动
|
// list 加载完后,根据当前模式启动滚动
|
||||||
'list.length'(newLen) {
|
'list.length'(newLen) {
|
||||||
if (newLen > 1 && this.useScroll && this.marqueeMode === 'step') {
|
if (newLen > 1 && this.useScroll) {
|
||||||
var self = this;
|
var self = this;
|
||||||
this.$nextTick(function() {
|
this.$nextTick(function() {
|
||||||
self.startStepMarquee();
|
if (self.marqueeMode === 'step') {
|
||||||
|
self.startStepMarquee();
|
||||||
|
} else {
|
||||||
|
self.startContinuousMarquee(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 模式切换(后端修改 stepPause 后):停止旧的步进定时器,按需重启动
|
// 模式切换(后端修改 stepPause 后):停止旧的动画,按需重启动
|
||||||
marqueeMode(newMode) {
|
marqueeMode(newMode) {
|
||||||
this.stopStepMarquee();
|
this.stopAllMarquee();
|
||||||
if (newMode === 'step' && this.list && this.list.length > 1) {
|
if (!this.useScroll || !this.list || this.list.length <= 1) return;
|
||||||
var self = this;
|
var self = this;
|
||||||
this.$nextTick(function() {
|
this.$nextTick(function() {
|
||||||
|
if (newMode === 'step') {
|
||||||
self.startStepMarquee();
|
self.startStepMarquee();
|
||||||
});
|
} else {
|
||||||
}
|
self.startContinuousMarquee(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -393,20 +399,19 @@ export default {
|
|||||||
return d > 0 ? d : 500;
|
return d > 0 ? d : 500;
|
||||||
},
|
},
|
||||||
// 内层 .marquee-track 的 inline style
|
// 内层 .marquee-track 的 inline style
|
||||||
// 连贯模式:走 CSS @keyframes diy-article-marquee-up,inline 只指定 duration/delay
|
// 统一用 JS 控制 transform: translateY + transition;不再使用 CSS @keyframes,
|
||||||
// 步进模式:禁用 CSS 动画,用 JS 控制 transform: translateY + transition 实现逐条滚动
|
// 以便用户触摸拖拽时能即时改变滚动位置,并从当前位置恢复自动滚动。
|
||||||
marqueeTrackStyle() {
|
marqueeTrackStyle() {
|
||||||
if (this.marqueeMode === 'step') {
|
var translateY = this.marqueeMode === 'step' ? this.stepTranslateY : this.continuousTranslateY;
|
||||||
return this.$util.objToStyle({
|
// 步进自动滚动时启用 transition 产生动画;拖拽中/snapping 时禁用,保证即时响应/无跳变
|
||||||
'animation': 'none',
|
var transition = 'none';
|
||||||
'transform': 'translateY(-' + this._rpx(this.stepTranslateY) + ')',
|
if (this.marqueeMode === 'step' && !this.isUserDragging && !this.isStepSnapping) {
|
||||||
'transition': this.isStepSnapping ? 'none' : ('transform ' + this.stepAnimDuration + 'ms ease-in-out')
|
transition = 'transform ' + this.stepAnimDuration + 'ms ease-in-out';
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// 连贯模式:走 CSS @keyframes
|
|
||||||
return this.$util.objToStyle({
|
return this.$util.objToStyle({
|
||||||
'animation-duration': this.marqueeDuration + 's',
|
'animation': 'none',
|
||||||
'animation-delay': '-' + this.marqueeDelay + 's'
|
'transform': 'translateY(-' + this._rpx(translateY) + ')',
|
||||||
|
'transition': transition
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// list 中最高 item 的自然高度(动态) — swiperHeight + warpWrapStyle CSS 变量都用它
|
// list 中最高 item 的自然高度(动态) — swiperHeight + warpWrapStyle CSS 变量都用它
|
||||||
@@ -652,38 +657,136 @@ export default {
|
|||||||
cover_img: ''
|
cover_img: ''
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// ========== 跑马灯触摸控制(连贯模式:CSS animation-play-state; 步进模式:清除/恢复 JS 定时器)==========
|
// ========== 跑马灯触摸控制(连续/步进模式统一:暂停动画,允许用户拖拽手动滚动)==========
|
||||||
// 用户触摸 → 暂停滚动
|
// 用户触摸 → 停止自动滚动,记录拖拽起始位置(先不标记为拖拽,避免点击被误识别)
|
||||||
onMarqueeTouchStart() {
|
onMarqueeTouchStart(e) {
|
||||||
this.marqueePaused = true;
|
this.stopAllMarquee();
|
||||||
if (this.marqueeMode === 'step') {
|
this.isUserDragging = false;
|
||||||
this.stopStepMarquee();
|
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) {
|
if (this.marqueeResumeTimer) {
|
||||||
clearTimeout(this.marqueeResumeTimer);
|
clearTimeout(this.marqueeResumeTimer);
|
||||||
this.marqueeResumeTimer = null;
|
this.marqueeResumeTimer = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 用户松手 → 2s 后继续滚动(动画会从暂停位置继续,不会跳变)
|
// ========== 连续滚动模式:JS 控制 translateY 平滑滚动 ==========
|
||||||
onMarqueeTouchEnd() {
|
// 启动:根据 marqueeDelay 初始化偏移后开始逐帧滚动
|
||||||
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
|
// resetOffset=true 时从头计算初始偏移(首次启动/模式切换),否则从当前 continuousTranslateY 继续
|
||||||
var self = this;
|
startContinuousMarquee(resetOffset) {
|
||||||
this.marqueeResumeTimer = setTimeout(function() {
|
this.stopContinuousMarquee();
|
||||||
self.marqueePaused = false;
|
if (!this.list || this.list.length <= 1 || this.isUserDragging) return;
|
||||||
if (self.marqueeMode === 'step') {
|
var firstH = this.firstListHeight;
|
||||||
// 步进模式:从当前 item 的 pause 阶段继续
|
if (firstH > 0 && resetOffset) {
|
||||||
self.stepTimer = setTimeout(function() {
|
// 根据 marqueeDelay 计算初始偏移,与旧 CSS animation-delay 对齐
|
||||||
self.stepAdvance();
|
var initialOffset = (this.marqueeDelay / this.marqueeDuration) * firstH;
|
||||||
}, self.pauseDuration);
|
this.continuousTranslateY = initialOffset % firstH;
|
||||||
}
|
}
|
||||||
}, 2000);
|
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 逐条滚动 ==========
|
// ========== 步进模式:JS 控制 translateY 逐条滚动 ==========
|
||||||
// 启动:从 item 0 开始,先 pause pauseDuration ms 再滚到 item 1
|
// 启动:从 item 0 开始,先 pause pauseDuration ms 再滚到 item 1
|
||||||
// 注意: 方法名不以 _ 开头,避免 Vue 2 不代理到 this 上
|
// 注意: 方法名不以 _ 开头,避免 Vue 2 不代理到 this 上
|
||||||
startStepMarquee() {
|
startStepMarquee() {
|
||||||
this.stopStepMarquee();
|
this.stopStepMarquee();
|
||||||
if (!this.list || this.list.length <= 1) return;
|
if (!this.list || this.list.length <= 1 || this.isUserDragging) return;
|
||||||
this.stepIndex = 0;
|
this.stepIndex = 0;
|
||||||
this.stepTranslateY = 0;
|
this.stepTranslateY = 0;
|
||||||
this.isStepSnapping = false;
|
this.isStepSnapping = false;
|
||||||
@@ -699,6 +802,33 @@ export default {
|
|||||||
this.stepTimer = null;
|
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,再推进
|
// - 普通下一条:动画滚到 itemOffsetArr[i+1],停 pauseDuration ms,再推进
|
||||||
// - 最后一条:先动画滚到「第二份 item 0」= translateY(-firstListHeight),
|
// - 最后一条:先动画滚到「第二份 item 0」= translateY(-firstListHeight),
|
||||||
@@ -801,14 +931,6 @@ export default {
|
|||||||
|
|
||||||
.marquee-track {
|
.marquee-track {
|
||||||
will-change: transform;
|
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 命中,
|
// 关键: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 {
|
.item {
|
||||||
margin-bottom: var(--article-item-gap, 16rpx);
|
margin-bottom: var(--article-item-gap, 16rpx);
|
||||||
/* 背景 / 圆角通过 CSS 变量从 .article-wrap 继承(由 warpWrapStyle 注入) */
|
/* 背景 / 圆角通过 CSS 变量从 .article-wrap 继承(由 warpWrapStyle 注入) */
|
||||||
|
|||||||
Reference in New Issue
Block a user