跳到主要内容

2 篇博文 含有标签「scroll」

查看所有标签

· 阅读需 3 分钟

记录下聊天页面与滚动相关的两个功能的实现方案

  • 滚动至顶部加载更多
  • 自动滚动至新消息

封装 useChatScroll hook

接收聊天页面容器 div、bottom div(chat 最下方的空 div)、shouldLoadMore、loadMore 方法、count(数据总数)五个参数

useChatScroll({
chatRef,
bottomRef,
// useChatQuery hook 返回的请求更多的方法
loadMore: fetchNextPage,
// 没有正在请求下页数据,并且存在下页数据时,可以加载更多
shouldLoadMore: !isFetchingNextPage && !!hasNextPage,
count: data?.pages?.[0]?.items.length ?? 0,
})

加载更多

该 Hook 中主要有两个 useEffect 组成

第一个主要负责监控聊天框的滚动事件。当容器 div 的滚动条位于顶部(scrollTop = 0)且可以加载更多时,调用 loadMore 即可;

useEffect(() => {
const topDiv = chatRef?.current

const handleScroll = () => {
const scrollTop = topDiv?.scrollTop

if (scrollTop === 0 && shouldLoadMore)
loadMore()
}

topDiv?.addEventListener('scroll', handleScroll)

return () => topDiv?.removeEventListener('scroll', handleScroll)
}, [shouldLoadMore, loadMore, chatRef])

自动滚动至新消息

第二个 useEffect 主要负责自动滚动聊天框以保持最新的消息可见

使用 hasInitialized state 作为标记,第一次渲染时将其设置为 true,并执行一次 autoScroll(以确保在初始化时自动滚动)

autoScroll 就是将 bottom div scrollIntoView

  • 优化

后续当 count 变化时,检查聊天视口底部到容器底部的距离(总滚动高度 - 滚动过的距离 - 可视高度)。如果距离小于等于100像素,表示接近底部,此时执行 autoScroll。防止当用户在浏览历史信息时,突然滚动到最下方

const [hasInitialized, sethasInitialized] = useState(false)

useEffect(() => {
const bottomDiv = bottomRef?.current
const topDiv = chatRef.current
const shouldAutoScroll = () => {
if (!hasInitialized && bottomDiv) {
sethasInitialized(true)
return true
}
if (!topDiv)
return false

const distanceFromBottom = topDiv.scrollHeight - topDiv.scrollTop - topDiv.clientHeight
return distanceFromBottom <= 100
}

if (shouldAutoScroll()) {
setTimeout(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
})
}, 100)
}
}, [bottomRef, chatRef, count, hasInitialized])

· 阅读需 5 分钟

组件源码:https://github.com/GSemir0418/account-app-vite/tree/main/src

综述

使用 CSS Scroll SnapIntersection Observer API 实现移动端的 Tab 页签移动效果

本质上就是将 Tab 页面横向排列,使用原生的滚动效果实现页签的切换,使用以上两种技术来处理交互

类型定义

接受 tabList 列表,每一项分别是一个 tab 页面与按钮属性的配置

接受 defaultKey 作为默认展示的 tab,这里使用泛型限制 defaultKey 要属于 tab.key 属性的集合

interface TabProps<T extends React.Key> {
tabList: {
key: T
label1: string
label2: string
className: string
value: TagSummary[]
}[]
defaultKey: T
}

布局

整体为 flex 上下布局,上面是 tab 按钮,剩下是 tab 页面

<div className="flex flex-col">
<div className="w-full">
tab 按钮栏
</div>
<div className="flex-1 w-full relative">
tab 页面
</div>
</div>
  • 页面横向排列,触发横向滚动

这里将外部 tab 容器设置为 flex 布局,支持滚动,内部页面设置最小宽度为 100 %

<div className="flex-1 w-full relative flex overflow-auto">
{tabList.map(tab => (
<div key={tab.key} className="h-full min-w-full">
这里是 tab 内容
</div>
))}
</div>
  • 设置 CSS Snap Scroll
