From e582713e23cb8845c123bf301392eaa870617d39 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 23 Dec 2023 18:16:44 +0000 Subject: [PATCH] Snap the header scroll on the web --- src/lib/ScrollContext.tsx | 23 +++++++++++--- src/view/com/util/List.web.tsx | 20 ++++++++++-- src/view/com/util/MainScrollProvider.tsx | 40 +++++++++++++++++------- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/lib/ScrollContext.tsx b/src/lib/ScrollContext.tsx index 00b197bedc..447db74e0e 100644 --- a/src/lib/ScrollContext.tsx +++ b/src/lib/ScrollContext.tsx @@ -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>({ +type ScrollHandlers = { + onBeginDrag: undefined | ScrollHandler + onEndDrag: undefined | ScrollHandler + onScroll: undefined | ScrollHandler + onScrollEndWeb: + | undefined + | ((e: Pick) => void) // Web-only. +} + +const ScrollContext = createContext({ onBeginDrag: undefined, onEndDrag: undefined, onScroll: undefined, + onScrollEndWeb: undefined, }) -export function useScrollHandlers(): ScrollHandlers { +export function useScrollHandlers(): ScrollHandlers { return useContext(ScrollContext) } -type ProviderProps = {children: React.ReactNode} & ScrollHandlers +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 ( {children} diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 10c515f2ac..d29b912b19 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -109,7 +109,7 @@ function ListImpl( 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( ) } }) + 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( 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) diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 15aefb5659..d3dd185f02 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -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) => { + 'worklet' + snapToClosestMode(e.contentOffset.y) + }, + [snapToClosestMode], ) const onScroll = useCallback( @@ -100,7 +117,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { + onScroll={onScroll} + onScrollEndWeb={onScrollEndWeb}> {children} )