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>
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<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;
}
View Code

 

posted @ 2024-05-04 11:42  兴杰  阅读(21)  评论(0编辑  收藏  举报