背景
近一段时间一直在做移动端web应用相关的开发,随着产品迭代页面越来越多,一个页面模块也随着增加,产品经理就提出了给内容模块较多的页面添加锚点功能的需求。
本来想着移动端页面模块锚点的产品市面上有很多,应该有现成的组件可以拿来即用,结果经过一番辛苦的调(sou)研(suo),没有发现适用的。既然没有现成的,那就自己写一个呗,反正页面模块锚点功能也不是很复杂,能花多大时间,结果自己写了一个页面的锚点测试就各种问题,到下一个页面发现上一个写的功能不适用,又单独写了一遍,又各种问题,一个小小的锚点功能各种坑,不得不感(hou)慨(hui)应该好好设计一番才动手。
设计目标
-
提高线上产品体验,让用户点击锚点交互更流畅,如丝般顺滑
-
降低重复开发人力成本,做到一套锚点组件多页面复用
问题前瞻
页面模块锚点功能的组成
-
锚点导航栏
-
锚点的内容展示模块
类似效果如下图:
页面模块锚点需要实现的功能
-
锚点导航点击滚轮滑动展示对应的内容模块
-
内容模块视图内展示对应的锚点导航高亮
-
导航栏开始隐藏时,吸顶
核心功能实现
锚点导航点击滚轮滑动展示对应的内容模块
实现方法
1、a标签href=#id实现对应内容模块的锚点。缺点是链接会变化、内容模块顶端与视图顶端对齐-无法控制、不会触发滚动条滚动事件
2、使用Element接口的scrollIntoView()方法,使内容模块滚动到视图范围内。缺点是只有内容模块顶端与视图顶端对齐和内容模块底端和视图底端对齐两种形式-无法做到精确距离控制
const contentDom = document.querySelector(`#content${index}`)
contentDom.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
复制代码
3、原生js写个定时器或用requestAnimationFrame改变滚动条元素的scrollTop,实现类似jquery的animate()动画效果。缺点是需要人为实现较复杂的动画效果、考虑边界和数据处理。
const contentDom = document.querySelector(`#content${index}`)
const scrollDom = document.querySelector(`.layout-main`)
const { TopRange } = this.state
if (this.animateToTop) return
const animateStep = () => {
// 需要滚动的总距离 (滚动距离+1 防止锚点定位不准)
let allRange = contentDom.getBoundingClientRect().top - TopRange + 1
// 当次滚动的距离
let nowRange = allRange / 10
// 剩余要滚动的距离
let nextRange = allRange - nowRange
if (Math.abs(nextRange) > 10) {
// 如果剩余要滚动的距离大于10
let oldScrollTop = scrollDom.scrollTop // 留存未触发动画前滚动条位置
scrollDom.scrollTop += nowRange // 触发滚轮滚动
// 如果滚动条发送变化-执行下一部动画
if (oldScrollTop !== scrollDom.scrollTop) {
window.requestAnimationFrame(animateStep)
} else {
// 滑动完毕
this.animateToTop = false
}
} else {
// 如果剩余要滚动的距离小于10
scrollDom.scrollTop += nowRange + nextRange // 触发滚轮滚动
// 滑动完毕
this.animateToTop = false
}
}
this.animateToTop = true
animateStep()
复制代码
内容模块视图内展示对应的锚点导航高亮
实现方法
1、监听滚动元素滚动事件。遍历内容模块,判断第一个-元素距离视图顶端高度大于(留存高度 || 0)
document.querySelector(`.layout-main`).addEventListener('scroll', (e) => {
const { TopRange } = this.state
// 判断当前显示的内容模块视图对应的锚点
for (let i = 0; i < ModuleList.length; i++) {
const contentDom = document.querySelector(`#content${i}`)
// 当前元素距视图顶端的距离
let domTop = contentDom.getBoundingClientRect().top
// 边界值-1防止点击锚点变换不准确
if ((domTop - 1) > TopRange) {
// 当前边界元素为上一内容模块
const pointSelectIndex = i > 1 ? i - 1 : 0
pointSelectIndex !== this.state.pointSelectIndex &&
this.setState({
pointSelectIndex
})
break
}
}
})
复制代码
2、使用requestAnimationFrame实时判断当前锚点下标
const step = () => {
const { TopRange } = this.state
// 判断当前显示的内容模块视图对应的锚点
for (let i = 0; i < ModuleList.length; i++) {
const contentDom = document.querySelector(`#content${i}`)
// 当前元素距视图顶端的距离
let domTop = contentDom.getBoundingClientRect().top
// 边界值-1防止点击锚点变换不准确
if ((domTop - 1) > TopRange) {
// 当前边界元素为上一内容模块
const pointSelectIndex = i > 1 ? i - 1 : 0
pointSelectIndex !== this.state.pointSelectIndex &&
this.setState({
pointSelectIndex
})
break
}
}
this.requestAnimationFrameInstance = window.requestAnimationFrame(step) // 存储实例
}
step()
复制代码
3、使用IntersectionObserver异步观察内容模块,根据内容模块的展示情况和位置计算当前高亮锚点
let { TopRange } = this.state // 留存高度
// 观察器选项
let options = {
root: document.querySelector('.layout-main'),
threshold: [0, 1],
rootMargin: `-${TopRange}px 0px 0px 0px`
}
// 创建一个观察器
this.ioObserver = new window.IntersectionObserver(function (entries) {
entries.reverse().forEach(function (entry) {
// 元素在观察区域 && 元素的上边距是负值
if (entry.isIntersecting && entry.boundingClientRect.top < TopRange) {
entry.target.active()
}
})
}, options)
// 遍历内容模块,对每一个模块进行观察
ModuleList.forEach((item, index) => {
let element = document.querySelector(`#content${index}`)
element.active = () => {
index !== this.state.pointSelectIndex &&
this.setState(
{
pointSelectIndex: index
}
)
}
// 开始观察
this.ioObserver.observe(element)
复制代码
导航栏开始隐藏时,吸顶
实现方法
1、监听滚动元素滚动事件。判断锚点导航要消失在视图时开始吸顶,判断第一个内容模块元素将要完整展示在视图内时停止吸顶。
document.querySelector(`.layout-main`).addEventListener('scroll', (e) => {
let { TopRange } = this.state // 留存高度
// 判断锚点导航开始吸顶
const navDom = document.querySelector(`#NAV_ANCHOR`)
// 当前元素距视图顶端的距离
let navDomTop = navDom.getBoundingClientRect().top
// 当前导航元素是否将要不在视图内
if (navDomTop <= TopRange) {
!this.state.isNavFixed &&
this.setState({
isNavFixed: true
})
}
// 判断锚点导航停止吸顶
const firstDom = document.querySelector(`#content0`)
// 第一个模块元素距视图顶端的距离
let firstDomTop = firstDom.getBoundingClientRect().top
// 第一个模块元素是否将要完整展示在视图内
if (firstDomTop >= TopRange) {
this.state.isNavFixed &&
this.setState({
isNavFixed: false
})
}
})
复制代码
2、使用requestAnimationFrame实时判断锚点导航栏将要消失在可视范围内
const step = () => {
let { TopRange } = this.state // 留存高度
// 判断锚点导航开始吸顶
const navDom = document.querySelector(`#NAV_ANCHOR`)
// 当前元素距视图顶端的距离
let navDomTop = navDom.getBoundingClientRect().top
// 当前导航元素是否将要不在视图内
if (navDomTop <= TopRange) {
!this.state.isNavFixed &&
this.setState({
isNavFixed: true
})
}
// 判断锚点导航停止吸顶
const firstDom = document.querySelector(`#content0`)
// 第一个模块元素距视图顶端的距离
let firstDomTop = firstDom.getBoundingClientRect().top
// 第一个模块元素是否将要完整展示在视图内
if (firstDomTop >= TopRange) {
this.state.isNavFixed &&
this.setState({
isNavFixed: false
})
}
this.requestAnimationFrameInstance = window.requestAnimationFrame(step) // 存储实例
}
step()
复制代码
3、使用IntersectionObserver异步观察导航栏将要消失在可视区域时,吸顶
let options = {
root: document.querySelector('.layout-main'),
threshold: [0, 1]
}
// 创建一个观察器
this.navObserver = new window.IntersectionObserver(function (entries) {
entries.reverse().forEach(function (entry) {
if (entry.boundingClientRect.top < 0) {
entry.target && entry.target.isNavShow()
} else {
entry.target && entry.target.isNavHidden()
}
})
}, options)
let element = document.querySelector(`#NAV_ANCHOR`)
if (element) {
element.isNavShow = () => {
this.setState({
isNavFixed: true
})
}
element.isNavHidden = () => {
this.setState({
isNavFixed: false
})
}
// 开始观察
this.navObserver.observe(element)
}
复制代码
4、使用position: sticky 粘性布局。当元素在屏幕内,表现为relative,就要滚出显示器屏幕的时候,表现为fixed。
.navigate-wrapper {
position: sticky;
top: 0;
100%;
border-top: 0.5px solid #eeeeee;
border-bottom: 0.5px solid #eeeeee;
background: #fff;
}
复制代码
补充:亲测小米11手机MIUI 12.0.22.0上无问题,iphoneXS Max手机ios系统14.3上有问题,由于产品给用户使用,果然放弃┭┮﹏┭┮
总结
该锚点功能点的实现重点在于监听页面元素滚动改变导航选中态,调研并落地实例发现有三种方式,监听滚动条、实时监听和observer观察元素,三种方式都可以实现该功能,并且在移动端各手机的兼容性没有发现存在问题,大家可以根据实际产品功能选择使用一种方式来实现。