CSS & JS Effect – 用 wheel 模拟 scroll

前言

在 用 JavaScript 实现 position sticky 文章中,我提到了用 wheel 来模拟 scroll 效果。

这篇来说说具体怎么实现,挺简单的哦。

Preparation

table.html

First Name Last Name Age Address Email Phone City Country Occupation Salary
John Doe 30 123 Main St john.doe@example.com 123-456-7890 New York USA Software Engineer $80,000
Jane Smith 25 456 Elm St jane.smith@example.com 987-654-3210 Los Angeles USA Graphic Designer $60,000
Michael Johnson 35 789 Oak St michael.johnson@example.com 456-789-0123 Chicago USA Teacher $50,000
Sarah Williams 28 321 Pine St sarah.williams@example.com 789-012-3456 Miami USA Accountant $70,000
David Brown 40 654 Cedar St david.brown@example.com 210-987-6543 Houston USA Engineer $90,000
Emily Miller 33 987 Maple St emily.miller@example.com 567-890-1234 Seattle USA Manager $100,000
James Wilson 27 753 Walnut St james.wilson@example.com 890-123-4567 San Francisco USA Marketing Specialist $75,000
Emma Anderson 29 159 Birch St emma.anderson@example.com 234-567-8901 Boston USA Consultant $85,000
Christopher Lee 32 852 Oakwood St christopher.lee@example.com 678-901-2345 Atlanta USA Lawyer $120,000
Olivia Clark 26 357 Elmwood St olivia.clark@example.com 123-456-7890 Denver USA Artist $55,000
William White 31 951 Cedarwood St william.white@example.com 456-789-0123 Phoenix USA Architect $95,000
Ava Hall 34 246 Pinecrest St ava.hall@example.com 789-012-3456 Dallas USA Financial Analyst $80,000
Alexander Young 29 753 Maplewood St alexander.young@example.com 210-987-6543 Philadelphia USA Real Estate Agent $70,000
Mia Scott 38 852 Oak St mia.scott@example.com 567-890-1234 Minneapolis USA Doctor $150,000
Ethan Adams 27 369 Walnut St ethan.adams@example.com 890-123-4567 Portland USA Journalist $65,000
Isabella Carter 30 147 Pine St isabella.carter@example.com 234-567-8901 Detroit USA Entrepreneur $200,000
Logan Green 31 258 Elm St logan.green@example.com 678-901-2345 San Diego USA Engineer $90,000
Amelia Roberts 29 369 Cedar St amelia.roberts@example.com 123-456-7890 Charlotte USA Designer $70,000
Benjamin Hill 35 741 Oakwood St benjamin.hill@example.com 456-789-0123 San Antonio USA Manager $100,000
Charlotte Adams 33 852 Maple St charlotte.adams@example.com 789-012-3456 Orlando USA Software Developer $85,000
Gabriel Cook 28 159 Pinecrest St gabriel.cook@example.com 210-987-6543 Tampa USA Writer $60,000

View Code

table.scss

.container {

max-height: 256px;

overflow-y: auto;

max-width: 768px;

margin-inline: auto;

}

table {

border-spacing: 0;

margin-inline: auto;

th,

td {

border: 1px solid black;

}

:is(th, td):nth-child(n + 2) {

border-left: unset;

}

td {

border-top: unset;

}

thead {

tr {

background-color: white;

}

th {

padding: 16px;

}

}

td,

th {

padding: 16px;

min-width: 250px;

max-width: 250px;

}

}

View Code

实现原理

监听 wheel 事件,会得到一个 WheelEvent 对象。

它里面有一个 deltaY 属性,我们 wheel 一下,这个 deltaY 会是 100 或 -100。

positive 表示 scroll down,negative 表示 scroll up。

100 是游览器设定的一下 wheel 要移动多少 scrollTop。

轻轻 wheel 一下就是 scrollTop += 100

如果快速 wheel 几下,这个 deltaY 不一定是 100,有可能是 200 甚至 300。

也就是说 wheel 的越快越多,移动的 scrollTop 越大。这是游览器的交互体验。

我们监听 wheel 然后 update scrollTop 就可以了。如果要体验好,就加入 animation,让它 smooth 一点。

具体实现代码

table.ts

我用了 RxJS,不熟悉的朋友可以参考:RxJS 系列

const container = document.querySelector('.container')!;

// 监听 wheel 事件

const wheel$ = fromEvent(container, 'wheel').pipe(share());

// preventDefault body scroll,因为我们要控制的是 div scroll

wheel$.subscribe(e => e.preventDefault());

// 从 event 取出 deltaY

const deltaY$ = wheel$.pipe(map(e => e.deltaY)).pipe(share());

// 区分出 scroll up 和 scroll down

const [scrollUp$, scrollDown$] = partition(deltaY$, deltaY => deltaY < 0);

// for loop subscribe scroll$

for (const scroll$ of [scrollUp$, scrollDown$]) {

scroll$

.pipe(

// 下面 scroll 指的是 要 scrollTop 多少

// 轻轻 wheel 一下,scrollPerWheel 是 100

// 快快 wheel 的话,scrollPerWheel 可能会去到 200, 300

mergeMap(scrollPerWheel => {

// 如果是 scroll up,scrollPerWheel 会是 negative,我们为了统一算法,把它变成 positive 会比较方便

if (scroll$ === scrollUp$) scrollPerWheel *= -1;

// 150ms 内要完成 scroll

const duration = 150;

// 每一 ms 要 scroll 多少?

const scrollPerMillisecond = scrollPerWheel / duration;

return animationFrames().pipe(

// animationFrames 就是递归调用 requestAnimatonFrame

// elapsed 是一个累加的 ms

map(e => e.elapsed),

startWith(0),

pairwise(),

// 通过 current elapsed 减去 previous elapsed 就可以直到这一次的 requestAnimatonFrame 间隔多少时间

// 游览器 requestAnimatonFrame 通常间隔是在 16ms 左右,但也不太准,所以我们还是得准确算一下

map(([prev, curr]) => curr - prev),

scan(

({ totalScroll }, animationInterval) => {

// 每 16ms 左右我们就会 scroll 一点点

// 一直到 scroll 到 100px 就停

// remainingScroll 就是一个从 100 一直累减到 0 的记入

const remainingScroll = scrollPerWheel - totalScroll;

// 计算这一次要 scroll 多好

const scroll = limitMax(Math.ceil(animationInterval * scrollPerMillisecond), remainingScroll);

// totalScroll 则是已经 scroll 了多少

return { totalScroll: totalScroll + scroll, lastScroll: scroll };

},

{ totalScroll: 0, lastScroll: 0 },

),

// 判断 totalScroll 满了就停

takeWhile(({ totalScroll }) => totalScroll !== scrollPerWheel, true),

// 如果是 scroll up 要把它转换回 negative

map(e => (scroll$ === scrollDown$ ? e.lastScroll : e.lastScroll * -1)),

// 150ms 内如果用户反方向 wheel 就立刻停止以前方向的 scroll

takeUntil(scroll$ === scrollDown$ ? scrollUp$ : scrollDown$),

);

}),

)

// 每一次修改 scrollTop

.subscribe(scroll => (container.scrollTop += scroll));

}

效果

和原生的不会差太远,够用。

如果还想加入 overscroll 概念,可以添加一个 targetScrollElement$,它会决定要 scroll 哪一个 element (child to ancestor)

// 当用户停止 wheel 之后的第一个 wheel 做检查

// 这里使用 debounceTime 300ms 来等待用户停止 wheel

