Scroll,你玩明白了嘛?

发表于 2年以前  | 总阅读数:588 次

1、引言

最近在实现列表的滚动交互时,算是被复杂的业务场景整得怀疑人生了。今天主要聊一下关于 scroll 的应用:

  • CSS 平滑滚动
  • JS 滚动方法
  • 区分人为滚动和脚本滚动

2、CSS 平滑滚动

2.1 一行样式改善体验

在一些滚动交互比较频繁的场景,我们可以通过在可滚动容器上增加一行样式来改善用户体验。

scroll-behavior: smooth;

比如说,在文档网站里,我们常使用 # 来去定位到对应的浏览位置。

像上面这个 例子 ,我们首先通过 # 去锚定对应内容,实现了一个 tab 切换的效果:

<div>
  <a href="#A">A</a>
  <a href="#B">B</a>
  <a href="#C">C</a>
</div>
<div className="scroll-ctn">
  <div id="A" className="scroll-panel">
    A
  </div>
  <div id="B" className="scroll-panel">
    B
  </div>
  <div id="C" className="scroll-panel">
    C
  </div>
</div>

同时,为了实现平滑滚动,我们在滚动容器上设置了如下的 CSS:

.scroll-ctn {
  display: block;
  width: 100%;
  height: 300px;
  overflow-y: scroll;
  scroll-behavior: smooth;
  border: 1px solid grey;
}

scroll-behavior: smooth 的作用下,容器内的默认滚动呈现了平滑滚动的效果。

2.2 兼容性

IE 和 移动端 ios 上兼容性较差,必要时需要依赖 polyfill。

2.3 注意

1、在可滚动的容器上设置了 scroll-behavior: smooth 之后,其优先级是高于 JS 方法的。也就是说,在 JS 中指定 behavior: auto,想要恢复立即滚动到目标位置的效果,将不会生效。

2、在可滚动的容器上设置了 scroll-behavior: smooth 之后,还能够影响到浏览器 Ctrl+F 的表现,使其也呈现平滑滚动的效果。

3、JS 滚动方法

3.1 基本方法

我们熟知的原生 scroll 方法,大概有这些:

  • scrollTo:滚动到目标位置
  • scrollBy:相对当前位置滚动
  • scrollIntoView:让元素滚动到视野内
  • scrollIntoViewIfNeeded:让元素滚动到视野内(如果不在视野内)

以大家用得比较多的 scrollTo 为例,它有两种调用方式:

// 第一种形式
const x = 0, y = 200;
element.scrollTo(x, y);
// 第二种形式
const options = {
  top: 200,
  left: 0,
  behavior: 'smooth'
};
element.scrollTo(options);

而滚动的行为,即方法参数中的 behavior 分为两种:

  • auto:立即滚动
  • smooth:平滑滚动

除了上述的 3 个 api,我们还可以通过简单粗暴的 scrollTopscrollLeft 去设置滚动位置:

// 设置 container 上滚动距离 200
container.scrollTop = 200;
// 设置 container 左滚动距离 200
container.scrollLeft = 200;

值得一提的是, scrollTopscrollLeft 的兼容性很好。而且相较于其他的方法,一般不会出什么幺蛾子(后文会讲到)。

3.2 应用

自己以往需要用到滚动的场景有:

  • 组件初始化,定位到目标位置
  • 点击当前页靠底部的某个元素,触发滚动翻页
  • ......

举个 例子 ,现在我希望在列表组件加载完成后,列表能够自动滚动到第三个元素。

根据上面提到的我们可以用很多种方式去实现,假设我们已经为列表容器增加了 scroll-behavior: smooth 的样式,然后在 useEffect hook 中去调用滚动方法:

import React, { useEffect, useRef } from "react";
import "./styles.css";

