我与滚动杠上了-吸顶篇

3,528 阅读7分钟

前言

上下滚动是页面上常见的交互行为之一,为了让一些重要的信息始终出现在界面上,不随着页面滚动而看不见。前端会在它向上滚动出屏幕前把它固定在顶部,向下滚动到该出现的位置时把它释放掉,这样的交互方式称为吸顶。比如页面的通栏标题,一些重要导购场景的筛选条件。

场景用例

有时候写了一个很通用的组件,如果它有吸顶能力,那么吸顶的位置一定是首次开发的时候根据设计稿决定的。我们的前端组件面向的是搭建场景,可以被使用者自由组合搭建页面。你无法确定使用者是否在它前面增加了其它的吸顶组件(或许使用者都不知组件有吸顶能力,只是看着UI不错就用了),滚动的时候多个组件吸顶的位置很可能会冲突。最常见的是大家都抢占top:0的位置,就都叠在一起了。使用者当然不希望为了吸顶而迁就页面的搭配和排版,就应该吸附在正常人觉得合适的位置上。

解决思路

核心是解决多个吸顶组件之间的冲突,那就把它们管理起来吧。使用公共的方式注册到吸顶队列中,按照组件在DOM中的位置顺序决定吸顶的前后关系。滚动行为是引起吸顶交互的发动机,发生滚动行为后轮询吸顶队列,依次判断组件是否吸顶。只要组件距离上一个吸顶组件的底部边界小于等于0(快要重叠到一起了),说明是时候吸顶了。首位的吸顶组件的吸顶时机是距离视口的位置小于等于0。

API设计

  • 足够简单,一行代码声明就能让目标组件具备吸顶功能。
  • 组件的吸顶编排的顺序按照DOM中的自然顺序,而不是组件的声明顺序。
  • 吸顶和解除吸顶前后,页面自然不抖动。
  • 吸顶后的样式可控制。如果吸顶后的层级不够高,不吸顶但是层级更高的组件在滚动中就会盖住组件。顺便也可以实现吸顶前白色,吸顶变红色。
  • 默认提供吸顶判断的逻辑,开发者可以重写。可重写的方法中要自动注入吸顶队列,自身的元素的各种状态,以及和前面吸顶元素之间的位置关系。

声明方式

类继承

默认导出一个Class,继承Class的组件直接具备吸顶能力。如果组件的render方法返回多个根元素,只有第一个根元素能吸顶,最好返回单个根节点。

import Sticky from '@ali/rox-sticky-helper';
export default Class extends Sticky {
	render() {
		return ...
	}
}

组件使用

提供一个StickyView组件,该组件的子组件会具备吸顶能力。StickyView不会给子组件包裹任何元素。同样,如果有多个直接子组件,只有第一个直接子组件能吸顶,最好只有一个直接子组件。

import { StickyView } from '@ali/rox-sticky-helper';
export default function() {
	return (
	  <StickyView>
	    <!-- 子组件 -->
	  </StickyView>
	);
}

Hooks

React16和Rax1.x都提供了hooks,这里也提供一个createStickyRef钩子,作用和React的createRef钩子相同,增加了让组件吸顶的能力。

import { createStickyRef } from '@ali/rox-sticky-helper';
const stickyRef = createStickyRef();
export default function() {
	return (
	  <div ref={stickyRef}>
	  </div>
	);
}

吸顶编排顺序

正常情况下,组件按照A、B、C的先后顺序显示在界面上,吸顶的时候也应该按照同样的顺序吸附在屏幕上。所以,每声明一个吸顶组件,会按照它在html排版中的位置关系放入吸顶队列中合适的位置。

使用标准的DOM APIcompareDocumentPosition(),能够很容易判断2个DOM之间的位置关系。IE9就开始支持了。

返回结果一览表

代码示例

<html>
  <head></head>
  <body></body>
</html>

// case1: 获取head元素和body元素的位置关系
document.head.compareDocumentPosition( document.body )
// 返回结果是4,符合上表格中第3条规则

// 反过来
document.body.compareDocumentPosition( document.head )
// 返回结果是2,符合上表格中第2条规则

// case2: 获取document和body的位置关系
document.compareDocumentPosition( document.body )
// 返回结果是20
// body被document包含,返回16,符合上表格中第2条规则
// 既然document包含body,那么document必然在body之前出现,返回结果是4,符合上表格中第3条规则
// 16 + 4 = 20

// 反过来
document.body.compareDocumentPosition( document )
// 返回结果是10
// document包含body,返回8,符合上表格中第4条规则
// document在body前面,返回2,符合上表格中第2条规则
// 8 + 2 = 10

吸顶占位

吸顶方式

css3新增了position: sticky属性让元素在普通情况保持原样,在滚动到指定的top后固定不动。不过,sticky属性受父元素的高度、overflow的影响,碰到灵活多变的结构排版会失效。最终决定使用简单粗暴的position: fixed,屏蔽一切层级结构的影响。