<div className="flex-1 w-full relative flex overflow-auto snap-x snap-mandatory">
{tabList.map(tab => (
<div key={tab.key} className="h-full min-w-full snap-center snap-normal">
这里是 tab 内容
</div>
))}
</div>

其中父元素的 scroll-snap-type: x mandatory; 表示父容器内的滚动操作(仅限水平方向)应当强制与子元素的某个对齐点对齐

除了 xscroll-snap-type 还可以设置为 y 以应用于垂直滚动,或者 both 来同时应用于水平和垂直滚动

mandatory 相对的还有一个值 proximityproximity 不强制滚动停止时进行对齐,只是在合适时尝试对齐,提供更灵活的用户体验。

子元素设置 scroll-snap-align: center; 来指定这些元素应当在滚动捕捉时居中对齐。也就是说,当用户停止滚动后,这些子元素会自动调整位置,以使其位于滚动容器的中心位置。在实现轮播图、画廊等功能时非常有用,它确保了用户在滑动结束时总能看到完整的项目而不是部分被截断的内容;

scroll-snap-stop: normal 指定元素在滚动捕捉点时应当保持正常的滚动行为。换句话说,当元素滚动接近捕捉点时,滚动不会被强制停止在捕捉点上,而是允许用户在捕捉点周围进行自由的滚动

交互

主要用来处理 tab 页与 tab 按钮之间的联动

  • 页面 => 按钮

使用 Intersection Observer API + useRef + useEffect 实现,注意要给 tab 页面设置 id

export function Tab<T extends React.Key>(props: TabProps<T>) {
const { defaultKey, tabList } = props
const [activeKey, setActiveKey] = useState(defaultKey)
// 使用 ref 维护 observer 实例
const observer = useRef<IntersectionObserver>()
useEffect(() => {
// 初始化 IntersectionObserver 实例,监听元素交叉状态
observer.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 将交叉的元素设置为当前活动的 key
if (entry.isIntersecting)
setActiveKey(entry.target.id as T)
})
},
// 监听元素可见度达到 50% 时触发回调
{ threshold: 0.5 },
)
// 根据 id 查找并监听每个 tab 元素的交叉情况
tabList.forEach((tab) => {
observer.current?.observe(document.getElementById(tab.key as string)!)
})

// 清理,释放
return () => {
observer.current?.disconnect()
observer.current = undefined
}
}, [])

return (
<div className="...">
<div className="...">
{tabList.map(tab => (
<span
key={tab.key}
className={`${activeKey === tab.key ? '...' : '...'}`}
>
{tab.label1}
<span className={`${tab.className} text-sm`}>{tab.label2}</span>
</span>
))}
</div>
<div className="...">
{tabList.map(tab => (
<div key={tab.key} id={tab.key} className="...">
这里是 tab 内容
</div>
))}
</div>
</div>
)
}
  • 按钮 => 页面

给按钮绑定点击事件,传入 key,根据 id 找到改 tab 元素,执行 scrollIntoView 方法即可

interface TabProps<T extends React.Key> {}

export function Tab<T extends React.Key>(props: TabProps<T>) {
// ...

const handleTabClick = (key: T) => {
// scrollIntoView
document.getElementById(tabList.find(tab => tab.key === key)?.key as string)?.scrollIntoView({ behavior: 'smooth', block: 'end' })
// 同步设置为当前活动的 key
setActiveKey(key)
}

return (
<div className="...">
<div className="...">
{tabList.map(tab => (
<span
key={tab.key}
className={`...`}
onClick={() => handleTabClick(tab.key as T)}
>
{tab.label1}
<span className={`...`}>{tab.label2}</span>
</span>
))}
</div>
<div className="...">
{tabList.map(tab => (
<div key={tab.key} id={tab.key} className="...">
这里是 tab 内容
</div>
))}
</div>
</div>
)
}

至此,完成 Tab 组件的封装