scrollbar
首先 scrollbar 组件对外暴漏的接口如下:
props: {
// 是否采用原生滚动(即只是隐藏掉了原生滚动条,但并没有使用自定义的滚动条)
native: {
type: Boolean,
// 文件配置信息
default: scrollbar?.native ?? false,
},
// 自定义 wrap 容器的样式
wrapStyle: {
type: [String, Array],
default: '',
},
wrapClass: {
type: [String, Array],
default: '',
},
// 自定义 view 容器的样式
viewClass: {
type: [String, Array],
default: '',
},
viewStyle: {
type: [String, Array],
default: '',
},
// 如果 container 尺寸不会发生变化,最好设置它可以优化性能
noresize: Boolean,
// view 容器用那种标签渲染,默认为div
tag: {
type: String,
default: 'div',
},
},
最外层的 scrollbar 设置了 overflow:hidden,用来隐藏 wrap 中产生的浏览器原生滚动条。在 scrollbar 组件中的内容都将通过 slot 分发到 view 内部。
<!-- src/components/Scrollbar/src/Scrollbar.vue -->
<template>
<div class="scrollbar">
<!-- 生成 wrap 节点,并且给 wrap 绑定 scroll 事件 -->
<div
ref="wrap"
:class="[wrapClass, 'scrollbar__wrap', native ? '' : 'scrollbar__wrap--hidden-default']"
:style="style"
@scroll="handleScroll"
>
<!-- 生成 view 节点,并且将默认 slots 内容插入到 view 节点下 -->
<component :is="tag" ref="resize" :class="['scrollbar__view', viewClass]" :style="viewStyle">
<slot></slot>
</component>
</div>
<!-- 使用自定义的滚动条 -->
<template v-if="!native">
<bar :move="moveX" :size="sizeWidth" />
<bar vertical :move="moveY" :size="sizeHeight" />
</template>
</div>
</template>
<script lang="ts">
export default defineComponent({
name: 'Scrollbar',
setup (props) {
const sizeWidth = ref('0');
const sizeHeight = ref('0');
const moveX = ref(0);
const moveY = ref(0);
const wrap = ref();
const resize = ref();
// wrapStyle 传入样式的数据类型来处理 style
const style = computed(() => {
if (Array.isArray(props.wrapStyle)) {
return toObject(props.wrapStyle);
}
return props.wrapStyle;
});
},
})
</script>
在 onMounted 钩子中调用 update 方法对 sizeHeight、sizeWidth 进行初始化:
const update = () => {
if (!unref(wrap)) return;
// 计算 thumb 长度的百分比
const heightPercentage = (unref(wrap).clientHeight * 100) / unref(wrap).scrollHeight;
const widthPercentage = (unref(wrap).clientWidth * 100) / unref(wrap).scrollWidth;
// 设置 thumb 的 css 高度的百分比
// 当这个比值大于等于100,wrap.clientheight(容器高度)大于等于 wrap.scrollheight(滚动高度)时,不需要滚动条了,将 size 置为空字符串。
sizeHeight.value = heightPercentage < 100 ? heightPercentage + '%' : '';
sizeWidth.value = widthPercentage < 100 ? widthPercentage + '%' : '';
}
update 方法:thumb 在 track 中上下滚动,可滚动区域 view 在可视区域 wrap 中上下滚动,可以将 thumb 和 track 的这种相对关系看作是 wrap 和 view 相对关系的一个 微缩模型 (微缩反应),而滚动条的意义就是用来反映 view 和 wrap 的这种相对运动关系的。即:wrap.clientheight / wrap.scrollheight = thumb.clientheight / track.clientheight。
// 滚动条滚动位置的更新
const handleScroll = () => {
if (!props.native) {
moveY.value = (unref(wrap).scrollTop * 100) / unref(wrap).clientHeight;
moveX.value = (unref(wrap).scrollLeft * 100) / unref(wrap).clientWidth;
}
}
handleScroll 函数逻辑所在:wrap.scrolltop = wrap.clientheight 时,thumb 应该向下滚动它自身长度的距离,也就是transform: translateY(100%)。即当 wrap 滚动的时候,thumb 应该向下滚动的距离正好是 transform: translateY(wrap.scrolltop / wrap.clientheight )。
import { onMounted, onBeforeUnmount } from 'vue';
onMounted(() => {
if (props.native) return;
nextTick(update);
if (!props.noresize) {
addResizeListener(unref(resize), update);
addResizeListener(unref(wrap), update);
addEventListener('resize', update);
}
})
onBeforeUnmount(() => {
if (props.native) return;
if (!props.noresize) {
removeResizeListener(unref(resize), update);
removeResizeListener(unref(wrap), update);
removeEventListener('resize', update);
}
})
对于使用自定义的滚动条,若容器尺寸有变化的,我们分别在 onMounted 和 onBeforeUnmount 生命周期内,添加监听 resize 事件和移出事件等。
bar
bar 组件:右侧 track 是滚动条的滚动滑块、thumb 上下滚动的轨迹吧,并分别绑定了onmousedown事件。
bar 组件对外暴漏的接口如下:
props: {
vertical: Boolean, // 当前bar组件是否为垂直滚动条
size: String, // 百分数,当前bar组件的thumb长度 / track长度的百分比
move: Number, // 滚动条向下/向右发生transform: translate的值
},
自定义滚动条渲染 dom 信息。
import { defineComponent, h, computed, ref, getCurrentInstance, onUnmounted, inject, Ref } from 'vue'
import { on, off } from '/@/utils/domUtils'
export default defineComponent({
name: 'Bar',
setup(props) {
const instance = getCurrentInstance();
const thumb = ref();
const barStore = ref<Recordable>({});
const cursorDown = ref();
const wrap = inject('scroll-bar-wrap', {} as Ref<Nullable<HTMLElement>>) as any;
return () =>
h(
'div',
{
class: ['scrollbar__bar', 'is-' + bar.value.key],
onMousedown: clickTrackHandler,
},
h('div', {
ref: thumb,
class: 'scrollbar__thumb',
onMousedown: clickThumbHandler,
style: renderThumbStyle({
size: props.size,
move: props.move,
bar: bar.value,
}),
}),
);
}
})
通过 renderthumbstyle 转化为 trumb 的样式 transform: translatex( m o v e X {moveX}%) / transform: translatey( moveX{moveY}%) ,来生成 thumb,并且给 track 和 thumb 分别绑定了 onmousedown 事件。
const clickThumbHandler = () => {
if (e.ctrlKey || e.button === 2) {
return;
}
window.getSelection() ?.removeAllRanges();
startDrag(e);
// 记录this.y , this.y = 鼠标按下点到thumb底部的距离
// 记录this.x , this.x = 鼠标按下点到thumb左侧的距离
barStore.value[bar.value.axis] =
e.currentTarget[bar.value.offset] -
(e[bar.value.client] - e.currentTarget.getBoundingClientRect()[bar.value.direction]);
}
// 开始拖拽
const startDrag = () => {
e.stopImmediatePropagation();
// 标识位, 标识当前开始拖拽
cursorDown.value = true;
// 绑定 mousemove 和 mouseup 事件
on(document, 'mousemove', mouseMoveDocumentHandler);
on(document, 'mouseup', mouseUpDocumentHandler);
// 解决拖动过程中页面内容选中的bug
document.onselectstart = () => false;
}
const mouseMoveDocumentHandler = () => {
// 判断是否在拖拽过程中
if (cursorDown.value === false) return;
// 刚刚记录的this.y(this.x) 的值
const prevPage = barStore.value[bar.value.axis];
if (!prevPage) return;
// 鼠标按下的位置在 track 中的偏移量,即鼠标按下点到 track 顶部(左侧)的距离
const offset = (instance ?.vnode.el ?.getBoundingClientRect()[bar.value.direction] - e[bar.value.client]) * -1;
// 鼠标按下点到 thumb 顶部(左侧)的距离
const thumbClickPosition = thumb.value[bar.value.offset] - prevPage;
// 当前thumb顶部(左侧)到track顶部(左侧)的距离,即thumb向下(向右)偏移的距离 占track高度(宽度)的百分比
const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / instance ?.vnode.el ?.[bar.value.offset];
// wrap.scrollheight / wrap.scrollleft * thumbpositionpercentage 得到 wrap.scrolltop / wrap.scrollleft
// 当 wrap.scrolltop(wrap.scrollleft) 发生变化时,会触发父组件 wrap 上绑定的 onscroll 事件,
// 从而重新计算movex/movey的值,这样 thumb 的滚动位置就会重新渲染
wrap.value[bar.value.scroll] = (thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100;
}
function mouseUpDocumentHandler() {
// 当拖动结束,将标识位设为false
cursorDown.value = false;
// 将上一次拖动记录的this.y(this.x)的值清空
barStore.value[bar.value.axis] = 0;
// 取消页面绑定的 mousemove 事件
off(document, 'mousemove', mouseMoveDocumentHandler);
// 清空 onselectstart 事件绑定的函数
document.onselectstart = null;
}
thumb 滚动条拖拽处理逻辑:在拖拽 thumb 的过程中,动态的计算 thumb 顶部(左侧)到 track 顶部(左侧)的距离占 track 本身高度(宽度)的百分比,然后利用这个百分比动态改变 wrap.scrolltop 的值,从而触发页面滚动以及滚动条位置的重新计算,实现滚动效果。
const clickTrackHandler = () => {
const offset = Math.abs(
e.target.getBoundingClientRect()[bar.value.direction] - e[bar.value.client],
);
const thumbHalf = thumb.value[bar.value.offset] / 2;
const thumbPositionPercentage =
((offset - thumbHalf) * 100) / instance ?.vnode.el ?.[bar.value.offset];
wrap.value[bar.value.scroll] =
(thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100
}
需要注意一下两点:
- track 的 onmousedown 事件回调中不会给页面绑定 mousemove 和 mouseup 事件,因为 track 相当于 click 事件。
- 计算 thumb 顶部到 track 顶部的方法是:用鼠标点击点到 track 顶部的距离减去 thumb 的二分之一高度,因为点击 track 之后,thumb 的中点刚好要在鼠标点击点的位置。
完整代码如下:
// src/components/Scrollbar/src/bar.ts
import { defineComponent, h, computed, ref, getCurrentInstance, onUnmounted, inject, Ref } from 'vue'
import { on, off } from '/@/utils/domUtils'
export default defineComponent({
name: 'Bar',
setup(props) {
const instance = getCurrentInstance();
const thumb = ref();
const barStore = ref<Recordable>({});
const cursorDown = ref();
const wrap = inject('scroll-bar-wrap', {} as Ref<Nullable<HTMLElement>>) as any;
// 滚动条信息
const bar = computed(() => {
return BAR_MAP[props.vertical ? 'vertical' : 'horizontal'];
});
const clickThumbHandler = () => {
if (e.ctrlKey || e.button === 2) {
return;
}
window.getSelection()?.removeAllRanges();
startDrag(e);
// 记录this.y , this.y = 鼠标按下点到thumb底部的距离
// 记录this.x , this.x = 鼠标按下点到thumb左侧的距离
barStore.value[bar.value.axis] =
e.currentTarget[bar.value.offset] -
(e[bar.value.client] - e.currentTarget.getBoundingClientRect()[bar.value.direction]);
}
// track 的 onmousedown 事件回调中不会给页面绑定 mousemove 和 mouseup 事件,因为 track 相当于 click 事件。
const clickTrackHandler = () => {
const offset = Math.abs(
e.target.getBoundingClientRect()[bar.value.direction] - e[bar.value.client],
);
const thumbHalf = thumb.value[bar.value.offset] / 2;
// 用鼠标点击点到 track 顶部的距离减去thumb的二分之一高度,点击 track 之后,thumb 的中点刚好要在鼠标点击点的位置。
const thumbPositionPercentage =
((offset - thumbHalf) * 100) / instance?.vnode.el?.[bar.value.offset];
wrap.value[bar.value.scroll] =
(thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100
}
// 开始拖拽
const startDrag = () => {
e.stopImmediatePropagation();
// 标识位, 标识当前开始拖拽
cursorDown.value = true;
// 绑定 mousemove 和 mouseup 事件
on(document, 'mousemove', mouseMoveDocumentHandler);
on(document, 'mouseup', mouseUpDocumentHandler);
// 解决拖动过程中页面内容选中的bug
document.onselectstart = () => false;
}
const mouseMoveDocumentHandler = () => {
// 判断是否在拖拽过程中
if (cursorDown.value === false) return;
// 刚刚记录的this.y(this.x) 的值
const prevPage = barStore.value[bar.value.axis];
if (!prevPage) return;
// 鼠标按下的位置在 track 中的偏移量,即鼠标按下点到 track 顶部(左侧)的距离
const offset = (instance?.vnode.el?.getBoundingClientRect()[bar.value.direction] - e[bar.value.client]) * -1;
// 鼠标按下点到 thumb 顶部(左侧)的距离
const thumbClickPosition = thumb.value[bar.value.offset] - prevPage;
// 当前thumb顶部(左侧)到track顶部(左侧)的距离,即thumb向下(向右)偏移的距离 占track高度(宽度)的百分比
const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / instance?.vnode.el?.[bar.value.offset];
// wrap.scrollheight / wrap.scrollleft * thumbpositionpercentage 得到 wrap.scrolltop / wrap.scrollleft
// 当 wrap.scrolltop(wrap.scrollleft) 发生变化时,会触发父组件 wrap 上绑定的 onscroll 事件,
// 从而重新计算movex/movey的值,这样 thumb 的滚动位置就会重新渲染
wrap.value[bar.value.scroll] = (thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100;
}
function mouseUpDocumentHandler() {
// 当拖动结束,将标识位设为false
cursorDown.value = false;
// 将上一次拖动记录的this.y(this.x)的值清空
barStore.value[bar.value.axis] = 0;
// 取消页面绑定的 mousemove 事件
off(document, 'mousemove', mouseMoveDocumentHandler);
// 清空 onselectstart 事件绑定的函数
document.onselectstart = null;
}
onUnmounted(() => {
// 取消页面绑定的 mouseup 事件
off(document, 'mouseup', mouseUpDocumentHandler)
})
return () =>
h(
'div',
{
class: ['scrollbar__bar', 'is-' + bar.value.key],
onMousedown: clickTrackHandler,
},
h('div', {
ref: thumb,
class: 'scrollbar__thumb',
onMousedown: clickThumbHandler,
// 将它转化为trumb 的样式 transform: translatex(${moveX}%) / transform: translatey(${moveY}%)
style: renderThumbStyle({
size: props.size,
move: props.move,
bar: bar.value,
}),
}),
);
}
})
滚动容器 ScrollContainer
ScrollContainer 组件对外提供 scrollTo、scrollBottom 方法,通过该方法可以指定滚动条滚动的位置。
<!-- src/components/Container/src/ScrollContainer.vue -->
<template>
<Scrollbar ref="scrollbarRef" class="scroll-container" v-bind="$attrs">
<slot></slot>
</Scrollbar>
</template>
<script lang="ts">
import { defineComponent, ref, unref, nextTick } from 'vue'
import { Scrollbar } from '/@/components/Scrollbar';
import { useScrollTo } from '/@/hooks/event/useScrollTo';
export default defineComponent({
name: 'ScrollContainer',
components: { Scrollbar },
setup() {
const scrollbarRef = ref(null)
function getScrollWrap () {
const scrollbar = unref(scrollbarRef)
if (!scrollbar) {
return null
}
return scrollbar.wrap
}
// 滚动到指定位置
function scrollTo (to: Number, duration = 500) {
const scrollbar = unref(scrollbarRef)
if (!scrollbar) {
return
}
nextTick(() => {
const wrap = unref(scrollbar.wrap)
if (!wrap) {
return
}
const { start } = useScrollTo({ el: wrap, to, duration })
start()
})
},
// 滚动到底部
function scrollBottom () {
const scrollbar = unref(scrollbarRef)
if (!scrollbar) {
return
}
nextTick(() => {
const wrap = unref(scrollbar.wrap)
if (!wrap) {
return
}
const scrollHeight = wrap.scrollHeight
const { start } = useScrollTo({ el: wrap, to: scrollHeight })
start()
})
}
return {
scrollbarRef,
scrollTo,
scrollBottom,
getScrollWrap,
}
},
})
</script>
useScrollTo 实现
useScrollTo Hook 添加滚动动画效果。
// src/hooks/event/useScrollTo.ts
// 当前位置
const position = (el: HTMLElement) => {
return el.scrollTop
}
// 滚动至
const move = (el: HTMLElement, amount: number) => {
el.scrollTop = amount
}
// 缓入缓出
const easeInOutQuad = (t: number, b: number, c: number, d: number) => {
t /= d / 2;
if (t < 1) {
return (c / 2) * t * t + b;
}
t--;
return (-c / 2) * (t * (t - 2) - 1) + b;
}
export function useScrollTo({ el, to, duration = 500, callback}) {
// 滚动标识
const isActiveRef = ref(false)
const start = position(el)
const change = to - start
const increment = 20
let currentTime = 0
duration = isUnDef(duration) ? 500 : duration
// 滚动动画
const animateScroll = function () {
if (!unref(isActiveRef)) {
return
}
currentTime += increment
const val = easeInOutQuad(currentTime, start, change, duration)
move(el, val)
if (currentTime < duration && unref(isActiveRef)) {
// 节流: 每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。
requestAnimationFrame(animateScroll)
} else {
if (callback && isFunction(callback)) {
callback({ currentTime, start, change, duration })
}
}
}
const run = () => {
isActiveRef.value = true
animateScroll()
}
const stop = () => {
isActiveRef.value = false
}
return { start: run, stop }
}
使用案例
<template>
<ScrollContainer class="mt-4" ref="scrollRef">
<template v-for="index in 100" :key="index">
<p class="p-2" :style="{ border: '1px solid #eee' }"> {{ index }} </p>
</template>
</ScrollContainer>
</template>
<script lang="ts">
import { defineComponent, ref, unref } from 'vue';
export default defineComponent({
setup () {
const scrollRef = ref(null)
const getScroll = () => {
const scroll = unref(scrollRef)
if (!scroll) {
throw new Error('scroll is Null')
}
return scroll
}
function scrollTo(top: Number) {
getScroll().scrollTo(top)
}
function scrollBottom () {
getScroll().scrollBottom()
}
return { scrollRef, scrollTo, scrollBottom };
}
})
</script>
参考地址: