这篇本是数月前为了应付部门征稿而写的文章,后发表在 (自娱自乐的)部门技术公众号 上。虽说是应付之作,但也确实表达了不少我过去一年在 UI SDK 方面的一些工作结果和心得。与其被困在微信的围墙里无人问津(甚至可能被消失),不如在开放的互联网上重见天日

原标题:SDK 场景下第三方UI组件滚动交互实战。以下为原文:

本文通过商业广告创意中心前端SDK在工程实践中的三个真实案例介绍了第三方UI组件(下称:组件)如何实现较通用的滚动交互的方法。

在开发组件过程中,常常需要解决一些开发普通页面碰不到的问题。就拿滚动交互来说,这对于前端工程师来说并不陌生,常见的滚动加载、回到顶部等操作都与滚动有关,通常通过监听 scroll 事件、调用 window.scrollTo 方法就可以快速实现。但是在第三方页面上,组件并不能决定自身被用在什么地方,可能出现多种引用场景:

  • 组件在文档流中自然展开高度并跟随整个页面滚动
  • 组件在文档流中某个限定宽高的滚动容器内局部滚动
  • 组件在某个脱离文档流的限定宽高的滚动容器内局部滚动

因此处理滚动交互时,需要同时考虑这几种情况。在具体实现上,需要将这几种情况进行某种程度上的统一,从而实现优雅且合理的代码逻辑。

下面我们就来看下滚动加载、回到顶部 和 鼠标拖拽框选 这三个功能都是如何处理滚动交互的。

滚动加载

以图片列表为例,假如组件调用方把图片列表的渲染节点(root)放在一个自定义滚动容器内,那么常规的监听 window.onscroll 事件的方法就不管用了。如果在组件中这么实现(一开始我们确实这么写了,此处配naive表情),得到结果是:图片列表滚动到底了却没有触发“加载更多”、而整体页面滚动到底反而触发了图片加载。要解决这个问题,图片列表的加载调用逻辑需要识别到自身是否处于自定义滚动容器中,并决定是监听 window 还是自定义滚动容器的 scroll 事件。

那么组件怎么判断自身是否处于自定义滚动容器中呢?通过 parentElement 属性一层层往上找 scrollHeight > clientHeight 的父元素作为滚动容器的方式看似可行,但如果初始化的自定义滚动容器高度大于图片列表的高度,那么只会找到页面根节点,仍然绑定错了元素。看来自动查找滚动容器的方法无法覆盖部分case,因此通过约定调用方在自定义容器上标注滚动属性的方式来识别更为靠谱准确。

基于约定,可以通过查找最近的包含滚动属性(例:<div scroll-root> )的祖先元素来找到需要绑定 scroll 事件的元素(scrollContainer)(例:root.closest('[scroll-root]') || window),同时判断是否到达底部的逻辑中从哪个元素读取属性也需要做相应修改,这样一来我们就正确实现了组件中的滚动监听、判断逻辑。

回到顶部

因为同样的原因,几个开源的回到顶部组件在我们的场景下也没法使用,因为它们(如 react-back-to-top-buttonvue-backtotop)都只支持了 window 滚动。那么有了上一节的基础,我们实现回到顶部功能会不会简单点呢?并不是,这里还需要解决另一个问题——回到顶部按钮的定位:

  • 整个页面内:按钮相对于整个视口右下角定位
  • 自定义滚动容器内:按钮相对于所在的滚动容器右下角定位

对于前者,前端只需要声明相对视口右下角的固定定位属性(position: fixedrightbottom)即可。但对于后者,滚动容器的位置有可能会发生变化的,因此回到顶部按钮的定位不是固定的,而是跟随滚动容器位置的动态定位。

此时你脑中可能飘过 window.onresizeResizeObserverConstraintLayout 等一堆复杂的技术方案,所幸这一次我们可以站在巨人肩膀上,Popper.js 定位引擎提供了modifiers~inner功能,只需要传入滚动容器节点和相关定位参数就能实现这么苛刻的需求。流行的 Bootstrap 界面库也内置了该引擎来实现各种浮层功能。至于 Popper.js 内部是怎么实现的,实在是太过庞杂,此处篇幅有限不再展开更多。

