基于vueuse的扇形虚拟滚动容器
在开发Nuxt Content博客的时候,我注意到这个扇形滚动容器实际上是很实用的,至少是数学层面上,对我这个数学白痴来说,过了这个路基本不可能再写出来,所以决定写下这个笔记来记录一下扇形容器和虚拟滚动容器。
一般虚拟滚动容器需要拆分为4个问题:
- 总内容高度是多少
- 当前滚动到了哪里
- 哪些item进入了可视区域
- 这些item应该放在什么位置
10000 个 item
每个 item 高度 50px
总高度:
10000 × 50 = 500000px
但实际上浏览器不需要创建10000个DOM
假设viewport高度为600px,那么理论上只需要600/ 15 = 20个,再加上一些上下缓冲,例如:buffer = 5,最后可能只会渲染22个item。
核心数学关系
我们假设itemHeight高度为50,scrollTop为2370,那么第一个进入viewport附近的item是:
floor(scrollTop / itemHeight) ,差不多就是47,如果viewport高度是600,那么可视的item数量就是12个。如果考虑上buffer,那么:
renderStart = max(0, 47 - 5)
= 42
renderEnd = 47 + 12 + 5
= 64
所以真正从数据里取
items[42 ... 64]
这样的话浏览器会认为容器就只有23 x 50这样高度。用户就无法滚到后面的item,虚拟滚动容器就会制造一个虚拟内容高度,结构类似于:
<div class="viewport">
<div class="content">
<!-- 实际只放 23 个 item -->
</div>
</div>
content设置height500000px都没有问题,这样scroll会认为10000x50是真实存在的,然后真正的item会通过 transform去移动到正确位置。
Vuseuse提供的方法
vueuse这里提供了一个很好用的方法,这是我们组件的基底:useVirtualList(),这是vueuse提供的一套scroll position -> virtual items响应式计算逻辑。
const {
list,
containerProps,
wrapperProps
} = useVirtualList(...)
VueUse 常见写法大概是:
const {
list,
containerProps,
wrapperProps,
} = useVirtualList(items, {
itemHeight: 50,
overscan: 5,
})
list是当前应该渲染的数据,containerProps是外层滚动容器应该具备的属性,wrapperProps是内层虚拟容器应该具备的属性。
<div v-bind="containerProps">
<div v-bind="wrapperProps">
<!-- 实际只有几十个 item -->
</div>
</div>
组件设计
我的虚拟滚动容器是一个扇形滚动容器,但是比较好地分离了扇形逻辑和虚拟滚动组件容器,所以组合起来使用还是比较方便地。
/** 页面滚动进度 0~1 */
const pageProgress = computed(() => {
if (typeof document === 'undefined') return 0
// viewportH 是响应式的,窗口缩放会触发重算;scrollHeight 每次现读,
// 页面内容变高后(懒加载图片、字体回填)下一次滚动就会自动修正。
const max = document.documentElement.scrollHeight - viewportH.value
if (max <= 0) return 0
return Math.min(1, Math.max(0, windowY.value / max))
})
/** 整程页面对应列表里的多少个条目(0 = 整份列表) */
const span = computed(() =>
props.windowSpan > 0 ? props.windowSpan : Math.max(0, props.items.length - 1),
)
我们通过scrollHeight - 视口高度值就可以得到滚动进度,这个是个很常见地表达。windowSpan不是dom窗口宽度而是整个页面的滚动距离,对应列表中的多少个条目,假设items有100个,windowSpan是20个,那么对照起来就是这样:
页面位置 列表 progress
顶部 0% → 0
25% → 5
50% → 10
75% → 15
底部 100% → 20
通过span,我们就可以根据pageProgress的进度来换算前位索引:
/** 由页面进度直接换算出的「前位」索引 —— 绝对值,不是增量 */
const windowIndex = computed(() => pageProgress.value * span.value)
/**
* 窗口模式下把轨道同步到前位:直接写绝对值(不是增量)。
* useVirtualList 只认 container 的 scrollTop,同步过去它才取得到正确的窗口。
* 依据 CSSWG css-overflow:overflow:hidden 的滚动容器「滚轮不滚、脚本可滚」。
*/
watchEffect(() => {
if (props.driver !== 'window') return
const el = scrollerRef.value
if (!el) return
const next = windowIndex.value * props.itemHeight
if (Math.abs(el.scrollTop - next) > 0.5) el.scrollTop = next
})
这里有两种驱动模式,一个是跟随window的进度,一个是像是普通的容器那样跟随鼠标滚轮滑动。这里主要看第二种,我们通过scrollTop的高度 / item的高度就可以得到个数,也就是索引。这里的观察者模式用于把scrollerRef的scrollTop强制同步到windowIndex所代表的位置。
/**
* 容器 props。窗口模式下换成 overflow:hidden:
* 它仍然是可以被脚本写 scrollTop 的滚动容器("用户不可滚、脚本可滚"),
* 所以页面滚动能独占这条轨道。
*
* 注意 overscroll-behavior 必须跟着一起放开:实测(Chrome)
* 「overflow:hidden + overscroll-behavior:contain」会把滚轮整块吞掉 ——
* 轨道不滚、页面也不滚,光标停在扇形上就彻底卡住。
*/
const scrollerProps = computed(() => {
const { style, ...rest } = containerProps
const base = (typeof style === 'object' && style !== null ? style : {}) as Record<string, unknown>
const windowed = props.driver === 'window'
return {
...rest,
style: {
...base,
overflowY: windowed ? 'hidden' : 'auto',
overscrollBehavior: windowed ? 'auto' : 'contain',
},
}
})
/**
* 轨道 scrollTop 的上限 = 总高 − 容器高,不补留白的话末尾
* 「容器高 / itemHeight」条永远到不了前位。
*/
const endPadding = computed(() => {
if (typeof props.endPadding === 'number') return Math.max(0, props.endPadding)
if (props.endPadding === 'container') return boxH.value
return props.driver === 'window' ? boxH.value : 0
})
同一个scroller再self模式下应该让用户滚动,window模式下不让用户滚动,和上一段代码结合起来看就知道这样设计的目的,window滚动以后,内部的scroller的位置也要同步过去并禁止用户直接滚动内部scroller。
function scrollToIndex(index: number, behavior: ScrollBehavior = 'auto') {
if (behavior === 'auto') {
scrollTo(index)
return
}
const el = scrollerRef.value
if (!el) return
el.scrollTo({ top: index * props.itemHeight, behavior })
}
这端函数就是这个组件对外暴露的滚动到第几个item的封装,根据behavoir分为了两个模式,index是列表索引,behavoir是滚动方式,auto模式会走scrollTo,这个是通过useVirtualLsit得到的,这里可以直接复用它提供的滚动方法,这样我们就不用自己重复实现vueuse已经提供的index->scrollTo的转换了。那么非auto需要自己调用el.scrollTo,原生DOM提供了浏览器的平滑滚动的能力。
扇形组件
到刚才那一步,FanVirtualList的实现机制也就差不多了,接下来该看useFanArc.ts了,扇形容器十分依赖这个方法:
export function useFanArc(options: FanArcOptions) {
/** 每条跨越的圆心角(弧度)—— 「固定角度」就来自这里 */
const stepRad = computed(() => toValue(options.itemHeight) / toValue(options.radius))
const stepDeg = computed(() => (stepRad.value * 180) / Math.PI)
const maxAngleRad = computed(() => (toValue(options.maxAngle) * Math.PI) / 180)
这里面的三个属性主要是解决相邻item再圆弧上差的角度,扇形最多允许张开多少角度。stepRad是一个item占的弧度,这里可以用园弧长公式:s=rθ 所以:θ=rs。这里s = itemHeight,r=radius,所以可以得到stepRad。stepDeg可以把弧度转换成角度,css中rotate使用的是deg,数学计算中sin/cos使用的正是弧度。
radius
│
↓
itemHeight ───→ stepRad ───→ 每个 item 的角度间隔
│
↓
angleOf(index)
│
↓
theta
│
┌─────────────┴─────────────┐
↓ ↓
sin/cos maxAngleRad
↓ ↓
x / y visible
然后
function angleOf(index: number) {
return (toValue(options.progress) - index) * stepRad.value
}
function distanceOf(index: number) {
const max = maxAngleRad.value
if (max <= 0) return 1
return Math.min(1, Math.abs(angleOf(index)) / max)
}
这里实际上就是通过当前item距离前卫几个位置 x 每个位置对应的弧度,就可以得到角度了。distanceof计算某个item离前卫有多远,并把这个距离归一化成0~1。
到这里,还需要一个真正把角度换算成屏幕上x/y左边的方法:
function positionOf(index: number): FanArcPosition {
const theta = angleOf(index)
const r = toValue(options.radius)
// 圆心在「锚点向右 r」处:theta = 0 时条目正好落在锚点,向两端张开并轻微右凸
const x = toValue(options.anchorX) + r * (1 - Math.cos(theta))
const y = toValue(options.anchorY) - r * Math.sin(theta)
const thetaDeg = (theta * 180) / Math.PI
const orientation = toValue(options.orientation)
const rot =
orientation === 'upright' ? 0 : orientation === 'radial' ? thetaDeg - 90 : thetaDeg
return {
x,
y,
rot,
theta,
thetaDeg,
visible: Math.abs(theta) <= maxAngleRad.value,
distance: distanceOf(index),
}
}
拿到item角度之后取半径,这里的r是圆的半径,anchorX和anchorY不是圆心:
假设
anchorX = 100
anchorY = 300
radius = 200
那么圆心就在:
(100 + 200, 300)
= (300, 300)
圆心
●
│
│ 200
│
前位锚点 │
●─────────────────●
(100,300) (300,300)
然后计算得到x和y,transformOf方法会将x和y取出来,这里是x和y第一次被使用:
function transformOf(index: number) {
const { x, y, rot } = positionOf(index)
return `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) translate(-50%, -50%) rotate(${rot.toFixed(2)}deg)`
}
function itemStyle(index: number) {
const pos = positionOf(index)
if (!pos.visible) return { display: 'none' }
return {
transform: transformOf(index),
opacity: String(1 - toValue(options.fade) * pos.distance),
zIndex: String(1000 - Math.round(Math.abs(pos.thetaDeg))),
}
}
像
x = 126.79
y = 200
rot = 30
最后生成的css是
transform:
translate3d(126.79px, 200px, 0)
translate(-50%, -50%)
rotate(30deg);
itemStyle又调用transformOf,const pos...这里又拿了一次x/y,后面的transform: transforOf(index)又拿了一次,所以实际上同一个index 的positionOf会算两次,两次的结果应该是一样的,因为输入没有在中间改变。
<template>
<div ref="root" class="fan-list" :data-driver="driver">
<div v-bind="scrollerProps" class="fan-list__scroller">
<!-- 视觉层:sticky 钉在滚动视口上,条目用 transform 摆到弧上 -->
<div class="fan-list__stage" :style="{ height: `${boxH}px` }">
<div
v-for="entry in list"
:key="entry.index"
class="fan-list__item"
:style="arc.itemStyle(entry.index)"
>
<slot
:item="entry.data"
:index="entry.index"
:active="Math.abs(progress - entry.index) < 0.5"
:distance="arc.distanceOf(entry.index)"
/>
</div>
</div>
<!-- 占位层:只负责撑出滚动高度(vueuse 自己维护 marginTop / height) -->
<div v-bind="wrapperProps" />
<!--
末尾留白:让最后的条目也能滚到前位(见脚本里 endPadding 的注释)。
-->
<div
v-if="endPadding > 0"
class="fan-list__tail"
:style="{ height: `${endPadding}px` }"
/>
</div>
</div>
</template>
到这里我们就已经实现了一个扇形的容器,而且是一个带虚拟滚动的扇形容器。END。 食用方法如下:
<script setup lang="ts">
import { computed } from 'vue'
import { useContentStore } from '~/stores/content'
import FanVirtualList from '~/components/FanVirtualList.vue'
import RightRail from '~/components/RightRail.vue'
import { canonicalPath } from '~/utils/path'
const store = useContentStore()
const route = useRoute()
const items = computed(() => store.summaries)
const currentPath = computed(() => canonicalPath(route.path))
function isActive(path: string) {
return currentPath.value === path
}
</script>
<template>
<RightRail>
<FanVirtualList
class="fan"
:items="items"
driver="window"
:item-height="56"
:radius="520"
:max-angle="40"
:fade="0.85"
orientation="tangent"
:anchor-x="0.62"
:anchor-y="0.5"
>
<template #default="{ item, active }">
<NuxtLink
:to="item.path"
class="doc-link hidden truncate md:block"
:class="{ 'doc-link--front': active, 'doc-link--active': isActive(item.path) }"
>
{{ item.title }}
</NuxtLink>
</template>
</FanVirtualList>
</RightRail>
</template>
<style lang="scss" scoped>
.fan {
height: min(100vh, 34rem);
}
.doc-link {
width: 8rem;
padding: 0.45rem 0.75rem;
border: 1px solid transparent;
border-radius: 0.5rem;
font-size: 0.8125rem;
line-height: 1.15rem;
text-decoration: none;
box-shadow: 0 0 0 0 transparent;
}
.doc-link--front {
box-shadow: 0 6px 18px -8px rgb(0 0 0 / 0.35);
}
.doc-link--active {
font-weight: 600;
}
</style>