384 lines
8.4 KiB
Vue
384 lines
8.4 KiB
Vue
<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>
|