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

384 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>