需求
先简单说下背景需求:移动端web页面有个功能是要滚动定位到某个元素。且不能那么生硬,需要带有动画效果。 最低兼容性要求:Android5.0 IOS11 以上。
调研(废话篇)
调查优先从原生找起,优先CSS、浏览器JS Api、CSS+JS实现。实在不行在纯js实现。
scroll-behavior
纯CSS方案上只有:scroll-behavior【MDN】
兼容性检查:IOS15.4+(2022-03-15)、Android61 (2017-09-05)
前端判断是否支持: const smoothScrollSupported = 'scrollBehavior' in document.documentElement.style;
PASS!,而且还是需要手动JS计算滚动位置。
Window.scrollTo、Element.scrollTo
Element.scrollTo、Window.scrollto 二者的作用都是滚动元素或者文档到指定的位置。
语法
window.scrollTo(x-coord,y-coord)
window.scrollTo({
top: 1000,
behavior: "smooth"
});
重点是options.behavior属性,当值为smooth时,页面会平滑的滚动。
兼容性检查:IOS14,Android61 (2017-09-05)
PASS!
Window.scrollBy、Element.scrollBy
Element.scrollBy、Window.scrollBy 二者的作用跟上面俩差不多,只是是按指定的偏移量滚动文档。
兼容性也跟上面一样,不用看了。
PASS!
Element.scrollIntoView
Element.scrollIntoView 将元素滚动到视口的指定位置。对于简单的布局其实挺好用,语义化也不错。
// 将元素滚动到视口中间,同理block为top就是顶部,end就是底部
Element.scrollIntoView({block: "center"})
但要滚动到指定元素下方呢?因为页面上难免有一些header元素,或者导航Tab等。block的三个取值就太笼统了,还是不太满足很多需求。
兼容性检查:这块需要注意的时,入参options的支持性和options.behavior='smooth'的不同。IOS15.4,Android61 (安卓赢麻了呀。
Element.scrollTop + requestAnimationFrame
scrollTop、requestAnimationFrame 还得是他俩啊。。。没办法,计算吧!
在算之前,想想这种需求市面上应该封装的库,找到一个Star5.5k的库,github.com/cferdinandi… ,其核心逻辑就是按照屏幕刷新率一点点的滚,然后加点其他功能优化下。
大概看一眼文档,然后看下代码实现,代码量不是很多,是个学习的好机会。核心逻辑的不是很复杂,作为一个库,会有很多的代码在处理错误,兼容性判断和处理,多配置项支持等。结合我们的需求,一堆兼容性判断可以不用管了,配置项和其他功能?看我们的需求给它优化了。那这样核心代码就剩下不到两百行。那不如我们动手自己抄一遍,还能学到很多思想,安排!
代码实现
scroll.ts
// 滚动库
let animationInterval: any;
/**
* 浏览器原生是否支持顺滑滚动效果
*/
const smoothScrollSupported = 'scrollBehavior' in document.documentElement.style;
/**
* 判断页面是否滚动到底部
*/
const isReachTheBottom = () => {
const scrollTop = window.scrollY;
const { clientHeight, scrollHeight } = document.documentElement;
return scrollTop + clientHeight >= scrollHeight - 1;
};
/**
* 获取滚动结束后页面的位置
*/
const getEndLocation = (anchor: HTMLElement, headerHeight = 0, offset = 0): number => {
const location = anchor.getBoundingClientRect().top + window.scrollY;
return Math.max(location - headerHeight - offset, 0);
};
/**
* 计算缓动模式,支持自定义函数
* @link https://gist.github.com/gre/1650294
* @link https://easings.net/ 效果预览
*/
const easingPattern = (settings: any = {}, time: number) => {
let pattern;
// Default Easing Patterns
if (settings.easing === 'easeInQuad') pattern = time * time; // accelerating from zero velocity
if (settings.easing === 'easeOutQuad') pattern = time * (2 - time); // decelerating to zero velocity
if (settings.easing === 'easeInOutQuad')
pattern = time < 0.5 ? 2 * time * time : -1 + (4 - 2 * time) * time; // acceleration until halfway, then deceleration
if (settings.easing === 'easeInCubic') pattern = time * time * time; // accelerating from zero velocity
if (settings.easing === 'easeOutCubic') pattern = --time * time * time + 1; // decelerating to zero velocity
if (settings.easing === 'easeInOutCubic')
pattern =
time < 0.5 ? 4 * time * time * time : (time - 1) * (2 * time - 2) * (2 * time - 2) + 1; // acceleration until halfway, then deceleration
if (settings.easing === 'easeInQuart') pattern = time * time * time * time; // accelerating from zero velocity
if (settings.easing === 'easeOutQuart') pattern = 1 - --time * time * time * time; // decelerating to zero velocity
if (settings.easing === 'easeInOutQuart')
pattern = time < 0.5 ? 8 * time * time * time * time : 1 - 8 * --time * time * time * time; // acceleration until halfway, then deceleration
if (settings.easing === 'easeInQuint') pattern = time * time * time * time * time; // accelerating from zero velocity
if (settings.easing === 'easeOutQuint') pattern = 1 + --time * time * time * time * time; // decelerating to zero velocity
if (settings.easing === 'easeInOutQuint')
pattern =
time < 0.5
? 16 * time * time * time * time * time
: 1 + 16 * --time * time * time * time * time; // acceleration until halfway, then deceleration
// Custom Easing Patterns
if (typeof settings.customEasing === 'function') {
pattern = settings.customEasing(time);
}
return pattern || time; // no easing, no acceleration
};
/**
* 获取对应距离需要的滚动速度
*/
const getSpeed = (distance: number, settings = { speedAsDuration: false, speed: 1000 }) => {
let speed = settings.speedAsDuration
? settings.speed
: Math.abs((distance / 1000) * settings.speed);
return speed;
};
/**
* 取消滚动,移除下次动画回调并重置变量
*/
const cancelScroll = () => {
cancelAnimationFrame(animationInterval);
animationInterval = null;
};
interface IScrollOption {
// 是否优先使用原生滚动效果,为true且浏览器支持时下面的速度动画效果将不起作用 默认true
nativeScroll?: boolean;
// 滚动速度 滚动1000px需要花费的时间(ms),为0时不带动画效果直接位移 默认300
speed?: number;
// 精确的让滚动所有距离的动画耗时都为speed值 默认false
speedAsDuration?: boolean;
// 缓动函数名,详见easingPattern方法
easing?: string;
// 自定义缓动函数
customEasing?: (time: number) => number;
}
export const animateScroll = (
anchor: HTMLElement | string,
header: HTMLElement | string | number,
_option: IScrollOption
) => {
// 取消任何已存在的滚动
cancelScroll();
const defaultOption: IScrollOption = {
nativeScroll: true,
speed: 300,
speedAsDuration: false,
easing: 'easeInOutCubic',
};
const anchorElm =
typeof anchor === 'string' ? (document.querySelector(anchor) as HTMLElement) : anchor;
const headerElm =
typeof header === 'string' ? (document.querySelector(header) as HTMLElement) : header;
if (!anchorElm) {
return;
}
const headerHeight =
headerElm instanceof HTMLElement
? headerElm.getBoundingClientRect().height
: Number(header) || 0;
const startLocation = window.scrollY;
const endLocation = getEndLocation(anchorElm, headerHeight);
const distance = endLocation - startLocation;
const speed = getSpeed(distance);
const option = Object.assign(defaultOption, _option);
// 速度为0时,直接滚动到指定位置,不带动效
if (option.speed === 0) {
window.scrollTo(0, endLocation);
return;
}
// 当浏览器支持滚动时,且配置为优先使用原生则使用原生api
if (smoothScrollSupported && option.nativeScroll) {
window.scrollTo({ top: endLocation, behavior: 'smooth' });
return;
}
let start: number | null,
// 滚动进度区间值为[0,1]
percentage: number,
// 滚动位置
position: number,
// 累计滚动时间
timeLapsed = 0;
/**
* 判断是否要停止滚动
*/
const stopAnimateScroll = (position: number, endLocation: number): boolean => {
let currentLocation = window.scrollY;
// 判断是否该停止滚动
if (position == endLocation || currentLocation == endLocation || isReachTheBottom()) {
cancelScroll();
start = null;
return true;
}
return false;
};
/**
* 循环的触发滚动
*/
const loopAnimateScroll = (timestamp: number) => {
if (!start) {
start = timestamp;
}
timeLapsed += timestamp - start;
percentage = speed === 0 ? 0 : timeLapsed / speed;
percentage = percentage > 1 ? 1 : percentage;
position = startLocation + distance * easingPattern({ easing: 'easeInOutCubic' }, percentage);
window.scrollTo(0, Math.floor(position));
if (!stopAnimateScroll(position, endLocation)) {
animationInterval = window.requestAnimationFrame(loopAnimateScroll);
start = timestamp;
}
};
window.requestAnimationFrame(loopAnimateScroll);
};
使用
TODO,21点半,下班先了。
源码解析
参考这个文章,发现已经有大佬总结分析了# 平滑滚动的实现(下) - smooth-scroll源码分析
参考
js:scroll平滑滚动页面或元素到顶部或底部的方案汇总 blog.csdn.net/mouday/arti…