const targetScrollElement$ = wheel$.pipe(debounceTime(300), startWith(null)).pipe(

switchMap(() => {

return wheel$.pipe(

map(e => {

const upOrDown = e.deltaY > 0 ? 'Down' : 'Up';

// scrollElements 是 child to ancestor element

return scrollElements.find((scrollElement, index) => {

// 如果已经是最后一个 element 直接返回就好,总要有人可以 scroll 嘛

if (index === scrollElements.length - 1) return true;

// 如果要 scroll up 同时还没有 scroll 到顶就可以 scroll 这个 element

if (upOrDown === 'Up' && !reachedTop(scrollElement)) return true;

// 如果要 scroll down 同时还没有 scroll 到底就可以 scroll 这个 element

if (upOrDown === 'Down' && !reachedBottom(scrollElement)) return true;

// 不可以就去检查下一个 parent

return false;

})!;

}),

take(1), // 检查一次就行了

);

// 判断是否已经 scroll 到顶部

function reachedTop(element: HTMLElement) {

return element.scrollTop === 0;

}

// 判断是否已经 scroll 到底部

function reachedBottom(element: HTMLElement) {

return element.scrollHeight - element.clientHeight === element.scrollTop;

}

}),

shareReplay(1),

);

完整代码

export function setupWheelToScroll(

wheelElement: HTMLElement,

scrollElement: HTMLElement | HTMLElement[],

): Subscription {

const duration = 150;

const scrollElements = Array.isArray(scrollElement) ? scrollElement : [scrollElement];

const subscription = new Subscription();

const wheel$ = fromEvent(wheelElement, 'wheel').pipe(share());

subscription.add(wheel$.subscribe(e => e.preventDefault()));

// 当用户停止 wheel 之后的第一个 wheel 做检查

// 这里使用 debounceTime 300ms 来等待用户停止 wheel

const targetScrollElement$ = wheel$.pipe(debounceTime(300), startWith(null)).pipe(

switchMap(() => {

return wheel$.pipe(

map(e => {

const upOrDown = e.deltaY > 0 ? 'Down' : 'Up';

// scrollElements 是 child to parent element

return scrollElements.find((scrollElement, index) => {

// 如果已经是最后一个 element 直接返回就好,总要有人可以 scroll 嘛

if (index === scrollElements.length - 1) return true;

// 如果要 scroll up 同时还没有 scroll 到顶就可以 scroll 这个 element

if (upOrDown === 'Up' && !reachedTop(scrollElement)) return true;

// 如果要 scroll down 同时还没有 scroll 到底就可以 scroll 这个 element

if (upOrDown === 'Down' && !reachedBottom(scrollElement)) return true;

// 不可以就去检查下一个 parent

return false;

})!;

}),

take(1), // 检查一次就行了

);

// 判断是否已经 scroll 到顶部

function reachedTop(element: HTMLElement) {

return element.scrollTop === 0;

}

// 判断是否已经 scroll 到底部

function reachedBottom(element: HTMLElement) {

return element.scrollHeight - element.clientHeight === element.scrollTop;

}

}),

shareReplay(1),

);

const deltaY$ = wheel$.pipe(map(e => e.deltaY)).pipe(share());

const [scrollUp$, scrollDown$] = partition(deltaY$, deltaY => deltaY < 0);

for (const scroll$ of [scrollUp$, scrollDown$]) {

const scrollSub = scroll$

.pipe(

mergeMap(scrollPerWheel => {

if (scroll$ === scrollUp$) scrollPerWheel *= -1;

const scrollPerMillisecond = scrollPerWheel / duration;

return animationFrames().pipe(

map(e => e.elapsed),

startWith(0),

pairwise(),

map(([prev, curr]) => curr - prev),

scan(

({ totalScroll }, animationInterval) => {

const remainingScroll = scrollPerWheel - totalScroll;

const scroll = limitMax(Math.ceil(animationInterval * scrollPerMillisecond), remainingScroll);

return { totalScroll: totalScroll + scroll, lastScroll: scroll };

},

{ totalScroll: 0, lastScroll: 0 },

),

takeWhile(({ totalScroll }) => totalScroll !== scrollPerWheel, true),

map(e => (scroll$ === scrollDown$ ? e.lastScroll : e.lastScroll * -1)),

takeUntil(scroll$ === scrollDown$ ? scrollUp$ : scrollDown$),

);

}),

withLatestFrom(targetScrollElement$),

)

.subscribe(([scroll, targetScrollElement]) => (targetScrollElement.scrollTop += scroll));

subscription.add(scrollSub);

}

return subscription;

}

View Code