搞定了定位的问题,那么只需要实现点击“回到顶部”的功能了。如前所述,滚动容器可能是页面也有可能是自定义滚动容器,需要用 scrollContainer.scrollTo() 代替 window.scrollTo() 来控制特定容器的滚动。但是IE11不支持Element.scrollTo(),在Safari上尚不支持平滑滚动,出于兼容性考虑还需要做兼容处理(如下 scrollToTop 实现)。

function scrollToTop(container) {
    if ('scrollBehavior' in document.documentElement.style)
        container.scrollTo({top: 0, left: 0, behavior: 'smooth'});
    else
        getScrollToTopFunc(container)();
}
function getScrollToTopFunc(el) {
    return function run() {
        let currentScroll = el.scrollTop;
        if (currentScroll > 0) {
            el.scrollTop = Math.floor(currentScroll - (currentScroll / 5));
            window.requestAnimationFrame(run);
        }
    };
}

鼠标拖拽框选

图片列表中还存在这样的一种交互:鼠标拖拽框选图片(如下图)。

鼠标拖拽框选图片

再考虑到滚动容器内边框选边滚动的交互,选取框移出滚动区域部分应该不可见(如下图)。

边框选边滚动

计算逻辑上,不仅要考虑页面滚动和容器内滚动,而且这个交互因为要计算鼠标坐标并展现选取框,还需要区分 文档流内的局部滚动 还是 脱离文档流的局部滚动(模态窗口,Modal)。显示逻辑上,要做到选取框跟随滚动和滚出部分不可见,那么直接在滚动容器中渲染选取框并使之相对滚动容器定位的方式比较合理。综上,在计算坐标时全部换算成相对滚动容器的坐标来统一各种场景便成了唯一的选择。

在拖动开始时,首先计算滚动容器相对页面的偏移坐标(Pa)。然后根据鼠标相对视口坐标(Pb)、页面的滚动距离(Pc),滚动容器内容的滚动距离(Pd),计算得到鼠标相对于滚动容器的坐标(Pe)。局部滚动场景下还需要使用滚动容器的滚动宽高作为 Pe 的上限防止选取框大于可滚动区域。有了鼠标相对于滚动容器坐标,记录下拖动开始点(Pe1)和结束点(Pe2)便得到了一个选取范围(起始点=Pe1, 宽高=Pe2-Pe1),由此即可绘制出相对滚动容器的选取框浮层。

滚动容器相对视口坐标 Pf = Pa - Pc
鼠标相对于滚动容器坐标 Pe = Pb - Pf + Pd

而在判定哪些图片被选中的逻辑中,也需要计算出图片相对于滚动容器的坐标,并将该图片坐标、图片宽高与 Pe1, Pe2 进行比较——即两个矩形有没有相交——得到选中与否的结果。用两矩形相交的对立事件(两矩形不相交)取反可以轻松得到是否选中的结果,这里的难点在于如何实现“计算图片相对于滚动容器的坐标”的算法。因为 DOM 没有提供直接获取两个嵌套元素之间的偏移距离的接口,只能通过 offsetTop/Left 获取相对 offsetParent 元素的偏移距离并基于此间接实现计算两个嵌套元素间偏移距离的方法。在计算时,由于父级元素并不一定就是 offsetParent,因此还需要通过计算相对于共同 offsetParent 的偏移距离的差值辅助求解。例如存在 A > B > C > D 这样的嵌套结构,元素A 和 元素C 是 offsetParent,要获取元素 D 相对元素 B 的偏移距离,就需要如下图所示的计算过程。

图解

因为选中与否是视觉上的效果,因此还要把 CSS 样式导致的额外偏移也考虑进去,所以在计算时要再加上自身 translateX/Y 和 offsetParent 的 borderTop/LeftWidth 补偿值。该算法也可用于“计算滚动容器相对页面的偏移坐标”。完成了这些后,终于我们的拖拽框选可以在各种场景下都能畅快地跑起来了。

至此,通过这三个案例的剖析,相信你已经对SDK场景下实现滚动交互已经有了比较深入的了解。看似简单的图形界面功能,一旦放到SDK里面实现各种兼容,就变得复杂了起来。