export default function App() {
  const listRef = useRef({ cnt: undefined, items: [] });
  const listItems = ["A", "B", "C", "D"];
  useEffect(() => {
    // 定位到第三个
    const { cnt, items } = listRef.current;
    // 第一种
    // cnt.scrollTop = items[2].offsetTop;
    // 第二种
    // cnt.scrollTo(0, items[2].offsetTop);
    // 第三种
    // cnt.scrollTo({ top: items[2].offsetTop, left: 0, behavior: "smooth" });
    // 第四种
    items[2].scrollIntoView();
    // items[2].scrollIntoViewIfNeeded();Ï
  }, []);
  return (
    <div className="App">
      <ul className="list-ctn" ref={(ref) => (listRef.current.cnt = ref)}>
        {listItems.map((item, index) => {
          return (
            <li
              className="list-item"
              ref={(ref) => (listRef.current.items[index] = ref)}
              key={item}
            >
              {item}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

上述代码中,提到了四种方式:

  • 容器的 scrollTop 赋值
  • 容器的 scrollTo 方法,传入横纵滚动位置
  • 容器的 scrollTo 方法,传入滚动配置
  • 元素的 scrollIntoView / scrollIntoViewIfNeeded 方法

虽然最后效果都是一样的,但这几种方法实际上还是有些许差异的。

3.3 scrollIntoView 的奇怪现象

3.3.1 页面整体偏移

最近在过一些历史用例的时候,遇到了这种情况:

现象大概就是,当我通过按钮,滚动定位到聊天区域的某条消息时,页面整体发生了偏移(向上移动)。再看一眼代码,发现使用的是 scrollIntoView:

因为是第一次遇到,所以上万能的 stack overflow 上逛了一圈,看到了类似的问题: scrollIntoView 导致页面整体移动 。

这个问题常常发生在哪些情况下呢?

1、页面有 iframe 的情况下,比如说这个 例子 。

表现是当 iframe 内的内容发生滚动时,主页面也发生了滚动。这显然和 MDN 上的描述不一致:

Element 接口的 scrollIntoView () 方法会滚动元素的父容器,使被调用 scrollIntoView () 的元素对用户可见。

2、直接使用 scrollIntoView() 的默认参数

先说说 scrollIntoView() 支持什么参数:

element.scrollIntoView(alignToTop); // Boolean 型参数
element.scrollIntoView(scrollIntoViewOptions); // Object 型参数

(1)alignToTop

  • 如果为 true,元素的顶端将和其所在滚动区的可视区域的顶端对齐。相应的 scrollIntoViewOptions: {block: "start", inline: "nearest"}。这是这个参数的默认值。
  • 如果为 false,元素的底端将和其所在滚动区的可视区域的底端对齐。相应的 scrollIntoViewOptions: {block: "end", inline: "nearest"}

(2)scrollIntoViewOptions

包含下列属性:

  • behavior 可选

    定义动画过渡效果, "auto""smooth" 之一。默认为 "auto"

  • block 可选

    定义垂直方向的对齐, "start", "center", "end", 或 "nearest" 之一。默认为 "start"

  • inline 可选

    定义水平方向的对齐, "start", "center", "end", 或 "nearest" 之一。默认为 "nearest"

回到我们的问题,为什么使用默认参数,即 element.scrollIntoView(),会引发页面偏移的问题呢?

关键在于 block: "start",从上面的参数说明我们了解到,默认不传参数的情况下,取的是 block: start,它表示 “元素顶端与所在滚动区的可视区域顶端对齐”。但从现象上看,影响的不只是 “所在滚动区” 或者 “父容器”,祖先 DOM 元素也被影响了。

由于寻觅不到 scrollIntoView 的源码,暂时只能定位到是 start 这个默认值在做妖。既然原生的方法有问题,我们需要采取一些别的方式来代替。

3.3.2 解决方式

1、更换参数

既然是 block: start 有问题,那咱们换一个效果就好了,这里建议使用 nearest

element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });

可能也有好奇的朋友想问,这些对齐的选项具体代表了什么含义?在 MDN 里面好像都没有做特别的解释。这里引用 stackoverflow 上的一个 高赞解答 ,可以帮助你更好的理解。

  1. 使用 {block: "start"},元素在其祖先的顶部对齐。
  2. 使用 {block: "center"},元素在其祖先的中间对齐。
  3. 使用 {block: "end"},元素在其祖先的底部对齐。
  4. 使用 {block: "nearest"}
  • 如果您当前位于其祖先的下方,则元素在其祖先的顶部对齐。
  • 如果您当前位于其祖先之上,则元素在其祖先的底部对齐。
  • 如果它已经在视图中,保持原样。

2、scrollTop/scrollLeft

上文也提到 scrollTop/scrollLeft 赋值是兼容性最好的滚动方式,我们可以利用它来代替默认的 scrollIntoView () 的表现。

比如说置顶某个元素,可以定义可滚动容器的 scrollTop 为该元素的 offsetTop:

container.scrollTop = element.offsetTop;

值得一提的是,结合 CSS 的 scroll-behavior,这种赋值方式也可以实现平滑滚动效果。

4、如何区分人为滚动和脚本滚动

4.1 背景

最近遇到这么一个需求,做一个实时高亮当前播放内容的字幕文稿。核心的交互是:

1、当用户没有人为滚动文稿时,会保持自动翻页的功能

2、当用户人为滚动文稿时,后续将不会自动翻页,并出现 “回到当前播放位置” 的按钮

3、假如点击了 “回到当前播放位置” 的按钮,会回到目标位置,并恢复自动翻页的功能。

像上面的演示中,用户触发了人为滚动,之后点击 “回到当前播放位置”,触发了脚本滚动。

4.2 人为滚动

怎么定义 “人为滚动” 呢?我们所了解的人为滚动,包含:

  • 鼠标滚动
  • 键盘方向键滚动
  • 缩进键滚动
  • 翻页键滚动
  • ......

假如说,我们通过 onWheel、onKeyDown 等事件,去监听人为滚动,定是不能尽善尽美的。那么我们换个思路,能否去对 “脚本滚动” 下功夫?

4.3 脚本滚动

怎么定义 “脚本滚动”?我们将由代码触发的滚动,定义为 “脚本滚动”。

我们需要用一种方式描述 “脚本滚动”,来和 “人为滚动” 做区分。由于它们是非此即彼的关系,那实际上我们只需要在 onScroll 这个事件上,通过一个 flag 去区分即可。

流程图如下:

而这其中唯一需要关注的点在于,需要通过什么方式知道,脚本滚动结束了?

scrollTo 等原生方式,显然没有给我们提供回调方法,来告诉我们滚动在什么时候结束。所以我们还是需要依赖 onScroll 去监听当前的滚动位置,来得知滚动什么时候达到目标位置。

所以上面的流程还要再加一步:

接下来看看代码要怎么组织。

4.4 代码实现

首先看一下我们想要实现的 demo :

接下来先实现基本的页面结构。

1、定义一个长列表,并通过 useRef 记录:

  • 滚动容器的 ref
  • 脚本滚动的判断变量 isScriptScroll
  • 当前的滚动位置 scrollTop

2、接着,为滚动容器绑定一个 onScroll 方法,在其中分别编写人为滚动和脚本滚动的逻辑,并使用节流来避免频繁触发。

在人为滚动和脚本滚动的逻辑中,我们通过更新 wording 这个状态,来区分当前处于人为滚动还是脚本滚动。

3、用一个 button 来触发脚本滚动,调用 listScroll 方法,传入容器 ref,想要滚动到的 scrollTop 以及滚动结束后的 callback 方法。

如下:

import throttle from "lodash.throttle";
import React, { useRef, useState } from "react";
import { listScroll } from "./utils";
import "./styles.css";

const scrollItems = new Array(1000).fill(0).map((item, index) => {
  return index + 1;
});

export default function App() {
  const [wording, setWording] = useState("等待中");
  const cacheRef = useRef({
    isScriptScroll: false,
    cnt: null,
    scrollTop: 0
  });

  const onScroll = throttle(() => {
    if (cacheRef.current.isScriptScroll) {
      setWording("脚本滚动中");
    } else {
      cacheRef.current.scrollTop = cacheRef.current.cnt.scrollTop;
      setWording("人为滚动中");
    }
  }, 200);

  const scriptScroll = () => {
    cacheRef.current.scrollTop += 600;
    cacheRef.current.isScriptScroll = true;
    listScroll(cacheRef.current.cnt, cacheRef.current.scrollTop, () => {
      setWording("脚本滚动结束");
      cacheRef.current.isScriptScroll = false;
    });
  };

  return (
    <div className="App">
      <button
        className="btn"
        onClick={() => {
          scriptScroll();
        }}
      >
        触发一次脚本滚动
      </button>
      <p className="wording">当前状态:{wording}</p>
      <ul
        className="list-ctn"
        onScroll={onScroll}
        ref={(ref) => (cacheRef.current.cnt = ref)}
      >
        {scrollItems.map((item) => {
          return (
            <li className="list-item" key={item}>
              {item}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

接下来重点就在于 listScroll 怎么实现了。我们需要再去绑定一个 scroll 事件,不断去监听容器的 scrollTop 是否已经达到目标值,所以可以这么组织:

import debounce from "lodash.debounce";

/** 误差范围内 */
export const withErrorRange = (
  val: number,
  target: number,
  errorRange: number
) => {
  return val <= target + errorRange && val >= target - errorRange;
};

/** 列表滚动封装 */
export const listScroll = (
  element: HTMLElement,
  targetPos: number,
  callback?: () => void
) => {
  // 是否已成功卸载
  let unMountFlag = false;
  const { scrollHeight: listHeight } = element;

  // 避免一些边界情况
  if (targetPos < 0 || targetPos > listHeight) {
    return callback?.();
  }

  // 调用滚动方法
  element.scrollTo({
    top: targetPos,
    left: 0,
    behavior: "smooth"
  });

  // 没有回调就直接返回
  if (!callback) return;
  // 如果已经到达目标位置了,可以先行返回
  if (withErrorRange(targetPos, element.scrollTop, 10)) return callback();
  // 防抖处理
  const cb = debounce(() => {
    // 到达目标位置了,可以返回
    if (withErrorRange(targetPos, element.scrollTop, 10)) {
      element.removeEventListener("scroll", cb);
      unMountFlag = true;
      return callback();
    }
  }, 200);

  element.addEventListener("scroll", cb, false);

  // 兜底:卸载滚动回调,避免对之后的操作产生影响
  setTimeout(() => {
    if (!unMountFlag) {
      element.removeEventListener("scroll", cb);
      callback();
    }
  }, 1000);
};

按严谨的流程来写的话,我们需要依靠 scroll 事件去不断判断 scrollTop,直至在误差范围内相等。

但实际上滚动是一个很快的过程,跟我们兜底的定时器逻辑,也就是前后脚的事情,是不是可以只保留兜底的逻辑?

而且,考虑到那些异常情况:

  • 脚本滚动发生异常
  • 脚本滚动被人为滚动打断

我们都得保证执行了一次回调,确保外部状态被释放,下一次滚动的逻辑正常。

所以在不那么严格的场景下,上述的代码其实可以抛弃 eventListener 的部分,只保留兜底的逻辑,进一步简化:

/** 列表滚动封装 */
export const listScroll = (
  element: HTMLElement,
  targetPos: number,
  callback?: () => void
) => {
  const { scrollHeight: listHeight } = element;

  // 避免一些边界情况
  if (targetPos < 0 || targetPos > listHeight) {
    return callback?.();
  }

  // 调用滚动方法
  element.scrollTo({
    top: targetPos,
    left: 0,
    behavior: "smooth"
  });

  // 没有回调就直接返回
  if (!callback) return;
  // 如果已经到达目标位置了,可以先行返回
  if (withErrorRange(targetPos, element.scrollTop, 10)) return callback();

  // 兜底:卸载滚动回调,避免对之后的操作产生影响
  setTimeout(() => {
    callback();
  }, 1000);
};

当然,这个实现只是一种参考,相信大家也有别的更好的思路。

5、小结

回顾整篇文章,简单介绍了关于 scroll 的一些 api 使用,原生 scrollIntoView 的坑以及区分人为滚动和脚本滚动的实现参考。

滚动,这一个看似微小的交互点,实际上可能隐藏着不少的工作量,在往后的评估或者实践中,需要多加重视和思考,隐藏在交互体验之下的复杂逻辑。

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/EhD8YIh8yAGRgXcibeEFsw

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237227次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8063次阅读
 目录