chuangte_bike_newxcx/components/text-marquee/text-marquee.vue

384 lines
8.4 KiB
Vue
Raw Normal View History

2026-04-02 01:48:12 +08:00
<template>
<view class="text-marquee" :style="rootStyle">
<!-- 测量节点须留在组件布局树内fixed + 移出视区在部分小程序上宽度为 0导致永不跑马灯 -->
<view class="text-marquee__measure">
<text :id="measureId" class="text-marquee__measure-text" :style="textStyle">{{ text || '' }}</text>
</view>
<view :id="boxId" class="text-marquee__box">
<view v-if="measured && !isOverflow" class="text-marquee__static">
<text :style="textStyle">{{ text || '' }}</text>
</view>
<view v-else-if="measured && isOverflow" class="text-marquee__scroll">
<view class="text-marquee__track" :style="marqueeTransformStyle">
<text class="text-marquee__seg" :style="textStyle">{{ text }}</text>
<view class="text-marquee__gap" :style="{ width: gapRpx + 'rpx' }" />
<text class="text-marquee__seg" :style="textStyle">{{ text }}</text>
</view>
</view>
<view v-else class="text-marquee__static">
<text :style="textStyle">{{ text || '' }}</text>
</view>
</view>
</view>
</template>
<script>
function raf(fn) {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame(fn)
}
return setTimeout(fn, 16)
}
function caf(id) {
if (id == null) {
return
}
clearTimeout(id)
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(id)
}
}
export default {
name: 'text-marquee',
props: {
text: {
type: String,
default: ''
},
width: {
type: String,
default: '100%'
},
speed: {
type: Number,
default: 40
},
gap: {
type: Number,
default: 32
},
fontSize: {
type: [Number, String],
default: 28
},
color: {
type: String,
default: '#333333'
},
fontWeight: {
type: [Number, String],
default: 'normal'
},
/** 测量防抖ms减轻父页面频繁 setData 导致的反复测量 */
measureDebounce: {
type: Number,
default: 200
},
/** 溢出滞回:进入需 tw > bw + margin退出需 tw < bw - margin避免临界抖动 */
overflowMargin: {
type: Number,
default: 4
}
},
data() {
return {
uid: `m_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
measured: false,
isOverflow: false,
textWidthPx: 0,
marqueeOffsetPx: 0,
_periodPx: 0,
_rAF: null,
_lastTs: 0,
_measureTimer: null,
_resizeTimer: null,
/** 每次发起测量自增exec 回调只对最后一次测量生效,避免多次点击/切换时异步结果乱序覆盖 */
_measureSeq: 0,
/** 每次停止跑马灯自增,旧 rAF 回调读到后不继续调度,避免多循环抢 _lastTs 导致 dt 为负、左右狂抖 */
_marqueeGen: 0
}
},
computed: {
gapPx() {
if (typeof uni !== 'undefined' && typeof uni.upx2px === 'function') {
return uni.upx2px(this.gapRpx)
}
return Number(this.gapRpx) || 0
},
measureId() {
return `mm_${this.uid}`
},
boxId() {
return `mb_${this.uid}`
},
gapRpx() {
return this.gap
},
rootStyle() {
return {
width: this.width
}
},
fontSizeWithUnit() {
const fs = this.fontSize
return typeof fs === 'number' ? `${fs}rpx` : fs
},
textStyle() {
return {
fontSize: this.fontSizeWithUnit,
color: this.color,
fontWeight: this.fontWeight
}
},
marqueeTransformStyle() {
if (!this.isOverflow) {
return {}
}
const x = Math.round(this.marqueeOffsetPx * 100) / 100
return {
transform: `translate3d(${x}px, 0, 0)`,
willChange: 'transform'
}
}
},
watch: {
text() {
this.scheduleRemeasure()
},
width() {
this.scheduleRemeasure()
},
fontSize() {
this.scheduleRemeasure()
},
gap() {
this.scheduleRemeasure()
}
},
mounted() {
this.runRemeasure()
// 父级 flex/百分比宽在首帧可能未就绪,延迟再测(尤其微信小程序)
this.$nextTick(() => {
setTimeout(() => this.runRemeasure(), 50)
setTimeout(() => this.runRemeasure(), 280)
})
if (typeof uni !== 'undefined' && uni.onWindowResize) {
this._resizeHandler = () => {
clearTimeout(this._resizeTimer)
this._resizeTimer = setTimeout(() => {
this._resizeTimer = null
this.runRemeasure()
}, 280)
}
uni.onWindowResize(this._resizeHandler)
}
},
beforeDestroy() {
this.stopMarquee()
clearTimeout(this._measureTimer)
clearTimeout(this._resizeTimer)
if (this._resizeHandler && typeof uni !== 'undefined' && uni.offWindowResize) {
uni.offWindowResize(this._resizeHandler)
}
},
methods: {
scheduleRemeasure() {
const ms = Math.max(0, Number(this.measureDebounce) || 0)
clearTimeout(this._measureTimer)
if (ms <= 0) {
this.runRemeasure()
return
}
this._measureTimer = setTimeout(() => {
this._measureTimer = null
this.runRemeasure()
}, ms)
},
runRemeasure() {
const seq = ++this._measureSeq
this.$nextTick(() => {
if (seq !== this._measureSeq) {
return
}
const q = uni.createSelectorQuery().in(this)
q.select(`#${this.measureId}`).boundingClientRect()
q.select(`#${this.boxId}`).boundingClientRect()
q.exec((res) => {
if (seq !== this._measureSeq) {
return
}
const tr = res && res[0]
const br = res && res[1]
if (!tr || !br || typeof tr.width !== 'number' || typeof br.width !== 'number') {
this.applyOverflowDecision(false, 0)
return
}
const tw = tr.width
const bw = br.width
this.textWidthPx = tw
const m = Math.max(0, Number(this.overflowMargin) || 0)
let nextOverflow
if (!this.measured) {
nextOverflow = tw > bw + m
} else if (this.isOverflow) {
nextOverflow = tw > bw - m
} else {
nextOverflow = tw > bw + m
}
const period = tw + this.gapPx
this.applyOverflowDecision(nextOverflow, period)
})
})
},
applyOverflowDecision(nextOverflow, period) {
const wasOverflow = this.isOverflow
this.isOverflow = nextOverflow
this.measured = true
if (!nextOverflow) {
this.stopMarquee()
this.marqueeOffsetPx = 0
this._periodPx = 0
return
}
const periodChanged =
this._periodPx <= 0 || Math.abs(this._periodPx - period) > 0.75
this._periodPx = period
if (!wasOverflow && nextOverflow) {
this.marqueeOffsetPx = 0
this.$nextTick(() => this.startMarquee(period))
return
}
if (wasOverflow && nextOverflow && periodChanged) {
this.marqueeOffsetPx = 0
this.stopMarquee()
this.$nextTick(() => this.startMarquee(period))
return
}
if (wasOverflow && nextOverflow && !this._rAF) {
this.$nextTick(() => this.startMarquee(period))
}
},
stopMarquee() {
this._marqueeGen++
if (this._rAF != null) {
caf(this._rAF)
this._rAF = null
}
this._lastTs = 0
},
startMarquee(periodPx) {
this.stopMarquee()
if (!this.isOverflow || periodPx <= 0) {
return
}
const myGen = this._marqueeGen
const speed = Math.max(1, Number(this.speed) || 40)
const loop = (ts) => {
if (myGen !== this._marqueeGen) {
return
}
if (!this.isOverflow || this._periodPx <= 0) {
this._rAF = null
return
}
if (!this._lastTs) {
this._lastTs = ts
}
let dt = ts - this._lastTs
this._lastTs = ts
if (dt < 0) {
dt = 0
}
if (dt > 80) {
dt = 80
}
let next = this.marqueeOffsetPx - (speed * dt) / 1000
const p = this._periodPx
while (next <= -p) {
next += p
}
this.marqueeOffsetPx = next
if (myGen !== this._marqueeGen) {
return
}
this._rAF = raf(loop)
}
this._rAF = raf(loop)
}
}
}
</script>
<style lang="scss" scoped>
.text-marquee {
position: relative;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
}
.text-marquee__measure {
position: absolute;
left: 0;
top: 0;
z-index: -1;
opacity: 0;
pointer-events: none;
white-space: nowrap;
display: inline-block;
width: max-content;
max-width: none;
height: 0;
overflow: visible;
}
.text-marquee__measure-text {
white-space: nowrap;
display: inline-block;
}
.text-marquee__box {
width: 100%;
overflow: hidden;
box-sizing: border-box;
}
.text-marquee__static {
width: 100%;
overflow: hidden;
white-space: nowrap;
}
.text-marquee__scroll {
width: 100%;
overflow: hidden;
}
.text-marquee__track {
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
white-space: nowrap;
backface-visibility: hidden;
}
.text-marquee__seg {
flex-shrink: 0;
white-space: nowrap;
}
.text-marquee__gap {
flex-shrink: 0;
height: 1px;
}
</style>