Snap the header scroll on the web
This commit is contained in:
@@ -1,17 +1,28 @@
|
||||
import React, {createContext, useContext, useMemo} from 'react'
|
||||
import {ScrollHandlers} from 'react-native-reanimated'
|
||||
import {ScrollHandler} from 'react-native-reanimated'
|
||||
import {NativeScrollEvent} from 'react-native'
|
||||
|
||||
const ScrollContext = createContext<ScrollHandlers<any>>({
|
||||
type ScrollHandlers = {
|
||||
onBeginDrag: undefined | ScrollHandler
|
||||
onEndDrag: undefined | ScrollHandler<any>
|
||||
onScroll: undefined | ScrollHandler<any>
|
||||
onScrollEndWeb:
|
||||
| undefined
|
||||
| ((e: Pick<NativeScrollEvent, 'contentOffset'>) => void) // Web-only.
|
||||
}
|
||||
|
||||
const ScrollContext = createContext<ScrollHandlers>({
|
||||
onBeginDrag: undefined,
|
||||
onEndDrag: undefined,
|
||||
onScroll: undefined,
|
||||
onScrollEndWeb: undefined,
|
||||
})
|
||||
|
||||
export function useScrollHandlers(): ScrollHandlers<any> {
|
||||
export function useScrollHandlers(): ScrollHandlers {
|
||||
return useContext(ScrollContext)
|
||||
}
|
||||
|
||||
type ProviderProps = {children: React.ReactNode} & ScrollHandlers<any>
|
||||
type ProviderProps = {children: React.ReactNode} & ScrollHandlers
|
||||
|
||||
// Note: this completely *overrides* the parent handlers.
|
||||
// It's up to you to compose them with the parent ones via useScrollHandlers() if needed.
|
||||
@@ -20,14 +31,16 @@ export function ScrollProvider({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
onScrollEndWeb,
|
||||
}: ProviderProps) {
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
onScrollEndWeb,
|
||||
}),
|
||||
[onBeginDrag, onEndDrag, onScroll],
|
||||
[onBeginDrag, onEndDrag, onScroll, onScrollEndWeb],
|
||||
)
|
||||
return (
|
||||
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
|
||||
|
||||
@@ -109,7 +109,7 @@ function ListImpl<ItemT>(
|
||||
const containerRef = useRef(null)
|
||||
useResizeObserver(containerRef, onContentSizeChange)
|
||||
|
||||
// --- onScroll ---
|
||||
// --- onScroll & onScrollEndWeb ---
|
||||
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
|
||||
const handleWindowScroll = useNonReactiveCallback(() => {
|
||||
if (isInsideVisibleTree) {
|
||||
@@ -124,6 +124,16 @@ function ListImpl<ItemT>(
|
||||
)
|
||||
}
|
||||
})
|
||||
const handleWindowScrollEnd = useNonReactiveCallback(() => {
|
||||
if (isInsideVisibleTree) {
|
||||
contextScrollHandlers.onScrollEndWeb?.({
|
||||
contentOffset: {
|
||||
x: window.scrollX,
|
||||
y: window.scrollY,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (!isInsideVisibleTree) {
|
||||
// Prevents hidden tabs from firing scroll events.
|
||||
@@ -131,8 +141,12 @@ function ListImpl<ItemT>(
|
||||
return
|
||||
}
|
||||
window.addEventListener('scroll', handleWindowScroll)
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll)
|
||||
}, [isInsideVisibleTree, handleWindowScroll])
|
||||
window.addEventListener('scrollend', handleWindowScrollEnd)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleWindowScroll)
|
||||
window.removeEventListener('scrollend', handleWindowScrollEnd)
|
||||
}
|
||||
}, [isInsideVisibleTree, handleWindowScroll, handleWindowScrollEnd])
|
||||
|
||||
// --- onScrolledDownChange ---
|
||||
const isScrolledDown = useRef(false)
|
||||
|
||||
@@ -28,6 +28,21 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
}
|
||||
})
|
||||
|
||||
const snapToClosestMode = React.useCallback(
|
||||
(scrollY: number) => {
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
if (scrollY < headerHeight.value / 2) {
|
||||
// If we're close to the top, show the shell.
|
||||
setMode(false)
|
||||
} else {
|
||||
// Snap to whichever state is the closest.
|
||||
setMode(Math.round(mode.value) === 1)
|
||||
}
|
||||
},
|
||||
[startDragOffset, startMode, headerHeight, mode, setMode],
|
||||
)
|
||||
|
||||
const onBeginDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
@@ -40,17 +55,19 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const onEndDrag = useCallback(
|
||||
(e: NativeScrollEvent) => {
|
||||
'worklet'
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
if (e.contentOffset.y < headerHeight.value / 2) {
|
||||
// If we're close to the top, show the shell.
|
||||
setMode(false)
|
||||
} else {
|
||||
// Snap to whichever state is the closest.
|
||||
setMode(Math.round(mode.value) === 1)
|
||||
}
|
||||
snapToClosestMode(e.contentOffset.y)
|
||||
},
|
||||
[startDragOffset, startMode, setMode, mode, headerHeight],
|
||||
[snapToClosestMode],
|
||||
)
|
||||
|
||||
// On the web, we don't get the begin/end drag events,
|
||||
// but we still need to expand or collapse the header.
|
||||
const onScrollEndWeb = useCallback(
|
||||
(e: Pick<NativeScrollEvent, 'contentOffset'>) => {
|
||||
'worklet'
|
||||
snapToClosestMode(e.contentOffset.y)
|
||||
},
|
||||
[snapToClosestMode],
|
||||
)
|
||||
|
||||
const onScroll = useCallback(
|
||||
@@ -100,7 +117,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
<ScrollProvider
|
||||
onBeginDrag={onBeginDrag}
|
||||
onEndDrag={onEndDrag}
|
||||
onScroll={onScroll}>
|
||||
onScroll={onScroll}
|
||||
onScrollEndWeb={onScrollEndWeb}>
|
||||
{children}
|
||||
</ScrollProvider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user