高度占位

一个正常在文档流中的元素,假设高度是50px。吸顶后的position变为fixed,脱离了正常文档流,原来的位置上减少了50px,它后面的元素就会向上跳50px;同理,页面向下滚动,元素解除吸顶状态回归到正常文档流中,会立即多出50px把后面的元素往下挤。为了防止页面抖动,吸顶的时候插入一个不可见的同高度的占位元素,解除吸顶后再移除。

解除吸顶

组件吸顶后脱离了文档流,留下一个占位元素插入原来的位置。反过来,当占位元素快要完全从前一个吸顶元素边界上离开的时候,说明是时候解除吸顶。把原组件的样式还原回来,然后从DOM中移除占位元素。

吸顶样式

组件在文档流中的时候,宽、高可以受父元素布局影响呈现自适应状态,背景色也可以直接透出父元素的。

无样式吸顶

有样式吸顶

代码示例-继承

import Sticky from '@ali/rox-sticky-helper';
export default class extends Sticky {
  getStickyStyle() {
    return { color: '#fff', backgroundColor: '#2990dc' };
  }
  render() {
    return (
      <div id="d1" style={{ width: 750, lineHeight: 100, backgroundColor: '#fff' }}>
        吸顶模块1
      </div>
    );
  }
}

代码示例-组件

import { StickyView } from '@ali/rox-sticky-helper';
function Module2() {
  return <StickyView
    getStickyStyle={() => ({ color: '#fff', backgroundColor: '#f15a4a' })}
  >
    <div id="d2" style={{ width: 750, lineHeight: 100, backgroundColor: '#fff' }}>
      吸顶模块2
    </div>
  </StickyView>;
}

代码示例-Hooks

import { createStickyRef } from '@ali/rox-sticky-helper';
const ref = createStickyRef({
  getStickyStyle() {
	return { color: '#fff', backgroundColor: '#f39826' };
  }
});
function Module3() {
  return (
    <div ref={ref}
      style={{ width: 750, lineHeight: 100, backgroundColor: '#fff' }}
    >
      吸顶模块hook
    </div>
  );
}

控制吸顶时机

这里再赘述一遍吸顶控制时机吧!!!

API会自动监听页面上的滚动事件,默认情况下,第一个元素距离视口小于等于0,判定吸顶。非第一个元素距离前一个元素的底边界小于等于0,判定吸顶。

如果有奇葩的业务逻辑需要自定一吸顶时机,重写onStickyScroll方法即可。

代码示例

import Sticky from '@ali/rox-sticky-helper';
class Module1 extends Sticky {
  onStickyScroll(helper) {
    // 默认处理逻辑
    const {
      prevTopDiff, // 和前一个吸顶组件之间的距离
      prevBottom, // 前一个吸顶组件的底部边界在视口的位置
      self, // 吸顶队列中的自己
      stickyAPI, // 吸顶相关的API
      forceReflow
    } = helper;
    
    const {
      dom, // 组件的dom元素
      isSticky // 是否吸顶
    } = self;

    if (isSticky) {
      if (!self.keepSticky) { // 是否该解除吸顶了
        stickyAPI.resetSticky(self);
      } else if (dom.getBoundingClientRect().top != prevBottom) {
        // 吸顶后位置发生偏差,进行二次校准
        // 有时候前一个元素一边吸顶一边高度发生变化,后面的元素需要不断修改吸顶位置
        dom.style.top = `${prevBottom}px`;
      }
    } else if (prevTopDiff <= 0) { // 距离上个吸顶元素小于等于0
      stickyAPI.setSticky(self, { // 调用吸顶设置API
        position: 'fixed',
        zIndex: 100,
        top: `${prevBottom}px`,
        ...self.ref.getStickyStyle(helper), // 获取用户自定义吸顶样式
      }, forceReflow);
    }
  }
}

初始化立刻吸顶

导航组件这类初始化就处于页面最顶上吸顶组件,页面首次向上滚动的时候组件追随页面至少上移3个像素,此时组件距离视口的top是-3px,该吸顶了。于是组件需要从-3px移动到0px的位置来,页面上会出现导航组件先上后下抖动感。

于是,默认情况下,组件声明为吸顶后自动执行一次吸顶判断。这样,在屏幕首位的组件初始化后就能立刻吸顶,不会出现滚动抖动。如果你的组件和页面顶部有足够的安全距离,可以手动关闭自动吸顶。

import Sticky, { StickyView, createStickyRef } from '@ali/rox-sticky-helper';

// 继承方式
class Module1 extends Sticky {
  // 默认true,初始化立即执行吸顶检测。
  autoStickyTrigger = false;
}

// 组件方式
<StickyView autoStickyTrigger={false} />

// Hooks
createStickyRef({ autoStickyTrigger: false })

吸顶衍生交互案例

多行导航,吸顶合成一行

2行筛选条件,吸顶后变一行,左右横滑