CSS & JS Effect – 用 wheel 模拟 scroll
前言
在 用 JavaScript 实现 position sticky 文章中,我提到了用 wheel 来模拟 scroll 效果。
这篇来说说具体怎么实现,挺简单的哦。
Preparation
table.html
<div class="container"> <table> <thead> <tr> <th>First Name</th> <th>Last Name</th> <th>Age</th> <th>Address</th> <th>Email</th> <th>Phone</th> <th>City</th> <th>Country</th> <th>Occupation</th> <th>Salary</th> </tr> </thead> <tbody> <tr> <td>John</td> <td>Doe</td> <td>30</td> <td>123 Main St</td> <td>john.doe@example.com</td> <td>123-456-7890</td> <td>New York</td> <td>USA</td> <td>Software Engineer</td> <td>$80,000</td> </tr> <tr> <td>Jane</td> <td>Smith</td> <td>25</td> <td>456 Elm St</td> <td>jane.smith@example.com</td> <td>987-654-3210</td> <td>Los Angeles</td> <td>USA</td> <td>Graphic Designer</td> <td>$60,000</td> </tr> <tr> <td>Michael</td> <td>Johnson</td> <td>35</td> <td>789 Oak St</td> <td>michael.johnson@example.com</td> <td>456-789-0123</td> <td>Chicago</td> <td>USA</td> <td>Teacher</td> <td>$50,000</td> </tr> <tr> <td>Sarah</td> <td>Williams</td> <td>28</td> <td>321 Pine St</td> <td>sarah.williams@example.com</td> <td>789-012-3456</td> <td>Miami</td> <td>USA</td> <td>Accountant</td> <td>$70,000</td> </tr> <tr> <td>David</td> <td>Brown</td> <td>40</td> <td>654 Cedar St</td> <td>david.brown@example.com</td> <td>210-987-6543</td> <td>Houston</td> <td>USA</td> <td>Engineer</td> <td>$90,000</td> </tr> <tr> <td>Emily</td> <td>Miller</td> <td>33</td> <td>987 Maple St</td> <td>emily.miller@example.com</td> <td>567-890-1234</td> <td>Seattle</td> <td>USA</td> <td>Manager</td> <td>$100,000</td> </tr> <tr> <td>James</td> <td>Wilson</td> <td>27</td> <td>753 Walnut St</td> <td>james.wilson@example.com</td> <td>890-123-4567</td> <td>San Francisco</td> <td>USA</td> <td>Marketing Specialist</td> <td>$75,000</td> </tr> <tr> <td>Emma</td> <td>Anderson</td> <td>29</td> <td>159 Birch St</td> <td>emma.anderson@example.com</td> <td>234-567-8901</td> <td>Boston</td> <td>USA</td> <td>Consultant</td> <td>$85,000</td> </tr> <tr> <td>Christopher</td> <td>Lee</td> <td>32</td> <td>852 Oakwood St</td> <td>christopher.lee@example.com</td> <td>678-901-2345</td> <td>Atlanta</td> <td>USA</td> <td>Lawyer</td> <td>$120,000</td> </tr> <tr> <td>Olivia</td> <td>Clark</td> <td>26</td> <td>357 Elmwood St</td> <td>olivia.clark@example.com</td> <td>123-456-7890</td> <td>Denver</td> <td>USA</td> <td>Artist</td> <td>$55,000</td> </tr> <tr> <td>William</td> <td>White</td> <td>31</td> <td>951 Cedarwood St</td> <td>william.white@example.com</td> <td>456-789-0123</td> <td>Phoenix</td> <td>USA</td> <td>Architect</td> <td>$95,000</td> </tr> <tr> <td>Ava</td> <td>Hall</td> <td>34</td> <td>246 Pinecrest St</td> <td>ava.hall@example.com</td> <td>789-012-3456</td> <td>Dallas</td> <td>USA</td> <td>Financial Analyst</td> <td>$80,000</td> </tr> <tr> <td>Alexander</td> <td>Young</td> <td>29</td> <td>753 Maplewood St</td> <td>alexander.young@example.com</td> <td>210-987-6543</td> <td>Philadelphia</td> <td>USA</td> <td>Real Estate Agent</td> <td>$70,000</td> </tr> <tr> <td>Mia</td> <td>Scott</td> <td>38</td> <td>852 Oak St</td> <td>mia.scott@example.com</td> <td>567-890-1234</td> <td>Minneapolis</td> <td>USA</td> <td>Doctor</td> <td>$150,000</td> </tr> <tr> <td>Ethan</td> <td>Adams</td> <td>27</td> <td>369 Walnut St</td> <td>ethan.adams@example.com</td> <td>890-123-4567</td> <td>Portland</td> <td>USA</td> <td>Journalist</td> <td>$65,000</td> </tr> <tr> <td>Isabella</td> <td>Carter</td> <td>30</td> <td>147 Pine St</td> <td>isabella.carter@example.com</td> <td>234-567-8901</td> <td>Detroit</td> <td>USA</td> <td>Entrepreneur</td> <td>$200,000</td> </tr> <tr> <td>Logan</td> <td>Green</td> <td>31</td> <td>258 Elm St</td> <td>logan.green@example.com</td> <td>678-901-2345</td> <td>San Diego</td> <td>USA</td> <td>Engineer</td> <td>$90,000</td> </tr> <tr> <td>Amelia</td> <td>Roberts</td> <td>29</td> <td>369 Cedar St</td> <td>amelia.roberts@example.com</td> <td>123-456-7890</td> <td>Charlotte</td> <td>USA</td> <td>Designer</td> <td>$70,000</td> </tr> <tr> <td>Benjamin</td> <td>Hill</td> <td>35</td> <td>741 Oakwood St</td> <td>benjamin.hill@example.com</td> <td>456-789-0123</td> <td>San Antonio</td> <td>USA</td> <td>Manager</td> <td>$100,000</td> </tr> <tr> <td>Charlotte</td> <td>Adams</td> <td>33</td> <td>852 Maple St</td> <td>charlotte.adams@example.com</td> <td>789-012-3456</td> <td>Orlando</td> <td>USA</td> <td>Software Developer</td> <td>$85,000</td> </tr> <tr> <td>Gabriel</td> <td>Cook</td> <td>28</td> <td>159 Pinecrest St</td> <td>gabriel.cook@example.com</td> <td>210-987-6543</td> <td>Tampa</td> <td>USA</td> <td>Writer</td> <td>$60,000</td> </tr> </tbody> </table> </div>
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; } }
实现原理
监听 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<HTMLElement>('.container')!; // 监听 wheel 事件 const wheel$ = fromEvent<WheelEvent>(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<WheelEvent>(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; }