找回密码
 会员注册
查看: 28|回复: 0

Scroll,你玩明白了嘛?

[复制链接]

2万

主题

0

回帖

6万

积分

超级版主

积分
64454
发表于 2024-9-20 18:26:52 | 显示全部楼层 |阅读模式
1、引言最近在实现列表的滚动交互时,算是被复杂的业务场景整得怀疑人生了。今天主要聊一下关于scroll的应用:CSS平滑滚动JS滚动方法区分人为滚动和脚本滚动2、CSS平滑滚动2.1一行样式改善体验在一些滚动交互比较频繁的场景,我们可以通过在可滚动容器上增加一行样式来改善用户体验。scroll-behavior:smooth;比如说,在文档网站里,我们常使用 # 来去定位到对应的浏览位置。像上面这个例子,我们首先通过 # 去锚定对应内容,实现了一个tab切换的效果:      A    B    C 同时,为了实现平滑滚动,我们在滚动容器上设置了如下的CSS:.scroll-ctn{ display:block; width:100%; height:300px; overflow-y:scroll; scroll-behavior:smooth; border:1pxsolidgrey;}在 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 为例,它有两种调用方式://第一种形式constx=0,y=200;element.scrollTo(x,y);//第二种形式constoptions={ top:200, left:0, behavior:'smooth'};element.scrollTo(options);而滚动的行为,即方法参数中的 behavior 分为两种:auto:立即滚动smooth:平滑滚动除了上述的3个api,我们还可以通过简单粗暴的 scrollTop、 scrollLeft 去设置滚动位置://设置container上滚动距离200container.scrollTop=200;//设置container左滚动距离200container.scrollLeft=200;值得一提的是, scrollTop、 scrollLeft 的兼容性很好。而且相较于其他的方法,一般不会出什么幺蛾子(后文会讲到)。3.2应用自己以往需要用到滚动的场景有:组件初始化,定位到目标位置点击当前页靠底部的某个元素,触发滚动翻页......举个例子,现在我希望在列表组件加载完成后,列表能够自动滚动到第三个元素。根据上面提到的我们可以用很多种方式去实现,假设我们已经为列表容器增加了 scroll-behavior:smooth 的样式,然后在useEffecthook中去调用滚动方法:importReact,{useEffect,useRef}from"react";import"./styles.css";exportdefaultfunctionApp(){ constlistRef=useRef({cnt:undefined,items:[]}); constlistItems=["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(     (listRef.current.cnt=ref)}>    {listItems.map((item,index)=>{     return(      (listRef.current.items[index]=ref)}       key={item}      >       {item}           );    })}      );}上述代码中,提到了四种方式:容器的scrollTop赋值容器的scrollTo方法,传入横纵滚动位置容器的scrollTo方法,传入滚动配置元素的scrollIntoView/scrollIntoViewIfNeeded方法虽然最后效果都是一样的,但这几种方法实际上还是有些许差异的。3.3scrollIntoView的奇怪现象3.3.1页面整体偏移最近在过一些历史用例的时候,遇到了这种情况:现象大概就是,当我通过按钮,滚动定位到聊天区域的某条消息时,页面整体发生了偏移(向上移动)。再看一眼代码,发现使用的是scrollIntoView:因为是第一次遇到,所以上万能的stackoverflow上逛了一圈,看到了类似的问题: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上的一个高赞解答,可以帮助你更好的理解。使用 {block:"start"},元素在其祖先的顶部对齐。使用 {block:"center"},元素在其祖先的中间对齐。使用 {block:"end"},元素在其祖先的底部对齐。使用 {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当前的滚动位置 scrollTop2、接着,为滚动容器绑定一个 onScroll 方法,在其中分别编写人为滚动和脚本滚动的逻辑,并使用节流来避免频繁触发。在人为滚动和脚本滚动的逻辑中,我们通过更新wording这个状态,来区分当前处于人为滚动还是脚本滚动。3、用一个button来触发脚本滚动,调用 listScroll 方法,传入容器 ref,想要滚动到的 scrollTop 以及滚动结束后的 callback 方法。如下:importthrottlefrom"lodash.throttle";importReact,{useRef,useState}from"react";import{listScroll}from"./utils";import"./styles.css";constscrollItems=newArray(1000).fill(0).map((item,index)=>{ returnindex+1;});exportdefaultfunctionApp(){ const[wording,setWording]=useState("等待中"); constcacheRef=useRef({  isScriptScroll:false,  cnt:null,  scrollTop:0 }); constonScroll=throttle(()=>{  if(cacheRef.current.isScriptScroll){   setWording("脚本滚动中");  }else{   cacheRef.current.scrollTop=cacheRef.current.cnt.scrollTop;   setWording("人为滚动中");  } },200); constscriptScroll=()=>{  cacheRef.current.scrollTop+=600;  cacheRef.current.isScriptScroll=true;  listScroll(cacheRef.current.cnt,cacheRef.current.scrollTop,()=>{   setWording("脚本滚动结束");   cacheRef.current.isScriptScroll=false;  }); }; return(     {     scriptScroll();    }}   >    触发一次脚本滚动       当前状态:{wording}   (cacheRef.current.cnt=ref)}   >    {scrollItems.map((item)=>{     return(             {item}           );    })}      );}接下来重点就在于 listScroll 怎么实现了。我们需要再去绑定一个scroll事件,不断去监听容器的scrollTop是否已经达到目标值,所以可以这么组织:importdebouncefrom"lodash.debounce";/**误差范围内*/exportconstwithErrorRange=( val:number, target:number, errorRange:number)=>{ returnval=target-errorRange;};/**列表滚动封装*/exportconstlistScroll=( element:HTMLElement, targetPos:number, callback?)=>void)=>{ //是否已成功卸载 letunMountFlag=false; const{scrollHeight:listHeight}=element; //避免一些边界情况 if(targetPoslistHeight){  returncallback?.(); } //调用滚动方法 element.scrollTo({  top:targetPos,  left:0,  behavior:"smooth" }); //没有回调就直接返回 if(!callback)return; //如果已经到达目标位置了,可以先行返回 if(withErrorRange(targetPos,element.scrollTop,10))returncallback(); //防抖处理 constcb=debounce(()=>{  //到达目标位置了,可以返回  if(withErrorRange(targetPos,element.scrollTop,10)){   element.removeEventListener("scroll",cb);   unMountFlag=true;   returncallback();  } },200); element.addEventListener("scroll",cb,false); //兜底:卸载滚动回调,避免对之后的操作产生影响 setTimeout(()=>{  if(!unMountFlag){   element.removeEventListener("scroll",cb);   callback();  } },1000);};按严谨的流程来写的话,我们需要依靠scroll事件去不断判断scrollTop,直至在误差范围内相等。但实际上滚动是一个很快的过程,跟我们兜底的定时器逻辑,也就是前后脚的事情,是不是可以只保留兜底的逻辑?而且,考虑到那些异常情况:脚本滚动发生异常脚本滚动被人为滚动打断我们都得保证执行了一次回调,确保外部状态被释放,下一次滚动的逻辑正常。所以在不那么严格的场景下,上述的代码其实可以抛弃eventListener的部分,只保留兜底的逻辑,进一步简化:/**列表滚动封装*/exportconstlistScroll=( element:HTMLElement, targetPos:number, callback?)=>void)=>{ const{scrollHeight:listHeight}=element; //避免一些边界情况 if(targetPoslistHeight){  returncallback?.(); } //调用滚动方法 element.scrollTo({  top:targetPos,  left:0,  behavior:"smooth" }); //没有回调就直接返回 if(!callback)return; //如果已经到达目标位置了,可以先行返回 if(withErrorRange(targetPos,element.scrollTop,10))returncallback();  //兜底:卸载滚动回调,避免对之后的操作产生影响 setTimeout(()=>{  callback(); },1000);};当然,这个实现只是一种参考,相信大家也有别的更好的思路。5、小结回顾整篇文章,简单介绍了关于scroll的一些api使用,原生 scrollIntoView 的坑以及区分人为滚动和脚本滚动的实现参考。滚动,这一个看似微小的交互点,实际上可能隐藏着不少的工作量,在往后的评估或者实践中,需要多加重视和思考,隐藏在交互体验之下的复杂逻辑。紧追技术前沿,深挖专业领域扫码关注我们吧!
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-27 00:19 , Processed in 1.104625 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表