119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useEffect, useState, useRef, RefObject } from 'react';
|
|||
|
|
|
|||
|
|
interface UseStickyOptions {
|
|||
|
|
/**
|
|||
|
|
* 粘性触发的偏移量(相对于容器顶部)
|
|||
|
|
* 默认为 0
|
|||
|
|
*/
|
|||
|
|
offset?: number;
|
|||
|
|
/**
|
|||
|
|
* 抖动防护的滞后容差(像素)。
|
|||
|
|
* 会将触发边界向下偏移,避免 0 边界来回切换。
|
|||
|
|
* 默认为 2
|
|||
|
|
*/
|
|||
|
|
hysteresis?: number;
|
|||
|
|
/**
|
|||
|
|
* 根元素,用于观察交叉状态
|
|||
|
|
* 默认为 null(使用视口)
|
|||
|
|
*/
|
|||
|
|
root?: Element | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 检测元素是否处于 sticky 状态的 hook
|
|||
|
|
*
|
|||
|
|
* @param options 配置选项
|
|||
|
|
* @returns [ref, isSticky] - 需要绑定到目标元素的 ref 和 sticky 状态
|
|||
|
|
*/
|
|||
|
|
export function useSticky<T extends HTMLElement = HTMLDivElement>(
|
|||
|
|
options: UseStickyOptions = {}
|
|||
|
|
): [RefObject<T | null>, boolean] {
|
|||
|
|
const { offset = 0, hysteresis = 2, root = null } = options;
|
|||
|
|
const elementRef = useRef<T>(null);
|
|||
|
|
const [isSticky, setIsSticky] = useState(false);
|
|||
|
|
const rafIdRef = useRef<number | null>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const element = elementRef.current;
|
|||
|
|
if (!element) return;
|
|||
|
|
|
|||
|
|
// 如果没有指定 root,尝试自动查找滚动容器
|
|||
|
|
let scrollContainer = root;
|
|||
|
|
if (!scrollContainer) {
|
|||
|
|
// 查找具有 overflow 属性的父容器或 main-content 容器
|
|||
|
|
scrollContainer = document.getElementById('main-content') || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 创建一个哨兵元素来检测 sticky 状态
|
|||
|
|
const sentinel = document.createElement('div');
|
|||
|
|
sentinel.style.position = 'relative';
|
|||
|
|
sentinel.style.top = `${offset}px`; // 设置与 sticky offset 相同的位置
|
|||
|
|
sentinel.style.left = '0';
|
|||
|
|
sentinel.style.width = '1px';
|
|||
|
|
sentinel.style.height = '1px';
|
|||
|
|
sentinel.style.pointerEvents = 'none';
|
|||
|
|
sentinel.style.visibility = 'hidden';
|
|||
|
|
|
|||
|
|
// 将哨兵元素插入到目标元素之前
|
|||
|
|
element.parentNode?.insertBefore(sentinel, element);
|
|||
|
|
|
|||
|
|
const observer = new IntersectionObserver(
|
|||
|
|
([entry]) => {
|
|||
|
|
// 使用 rAF 合并更新,避免高频切换
|
|||
|
|
if (rafIdRef.current !== null) {
|
|||
|
|
cancelAnimationFrame(rafIdRef.current);
|
|||
|
|
}
|
|||
|
|
rafIdRef.current = requestAnimationFrame(() => {
|
|||
|
|
// 获取滚动容器的滚动位置
|
|||
|
|
const container = scrollContainer as HTMLElement;
|
|||
|
|
const scrollTop = container ? container.scrollTop : window.scrollY;
|
|||
|
|
|
|||
|
|
// 获取元素相对于容器的位置
|
|||
|
|
const elementRect = element.getBoundingClientRect();
|
|||
|
|
const containerRect = container ? container.getBoundingClientRect() : { top: 0 };
|
|||
|
|
|
|||
|
|
// 计算元素相对于容器顶部的距离
|
|||
|
|
const elementTopRelativeToContainer = elementRect.top - containerRect.top;
|
|||
|
|
|
|||
|
|
// 只有当元素顶部到达或超过 offset 位置,并且哨兵不可见时,才认为是 sticky
|
|||
|
|
// 同时确保滚动位置大于元素初始位置
|
|||
|
|
const nextIsSticky = !entry.isIntersecting &&
|
|||
|
|
elementTopRelativeToContainer <= offset + hysteresis &&
|
|||
|
|
scrollTop > 0;
|
|||
|
|
|
|||
|
|
// 仅在状态变化时更新,减少重复渲染
|
|||
|
|
setIsSticky(prev => (prev !== nextIsSticky ? nextIsSticky : prev));
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
root: scrollContainer,
|
|||
|
|
// 在向下滚动触发点加入滞后容差,远离 0 边界,减少抖动
|
|||
|
|
rootMargin: `-${offset + Math.max(0, Math.floor(hysteresis))}px 0px 0px 0px`,
|
|||
|
|
threshold: 0
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
observer.observe(sentinel);
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
observer.disconnect();
|
|||
|
|
sentinel.remove();
|
|||
|
|
if (rafIdRef.current !== null) {
|
|||
|
|
cancelAnimationFrame(rafIdRef.current);
|
|||
|
|
rafIdRef.current = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}, [offset, hysteresis, root]);
|
|||
|
|
|
|||
|
|
return [elementRef, isSticky];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 简化版本的 useSticky hook,使用默认配置
|
|||
|
|
*/
|
|||
|
|
export function useStickySimple<T extends HTMLElement = HTMLDivElement>(): [RefObject<T | null>, boolean] {
|
|||
|
|
return useSticky<T>();
|
|||
|
|
}
|