背景
近一段時間一直在做移動端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;
width: 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觀察元素,三種方式都可以實現該功能,並且在移動端各手機的兼容性沒有發現存在問題,大家可以根據實際產品功能選擇使用一種方式來實現。