Snap the header scroll on the web
This commit is contained in:
@@ -1,17 +1,28 @@
|
|||||||
import React, {createContext, useContext, useMemo} from 'react'
|
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,
|
onBeginDrag: undefined,
|
||||||
onEndDrag: undefined,
|
onEndDrag: undefined,
|
||||||
onScroll: undefined,
|
onScroll: undefined,
|
||||||
|
onScrollEndWeb: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function useScrollHandlers(): ScrollHandlers<any> {
|
export function useScrollHandlers(): ScrollHandlers {
|
||||||
return useContext(ScrollContext)
|
return useContext(ScrollContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderProps = {children: React.ReactNode} & ScrollHandlers<any>
|
type ProviderProps = {children: React.ReactNode} & ScrollHandlers
|
||||||
|
|
||||||
// Note: this completely *overrides* the parent handlers.
|
// Note: this completely *overrides* the parent handlers.
|
||||||
// It's up to you to compose them with the parent ones via useScrollHandlers() if needed.
|
// It's up to you to compose them with the parent ones via useScrollHandlers() if needed.
|
||||||
@@ -20,14 +31,16 @@ export function ScrollProvider({
|
|||||||
onBeginDrag,
|
onBeginDrag,
|
||||||
onEndDrag,
|
onEndDrag,
|
||||||
onScroll,
|
onScroll,
|
||||||
|
onScrollEndWeb,
|
||||||
}: ProviderProps) {
|
}: ProviderProps) {
|
||||||
const handlers = useMemo(
|
const handlers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
onBeginDrag,
|
onBeginDrag,
|
||||||
onEndDrag,
|
onEndDrag,
|
||||||
onScroll,
|
onScroll,
|
||||||
|
onScrollEndWeb,
|
||||||
}),
|
}),
|
||||||
[onBeginDrag, onEndDrag, onScroll],
|
[onBeginDrag, onEndDrag, onScroll, onScrollEndWeb],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
|
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ function ListImpl<ItemT>(
|
|||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
useResizeObserver(containerRef, onContentSizeChange)
|
useResizeObserver(containerRef, onContentSizeChange)
|
||||||
|
|
||||||
// --- onScroll ---
|
// --- onScroll & onScrollEndWeb ---
|
||||||
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
|
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
|
||||||
const handleWindowScroll = useNonReactiveCallback(() => {
|
const handleWindowScroll = useNonReactiveCallback(() => {
|
||||||
if (isInsideVisibleTree) {
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!isInsideVisibleTree) {
|
if (!isInsideVisibleTree) {
|
||||||
// Prevents hidden tabs from firing scroll events.
|
// Prevents hidden tabs from firing scroll events.
|
||||||
@@ -131,8 +141,12 @@ function ListImpl<ItemT>(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.addEventListener('scroll', handleWindowScroll)
|
window.addEventListener('scroll', handleWindowScroll)
|
||||||
return () => window.removeEventListener('scroll', handleWindowScroll)
|
window.addEventListener('scrollend', handleWindowScrollEnd)
|
||||||
}, [isInsideVisibleTree, handleWindowScroll])
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleWindowScroll)
|
||||||
|
window.removeEventListener('scrollend', handleWindowScrollEnd)
|
||||||
|
}
|
||||||
|
}, [isInsideVisibleTree, handleWindowScroll, handleWindowScrollEnd])
|
||||||
|
|
||||||
// --- onScrolledDownChange ---
|
// --- onScrolledDownChange ---
|
||||||
const isScrolledDown = useRef(false)
|
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(
|
const onBeginDrag = useCallback(
|
||||||
(e: NativeScrollEvent) => {
|
(e: NativeScrollEvent) => {
|
||||||
'worklet'
|
'worklet'
|
||||||
@@ -40,17 +55,19 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
const onEndDrag = useCallback(
|
const onEndDrag = useCallback(
|
||||||
(e: NativeScrollEvent) => {
|
(e: NativeScrollEvent) => {
|
||||||
'worklet'
|
'worklet'
|
||||||
startDragOffset.value = null
|
snapToClosestMode(e.contentOffset.y)
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[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(
|
const onScroll = useCallback(
|
||||||
@@ -100,7 +117,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
<ScrollProvider
|
<ScrollProvider
|
||||||
onBeginDrag={onBeginDrag}
|
onBeginDrag={onBeginDrag}
|
||||||
onEndDrag={onEndDrag}
|
onEndDrag={onEndDrag}
|
||||||
onScroll={onScroll}>
|
onScroll={onScroll}
|
||||||
|
onScrollEndWeb={onScrollEndWeb}>
|
||||||
{children}
|
{children}
|
||||||
</ScrollProvider>
|
</ScrollProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user