refactor(article-component): 替换swiper为scroll-view实现自动滚动,优化紧凑布局
1. 将原swiper自动滚动方案替换为scroll-view实现,支持item自然高度,不再强制等高,解决短item下方留白问题 2. 新增连贯滚动和步进滚动两种自动滚动模式,适配不同场景需求 3. 修复H5端rpx单位不兼容问题,统一内联样式单位转换逻辑 4. 优化滚动容器高度计算,根据配置显示数量动态计算窗口高度 5. 新增触摸暂停恢复逻辑,提升用户交互体验
This commit is contained in:
@@ -3,53 +3,58 @@
|
||||
<view class="article-wrap" :style="warpWrapStyle">
|
||||
<!-- 注: :class 用内联数组,与 ns-newform 对齐;computed listClass/itemClass 已移除(避免数组 toString 用逗号 join 的问题) -->
|
||||
<view :class="['list-wrap', value.style || 'style-1']">
|
||||
<!-- 自动垂直滚动(默认开启,兼容老数据:无 scrollMode/swiperConfig 字段也走这里) -->
|
||||
<swiper v-if="list.length > 0 && useScroll" class="auto-scroll-swiper"
|
||||
:style="swiperWrapStyle"
|
||||
:vertical="swiperConfig.vertical !== false"
|
||||
:autoplay="swiperConfig.autoplay !== false"
|
||||
:circular="swiperConfig.circular !== false"
|
||||
:interval="swiperConfig.interval || 3000"
|
||||
:duration="swiperConfig.duration || 500"
|
||||
:display-multiple-items="safeDisplayMultipleItems">
|
||||
<swiper-item v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)">
|
||||
<view class="swiper-item-content">
|
||||
<view :class="['item', value.ornament && value.ornament.type, !item.cover_img && value.style !== 'style-4' ? 'no-image' : '']" :style="itemStyle">
|
||||
<!-- 封面图:无图时隐藏图位;style-4 纯文字始终不显示 -->
|
||||
<view class="article-img" v-if="item.cover_img && value.style !== 'style-4'">
|
||||
<image class="cover-img" :src="$util.img(item.cover_img)" mode="aspectFill"
|
||||
@error="imgError(index)" show-menu-by-longpress></image>
|
||||
<!-- 自动垂直滚动(scroll-view 实现,默认开启,兼容老数据:无 scrollMode/swiperConfig 字段也走这里)
|
||||
设计:item 自然高度 + 固定 itemGap,scrollTop 累加 + loopedList 复制实现无缝循环
|
||||
vs 旧 swiper 方案:旧方案用 min-height 强制等高 → 短 item 下方空一大块,不符合"紧凑"诉求
|
||||
新方案:item 完全用内容撑起,每条 item 后固定 itemGap 空白,视觉间距天然恒等 -->
|
||||
<scroll-view v-if="list.length > 0 && useScroll" class="auto-scroll-list"
|
||||
:style="autoScrollListStyle"
|
||||
scroll-y="true"
|
||||
:scroll-top="scrollTop"
|
||||
:scroll-with-animation="scrollWithAnimation"
|
||||
:show-scrollbar="false"
|
||||
:enhanced="false"
|
||||
:bounces="false"
|
||||
:throttle="false"
|
||||
@touchstart="onAutoScrollTouchStart"
|
||||
@touchend="onAutoScrollTouchEnd">
|
||||
<view v-for="(item, index) in loopedList" :key="index"
|
||||
:class="['item', value.ornament && value.ornament.type, !item.cover_img && value.style !== 'style-4' ? 'no-image' : '']"
|
||||
:style="itemStyle"
|
||||
@tap.stop="toDetail(item)">
|
||||
<!-- 封面图:无图时隐藏图位;style-4 纯文字始终不显示 -->
|
||||
<view class="article-img" v-if="item.cover_img && value.style !== 'style-4'">
|
||||
<image class="cover-img" :src="$util.img(item.cover_img)" mode="aspectFill"
|
||||
@error="imgError(index % list.length)" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<text class="title">{{ item.article_title }}</text>
|
||||
<text class="desc"
|
||||
v-if="abstractStyle.support && abstractStyle.control && item.article_abstract">
|
||||
{{ item.article_abstract }}
|
||||
</text>
|
||||
<view class="tag-list"
|
||||
v-if="tagStyle.support && tagStyle.control && item.tag_names">
|
||||
<text class="tag" v-for="(tn, ti) in tagArr(item.tag_names)" :key="ti">{{ tn }}</text>
|
||||
</view>
|
||||
<view class="read-wrap">
|
||||
<block v-if="item.category_name">
|
||||
<text class="category-icon"></text>
|
||||
<text class="category-name">{{ item.category_name }}</text>
|
||||
</block>
|
||||
<view class="num" v-if="readNumStyle.support && readNumStyle.control">
|
||||
<uni-icons type="eye" size="12" :color="metaColor" />
|
||||
<text>{{ item.read_num || 0 }}</text>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<text class="title">{{ item.article_title }}</text>
|
||||
<text class="desc"
|
||||
v-if="abstractStyle.support && abstractStyle.control && item.article_abstract">
|
||||
{{ item.article_abstract }}
|
||||
</text>
|
||||
<view class="tag-list"
|
||||
v-if="tagStyle.support && tagStyle.control && item.tag_names">
|
||||
<text class="tag" v-for="(tn, ti) in tagArr(item.tag_names)" :key="ti">{{ tn }}</text>
|
||||
</view>
|
||||
<view class="read-wrap">
|
||||
<block v-if="item.category_name">
|
||||
<text class="category-icon"></text>
|
||||
<text class="category-name">{{ item.category_name }}</text>
|
||||
</block>
|
||||
<view class="num" v-if="readNumStyle.support && readNumStyle.control">
|
||||
<uni-icons type="eye" size="12" :color="metaColor" />
|
||||
<text>{{ item.read_num || 0 }}</text>
|
||||
</view>
|
||||
<view class="num" v-if="dianzanStyle.support && dianzanStyle.control">
|
||||
<uni-icons type="star" size="12" :color="metaColor" />
|
||||
<text>{{ item.dianzan_num || 0 }}</text>
|
||||
</view>
|
||||
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
|
||||
</view>
|
||||
<view class="num" v-if="dianzanStyle.support && dianzanStyle.control">
|
||||
<uni-icons type="star" size="12" :color="metaColor" />
|
||||
<text>{{ item.dianzan_num || 0 }}</text>
|
||||
</view>
|
||||
<text class="date" v-if="dateStyle.support && dateStyle.control">{{ $util.timeStampTurnTime(item.create_time, 'date') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<!-- 非滚动模式(scrollMode==='none')时的普通列表 -->
|
||||
<template v-if="!useScroll">
|
||||
<view v-for="(item, index) in list" :key="index" :class="['item', value.ornament && value.ornament.type, !item.cover_img && value.style !== 'style-4' ? 'no-image' : '']" :style="itemStyle"
|
||||
@@ -123,16 +128,46 @@ export default {
|
||||
headWidth: '160rpx',
|
||||
headHeight: '160rpx',
|
||||
textRows: 2
|
||||
}
|
||||
},
|
||||
// ========== 滚动模式(scroll-view 自动滚动)状态 ==========
|
||||
// scrollTop 累加驱动滚动;loopedList 把 list 复制一份实现无缝循环
|
||||
scrollTop: 0,
|
||||
autoScrollTimer: null, // setTimeout(16ms) id(60fps tick,跨端平滑,替代 setInterval 30ms)
|
||||
autoScrollResumeTimer: null, // touchend 后延迟恢复
|
||||
_scrollAccumulator: 0, // sub-pixel 累计,每帧 +0.4,到 ≥1 才提交 scrollTop
|
||||
// ========== 步进模式(scroll-step + pause)状态 ==========
|
||||
// 滚动一下 + 停留一会:每次把 scrollTop 推到下一个 item 起点,停 pauseDuration ms,再推下一个
|
||||
// 跳回 0(列表末尾 → 列表开头)时也走 stopPause + 立即 0,视觉无感
|
||||
_stepCurrentIndex: 0, // 当前已显示到的 item 索引(0..N-1)
|
||||
_stepPhase: 'idle', // 'idle' | 'scrolling' | 'pausing'
|
||||
_stepResetting: false // true=正在跳回 0,scrollWithAnimation computed 翻成 false,本次 scrollTop=0 赋值无动画
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getList();
|
||||
},
|
||||
mounted() {
|
||||
// 自动滚动由 watch 'list.length' 在 list 加载完后启动(mounted 时 list 仍为空)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.stopAutoScroll();
|
||||
if (this.autoScrollResumeTimer) {
|
||||
clearTimeout(this.autoScrollResumeTimer);
|
||||
this.autoScrollResumeTimer = null;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh() {
|
||||
this.getList();
|
||||
},
|
||||
// list 加载完后启动自动滚动(mounted 时 list 还是空,需监听 list 长度变化)
|
||||
'list.length'(newLen) {
|
||||
if (newLen > 1 && this.useScroll) {
|
||||
this.$nextTick(() => {
|
||||
this.startAutoScroll();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -167,39 +202,45 @@ export default {
|
||||
// CSS 变量(向下传递给所有子元素,通过 var() 读取)
|
||||
'--article-component-bg-color': v.componentBgColor || 'transparent',
|
||||
'--article-element-bg-color': v.elementBgColor || 'transparent',
|
||||
'--article-top-around-radius': isRootRound ? `${r(v.topAroundRadius, 0) * 2}rpx` : '0rpx',
|
||||
'--article-bottom-around-radius': isRootRound ? `${r(v.bottomAroundRadius, 0) * 2}rpx` : '0rpx',
|
||||
'--article-top-element-around-radius': isItemRound ? `${r(v.topElementAroundRadius, 0) * 2}rpx` : '0rpx',
|
||||
'--article-bottom-element-around-radius': isItemRound ? `${r(v.bottomElementAroundRadius, 0) * 2}rpx` : '0rpx',
|
||||
// ========== 关键:内联 style 的 rpx 在 H5 端浏览器不识别! ==========
|
||||
// MP-WXSS 原生支持 rpx,内联 rpx 有效;H5 端 rpx 不是有效 CSS 单位,
|
||||
// 直接写 "280rpx" 浏览器会忽略,导致 CSS 变量 / swiper 高度全失效。
|
||||
// 全部改走 this._rpx(val),H5 转 vw(750rpx 设计宽度 → 100/750 vw),
|
||||
// 其他端保留 rpx,与 CSS 文件(走 postcss 转换)对齐。
|
||||
'--article-top-around-radius': this._rpx(isRootRound ? r(v.topAroundRadius, 0) * 2 : 0),
|
||||
'--article-bottom-around-radius': this._rpx(isRootRound ? r(v.bottomAroundRadius, 0) * 2 : 0),
|
||||
'--article-top-element-around-radius': this._rpx(isItemRound ? r(v.topElementAroundRadius, 0) * 2 : 0),
|
||||
'--article-bottom-element-around-radius': this._rpx(isItemRound ? r(v.bottomElementAroundRadius, 0) * 2 : 0),
|
||||
// 标签胶囊 3 色 + 字号(rpx)
|
||||
'--article-tag-bg-color': tag.bgColor || '#f0f9ff',
|
||||
'--article-tag-text-color': tag.textColor || '#1e6bb8',
|
||||
'--article-tag-border-color': tag.borderColor || '#b3d8ff',
|
||||
'--article-tag-font-size': r(tag.fontSize, 22) + 'rpx',
|
||||
'--article-tag-font-size': this._rpx(r(tag.fontSize, 22)),
|
||||
// 摘要文字色 + 字号(rpx)
|
||||
'--article-abstract-color': abs.textColor || '#888888',
|
||||
'--article-abstract-font-size': r(abs.fontSize, 24) + 'rpx',
|
||||
'--article-abstract-font-size': this._rpx(r(abs.fontSize, 24)),
|
||||
// 阅读区文字色 + 图标大小 + 文字大小(rpx)
|
||||
'--article-meta-color': rw.color || '#999ca7',
|
||||
'--article-meta-icon-size': r(rw.iconSize, 24) + 'rpx',
|
||||
'--article-meta-font-size': r(rw.fontSize, 24) + 'rpx',
|
||||
'--article-meta-icon-size': this._rpx(r(rw.iconSize, 24)),
|
||||
'--article-meta-font-size': this._rpx(r(rw.fontSize, 24)),
|
||||
// 查看更多字号 + 图标大小(rpx,统一走 r() 兜底,不再 typeof 区分)
|
||||
'--article-view-more-font-size': r(vm.fontSize, 26) + 'rpx',
|
||||
'--article-view-more-icon-size': r(vm.iconSize, 24) + 'rpx',
|
||||
// 列表项间距(rpx)
|
||||
'--article-item-gap': r(v.itemGap, 16) + 'rpx',
|
||||
'--article-view-more-font-size': this._rpx(r(vm.fontSize, 26)),
|
||||
'--article-view-more-icon-size': this._rpx(r(vm.iconSize, 24)),
|
||||
// 列表项间距(rpx)— H5 端此值若失效,.swiper-item-content padding-bottom 变 0,item 直接贴一起
|
||||
'--article-item-gap': this._rpx(r(v.itemGap, 16)),
|
||||
// swiper 内 item 统一最小高度(rpx)= list 中最高 item 的自然高度
|
||||
// CSS 中 .auto-scroll-swiper .item { min-height: var(--article-swiper-item-min-height) }
|
||||
// 强制所有 item 高度统一,搭配 .swiper-item-content 的 padding-bottom = itemGap
|
||||
// 制造恒定视觉间距,与 swiperHeight = (maxItemHeight + itemGap) × N 公式一致
|
||||
'--article-swiper-item-min-height': this.maxItemHeight + 'rpx',
|
||||
// H5 端此值若失效,fallback 200rpx 会让 item 高度统一卡死在 200rpx,有摘要 item 溢出被裁
|
||||
'--article-swiper-item-min-height': this._rpx(this.maxItemHeight),
|
||||
// 分割线颜色(主要用于 style-4)
|
||||
'--article-divider-color': v.dividerColor || '#eeeeee',
|
||||
// 标题字号 4 风格独立(rpx)
|
||||
'--article-title-fs-1': tfs1 + 'rpx',
|
||||
'--article-title-fs-2': tfs2 + 'rpx',
|
||||
'--article-title-fs-3': tfs3 + 'rpx',
|
||||
'--article-title-fs-4': tfs4 + 'rpx'
|
||||
'--article-title-fs-1': this._rpx(tfs1),
|
||||
'--article-title-fs-2': this._rpx(tfs2),
|
||||
'--article-title-fs-3': this._rpx(tfs3),
|
||||
'--article-title-fs-4': this._rpx(tfs4)
|
||||
});
|
||||
},
|
||||
// 单项容器样式:背景 + 装饰(box-shadow / border)
|
||||
@@ -211,19 +252,42 @@ export default {
|
||||
'background-color': v.elementBgColor || 'transparent'
|
||||
};
|
||||
if (ornament.type === 'shadow') {
|
||||
style['box-shadow'] = `0 0 10rpx ${ornament.color || 'rgba(0, 0, 0, 0.1)'}`;
|
||||
// H5 端 rpx 失效,改走 _rpx()。10/2 是阴影模糊半径/边框宽度,与字号/间距共用同一单位
|
||||
style['box-shadow'] = `0 0 ${this._rpx(10)} ${ornament.color || 'rgba(0, 0, 0, 0.1)'}`;
|
||||
}
|
||||
if (ornament.type === 'stroke') {
|
||||
style['border'] = `2rpx solid ${ornament.color || 'transparent'}`;
|
||||
style['border'] = `${this._rpx(2)} solid ${ornament.color || 'transparent'}`;
|
||||
}
|
||||
return this.$util.objToStyle(style);
|
||||
},
|
||||
// swiper 容器高度(由 swiperHeight 计算属性驱动)
|
||||
swiperWrapStyle() {
|
||||
// 滚动容器高度(scroll-view 用)
|
||||
// 语义:窗口显示「前 N 个 item 的实际自然高度」,严格匹配后端 displayMultipleItems 配置
|
||||
// N = safeDisplayMultipleItems
|
||||
// 高度 = sum(前 N 个 item 的 _itemHeight) + (N-1) × itemGap
|
||||
// 之前用 maxItemHeight(全 list 最高一件) × N + buffer:短 item 多塞,长 item 溢出,可见行数随内容漂移
|
||||
// 现在:前 N 个 item 的真实高度之和 → 窗口里就是 N 行,所见即所配
|
||||
// 边界:list 尚未加载时(list 空)走 _staticMaxItemHeight() × N 兜底,保持初始可见区域
|
||||
autoScrollListStyle() {
|
||||
return this.$util.objToStyle({
|
||||
height: `${this.swiperHeight}rpx`
|
||||
height: this._rpx(this._visibleWindowHeight)
|
||||
});
|
||||
},
|
||||
// 可见窗口高度(rpx) — 即 scroll-view 容器高度
|
||||
_visibleWindowHeight() {
|
||||
if (!this.list || this.list.length === 0) {
|
||||
// 兜底:用静态最高 item × N 估算,保证初始有可见区域
|
||||
return this._staticMaxItemHeight() * this.safeDisplayMultipleItems;
|
||||
}
|
||||
var n = this.safeDisplayMultipleItems;
|
||||
var gap = this._num(this.value.itemGap, 16);
|
||||
// 取前 N 个 item 的自然高度之和(list 不足 N 时全取)
|
||||
var count = Math.min(n, this.list.length);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < count; i++) {
|
||||
sum += this._itemHeight(this.list[i]);
|
||||
}
|
||||
return sum + (count - 1) * gap;
|
||||
},
|
||||
// 查看更多样式(颜色由 viewMore.color 控制)
|
||||
viewMoreStyle() {
|
||||
return this.$util.objToStyle({
|
||||
@@ -273,12 +337,39 @@ export default {
|
||||
useScroll() {
|
||||
return this.scrollMode !== 'none' && this.list.length > 1;
|
||||
},
|
||||
// scroll-view 的 scroll-with-animation 绑定
|
||||
// 连贯模式: false(每帧 scrollTop += 1 同步生效,避免被 200ms 缓动打断造成频闪)
|
||||
// 步进模式:
|
||||
// - 推进中(scrolling 阶段,推 scrollTop 到下一个 item 起点): true(走 300-500ms 缓动,视觉是「滚一下」)
|
||||
// - 跳回 0 那一瞬(从 list 末尾 → 第二份 item 0 / 直接跳到 0): false(无动画,避免「向前滚回」)
|
||||
// - 暂停中(pausing 阶段): 当前 scrollTop 不动,binding 值无影响
|
||||
// 实现:_stepResetting 标志位标识「正在跳回 0,本次 scrollTop 赋值不要动画」
|
||||
scrollWithAnimation() {
|
||||
if (!this.stepPauseEnabled) return false;
|
||||
// 步进模式:正在跳回 0 时返回 false,否则 true
|
||||
return !this._stepResetting;
|
||||
},
|
||||
// 安全的 display-multiple-items(确保不大于实际 list 长度,避免 swiper 报错)
|
||||
safeDisplayMultipleItems() {
|
||||
var displayCount = this.swiperConfig.displayMultipleItems || 3;
|
||||
var actualCount = this.list.length;
|
||||
return actualCount > 0 ? Math.min(displayCount, actualCount) : 1;
|
||||
},
|
||||
// 是否启用「滚动一下 + 停留一会」步进模式
|
||||
// 默认 false(走 sub-pixel 连贯滚动);true 时按 item 粒度滚动,每滚一个 item 停 pauseDuration ms 再滚下一个
|
||||
stepPauseEnabled() {
|
||||
return !!this.swiperConfig.stepPause;
|
||||
},
|
||||
// 步进模式停留时长(ms),默认 1500
|
||||
pauseDuration() {
|
||||
var d = parseInt(this.swiperConfig.pauseDuration);
|
||||
return d > 0 ? d : 1500;
|
||||
},
|
||||
// 步进模式单次滚动动画时长(ms),默认 500(uni-app scroll-view 默认动画 300ms,留 buffer)
|
||||
stepAnimDuration() {
|
||||
var d = parseInt(this.swiperConfig.stepAnimDuration);
|
||||
return d > 0 ? d : 500;
|
||||
},
|
||||
// swiper 高度(rpx)优先级:
|
||||
// 1. 老/新数据显式设置了 swiperConfig.swiperHeight(>0) → 配置值(用户强制覆盖)
|
||||
// 2. 否则按 (maxItemHeight + itemGap) × N 计算(精确,不加分摊 buffer),其中:
|
||||
@@ -310,6 +401,37 @@ export default {
|
||||
return this._itemHeight(item);
|
||||
}.bind(this)));
|
||||
},
|
||||
// ========== scroll-view 自动滚动 配套 computed ==========
|
||||
// 滚动列表(复制一份用于无缝循环):scrollTop 滚到第一份底部时跳回 0,视觉上接续
|
||||
loopedList() {
|
||||
if (!this.list || this.list.length === 0) return [];
|
||||
return this.list.concat(this.list);
|
||||
},
|
||||
// 单份 list 的总高度(rpx)= sum(各 item 自然高) + (N-1) × itemGap
|
||||
// 重置 scrollTop 用:当 scrollTop 累加到 ≥ 此值,说明已经滚完第一份,跳回 0 视觉无感
|
||||
firstListHeight() {
|
||||
if (!this.list || this.list.length === 0) return 0;
|
||||
var gap = this._num(this.value.itemGap, 16);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < this.list.length; i++) {
|
||||
sum += this._itemHeight(this.list[i]);
|
||||
}
|
||||
return sum + (this.list.length - 1) * gap;
|
||||
},
|
||||
// item 索引 i 的累计顶部偏移(rpx)= sum(items[0..i-1].height) + i × gap
|
||||
// 步进模式用:把 scrollTop 推到 _itemOffset(i) 即「item i 顶部贴窗口顶部」
|
||||
// loopedList[i+N] 位置 = firstListHeight + _itemOffset(i),即「第一份末尾之后接续」
|
||||
_itemOffsetArr() {
|
||||
if (!this.list || this.list.length === 0) return [];
|
||||
var gap = this._num(this.value.itemGap, 16);
|
||||
var arr = [];
|
||||
var sum = 0;
|
||||
for (var i = 0; i < this.list.length; i++) {
|
||||
arr.push(sum);
|
||||
sum += this._itemHeight(this.list[i]) + gap;
|
||||
}
|
||||
return arr;
|
||||
},
|
||||
// 注: :class 一律在模板里用 :class="['static', dynamic]" 内联数组写法
|
||||
// 不再用 listClass/itemClass computed (computed 返回 array 时,uni-app WXML 编译
|
||||
// 输出 {{[listClass]}},运行时 toString 用逗号 join,产生 "list-wrap,style-1" 错误 class)
|
||||
@@ -321,6 +443,17 @@ export default {
|
||||
var n = parseInt(v);
|
||||
return isNaN(n) ? fb : n;
|
||||
},
|
||||
// 内联 style 单位归一化:H5 端 rpx 不是合法 CSS 单位,转 vw;其他端保留 rpx
|
||||
// 设计宽度按 750rpx(uni-app 默认),转换 1rpx = 100/750 vw
|
||||
// 关键:这个方法只用于内联 :style 绑定,CSS 文件里的 rpx 走 postcss 转换,不用管
|
||||
_rpx(val) {
|
||||
// #ifdef H5
|
||||
return (val * 100 / 750) + 'vw';
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return val + 'rpx';
|
||||
// #endif
|
||||
},
|
||||
// ========== 动态 perRow 估算(swiperHeight 用) ==========
|
||||
// 估算单条 item 的自然高度(rpx),考虑:
|
||||
// - style 决定图片高度 / info-wrap 宽度 / padding
|
||||
@@ -474,6 +607,156 @@ export default {
|
||||
...this.list[index],
|
||||
cover_img: ''
|
||||
});
|
||||
},
|
||||
// ========== scroll-view 自动滚动控制(两种模式互不干扰) ==========
|
||||
// 模式 1: 连贯滚动(sub-pixel + 16ms tick) — 默认
|
||||
// 设计目标:消除微信小程序「频闪」 + 跨端统一平滑滚动
|
||||
// 旧方案(频闪根因):setInterval(30ms) + scrollTop += 1 + scroll-with-animation="true"
|
||||
// - 30ms ≈ 33fps,与屏幕 60fps 不同步,出现周期性跳帧
|
||||
// - scroll-with-animation 让每次 scrollTop 变化都跑 200ms 缓动,30ms 内动画未完成又被打断
|
||||
// - MP-WXSS scroll-view 渲染管线压力大,叠加动画 → 肉眼可见 1 像素级跳变
|
||||
// 新方案:
|
||||
// - setTimeout(16ms) tick(60fps):跨端一致(MP 无全局 rAF,setTimeout 跨端可用)
|
||||
// - 每帧累加 0.4px(sub-pixel 抗锯齿),到 ≥ 1 才提交 scrollTop(避免每帧触发 vue 响应式)
|
||||
// - scroll-with-animation="false":scrollTop 同步生效,不启动 200ms 缓动
|
||||
// - 跳回 0 用 scrollTop 强制赋值,无动画 → 视觉无感
|
||||
// - 速度: 0.4 × 60 = 24 px/秒,慢速平稳
|
||||
//
|
||||
// 模式 2: 步进滚动(滚一下 + 停留一会) — 后端 stepPause=true 时启用
|
||||
// 语义:每滚一个 item 停 pauseDuration ms 再滚下一个,循环接续
|
||||
// 状态机: idle → scrolling(scrollTop 推到下一个 item 起点,scroll-with-animation=true) →
|
||||
// pausing(setTimeout(pauseDuration)) → scrolling(下一个) → ...
|
||||
// 跳回 0(末尾 → 开头):scrolling phase 立即把 scrollTop 设为 0(scroll-with-animation 关),视觉无感
|
||||
// 步进模式用 scroll-with-animation=true 是关键:每次推 scrollTop 是「滚一下」动画,不是直接跳
|
||||
//
|
||||
// 启动/暂停/恢复(两种模式共用):
|
||||
// - 启动:startAutoScroll 根据 stepPauseEnabled 分发
|
||||
// - 暂停:触摸时 stopAutoScroll,清除 resume timer
|
||||
// - 恢复:触摸结束 2s 后再 startAutoScroll
|
||||
// - 销毁:beforeDestroy 清 setTimeout
|
||||
_scrollTick() {
|
||||
// 模式 1 连贯滚动 tick
|
||||
var STEP = 0.4;
|
||||
this._scrollAccumulator += STEP;
|
||||
if (this._scrollAccumulator >= 1) {
|
||||
var inc = Math.floor(this._scrollAccumulator);
|
||||
this._scrollAccumulator -= inc;
|
||||
this.scrollTop += inc;
|
||||
// 滚到第一份底部,跳回 0
|
||||
if (this.firstListHeight > 0 && this.scrollTop >= this.firstListHeight) {
|
||||
this.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
// 下一帧
|
||||
var self = this;
|
||||
this.autoScrollTimer = setTimeout(function() {
|
||||
self._scrollTick();
|
||||
}, 16);
|
||||
},
|
||||
// 模式 2 步进滚动:推 scrollTop 到下一个 item 起点,停 pauseDuration,再推下一个,循环接续
|
||||
// 循环处理:list 末尾 → 跳回 0
|
||||
// 翻 _stepResetting = true → scrollWithAnimation computed 翻成 false →
|
||||
// $nextTick 等 scroll-view 接受新 prop → scrollTop = 0(无动画)→ 50ms 后翻回 → 继续推下一个
|
||||
// 状态机: idle → scrolling(scrollTop 推到下一个 item 起点,scroll-with-animation=true 走缓动) →
|
||||
// pausing(setTimeout(pauseDuration)) → scrolling(下一个) → ...
|
||||
_stepAdvance() {
|
||||
var offsets = this._itemOffsetArr;
|
||||
if (offsets.length === 0) {
|
||||
this._stepPhase = 'idle';
|
||||
return;
|
||||
}
|
||||
var n = offsets.length;
|
||||
var nextIndex = this._stepCurrentIndex + 1;
|
||||
var isLoop = false;
|
||||
if (nextIndex >= n) {
|
||||
// 末尾 → 跳回开头(无动画,scrollTop 设为 0)
|
||||
nextIndex = 0;
|
||||
isLoop = true;
|
||||
}
|
||||
this._stepCurrentIndex = nextIndex;
|
||||
this._stepPhase = 'scrolling';
|
||||
var self = this;
|
||||
if (isLoop) {
|
||||
// 翻 _stepResetting 让 scroll-with-animation=false,scrollTop 设为 0
|
||||
this._stepResetting = true;
|
||||
this.$nextTick(function() {
|
||||
self.scrollTop = 0;
|
||||
// 等 50ms 让 scroll-view 接受新位置,再翻回
|
||||
self.autoScrollTimer = setTimeout(function() {
|
||||
self._stepResetting = false;
|
||||
self._stepPhase = 'scrolling';
|
||||
self._stepAdvance();
|
||||
}, 50);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 正常推进:scrollTop 推到下一个 item 起点(scroll-with-animation=true 走 300-500ms 缓动)
|
||||
this.scrollTop = offsets[nextIndex];
|
||||
// 等动画结束 → 进入 pausing
|
||||
this.autoScrollTimer = setTimeout(function() {
|
||||
self._stepPhase = 'pausing';
|
||||
self.autoScrollTimer = setTimeout(function() {
|
||||
self._stepPhase = 'scrolling';
|
||||
self._stepAdvance();
|
||||
}, self.pauseDuration);
|
||||
}, this.stepAnimDuration);
|
||||
},
|
||||
startAutoScroll() {
|
||||
if (this.autoScrollTimer) return;
|
||||
if (!this.list || this.list.length <= 1) return;
|
||||
if (this.stepPauseEnabled) {
|
||||
// 模式 2 步进:从 item 0 开始,首次立刻滚到 item 1(scroll-with-animation=true 走缓动)
|
||||
this._stepCurrentIndex = 0;
|
||||
this._stepPhase = 'scrolling';
|
||||
this.scrollTop = 0;
|
||||
var self = this;
|
||||
// 首次立即推进(不预先 pause,符合「滚一下 + 停留一会」语义)
|
||||
this.autoScrollTimer = setTimeout(function() {
|
||||
self._stepAdvance();
|
||||
}, 16);
|
||||
} else {
|
||||
// 模式 1 连贯
|
||||
this._scrollAccumulator = 0;
|
||||
var self = this;
|
||||
this.autoScrollTimer = setTimeout(function() {
|
||||
self._scrollTick();
|
||||
}, 16);
|
||||
}
|
||||
},
|
||||
stopAutoScroll() {
|
||||
if (this.autoScrollTimer) {
|
||||
clearTimeout(this.autoScrollTimer);
|
||||
this.autoScrollTimer = null;
|
||||
}
|
||||
this._stepPhase = 'idle';
|
||||
},
|
||||
// 用户触摸 → 暂停自动滚动(touchend 2s 后由 onTouchEnd 恢复)
|
||||
onAutoScrollTouchStart() {
|
||||
this.stopAutoScroll();
|
||||
if (this.autoScrollResumeTimer) {
|
||||
clearTimeout(this.autoScrollResumeTimer);
|
||||
this.autoScrollResumeTimer = null;
|
||||
}
|
||||
},
|
||||
// 用户松手 → 2s 后恢复自动滚动(给用户阅读时间)
|
||||
// 步进模式:不重置 _stepCurrentIndex,从用户松手时所在 item 继续滚下一个
|
||||
// 连贯模式:正常重启 _scrollTick
|
||||
onAutoScrollTouchEnd() {
|
||||
if (this.autoScrollResumeTimer) clearTimeout(this.autoScrollResumeTimer);
|
||||
var self = this;
|
||||
this.autoScrollResumeTimer = setTimeout(function() {
|
||||
if (self.stepPauseEnabled) {
|
||||
// 步进模式:保留 _stepCurrentIndex,从 pausing 阶段继续
|
||||
self._stepPhase = 'pausing';
|
||||
self.autoScrollTimer = setTimeout(function() {
|
||||
self._stepPhase = 'scrolling';
|
||||
self._stepAdvance();
|
||||
}, self.pauseDuration);
|
||||
} else {
|
||||
// 连贯模式:正常启动
|
||||
self.startAutoScroll();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -515,37 +798,25 @@ export default {
|
||||
border-bottom-right-radius: var(--article-bottom-around-radius, 0);
|
||||
|
||||
.list-wrap {
|
||||
// 垂直滚动 swiper
|
||||
.auto-scroll-swiper {
|
||||
// ============ 自动垂直滚动(scroll-view 实现) ============
|
||||
// 设计目标:item 紧凑(自然高度) + 间距恒定(itemGap)
|
||||
// 实现方式:item 走基础 .item 规则的 margin-bottom: var(--article-item-gap),
|
||||
// 不再强制 min-height 让 item 用内容撑起,短 item 不会被拉高产生空白。
|
||||
// scrollTop 累加 + loopedList 复制一份实现无缝循环滚动。
|
||||
// vs 旧 swiper 方案:旧方案用 min-height 强制等高 + swiper-item-content padding-bottom 制造间距,
|
||||
// 导致短 item 下方空一大块(违反"紧凑"诉求);新方案天然紧凑。
|
||||
.auto-scroll-list {
|
||||
width: 100%;
|
||||
|
||||
// swiper-item-content:padding-bottom = itemGap 是制造 item 间恒定视觉间距的关键
|
||||
// 不管 item 自然高度多少,两条 item 之间的空白永远是 itemGap,
|
||||
// 而非「perRow - itemHeight」(后者随 item 高度变,间距不可控)。
|
||||
// 顶部不补 padding,避免与 item padding 叠加导致裁切。
|
||||
// 兜底:overflow:hidden 处理内容意外超过 maxItemHeight 的边界场景(防破坏布局)
|
||||
.swiper-item-content {
|
||||
padding-top: 0;
|
||||
padding-right: 0;
|
||||
padding-bottom: var(--article-item-gap, 16rpx);
|
||||
padding-left: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// swiper 内 item:
|
||||
// 1. margin-bottom: 0 — 间距由 .swiper-item-content 的 padding-bottom 承担,避免双重 margin
|
||||
// 2. min-height = maxItemHeight(动态 CSS 变量)— 强制所有 item 视觉高度统一,短 item 自然撑高
|
||||
// 3. align-items: flex-start — 覆盖默认 stretch,避免 info-wrap 被拉伸导致短内容被 space-between 拉开
|
||||
// 关键:loopedList 把 list 复制了一份,最后一条 .item(2N-1)也会被 :last-of-type 命中,
|
||||
// 导致其 margin-bottom: 0,而前面所有 item 是 itemGap。
|
||||
// 这会破坏无缝循环:scrollTop 跳回 0 时,用户看到「item 2N-1(无下间距)→ item 0(itemGap)」
|
||||
// 突然多出一截空白,视觉闪一下。
|
||||
// 修复:scroll-view 内所有 item 一视同仁,最后一条也保持 itemGap。
|
||||
.item {
|
||||
margin-bottom: 0 !important;
|
||||
min-height: var(--article-swiper-item-min-height, 200rpx);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// style-1 有图时,图片在 min-height item 内垂直居中(避免贴顶导致下方空一大块)
|
||||
.item .article-img {
|
||||
align-self: center;
|
||||
&:last-of-type {
|
||||
margin-bottom: var(--article-item-gap, 16rpx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +829,7 @@ export default {
|
||||
border-bottom-left-radius: var(--article-bottom-element-around-radius, 0);
|
||||
border-bottom-right-radius: var(--article-bottom-element-around-radius, 0);
|
||||
|
||||
// 静态列表(非滚动模式)最后一条不补间距
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user