diff options
author | Hailey <me@haileyok.com> | 2024-05-06 08:34:32 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-05-06 08:34:32 -0700 |
commit | bc070199114a4cf5971dea09c381ce84a2c78d5f (patch) | |
tree | 8dcbf8a4defb687cf587acea5801a4dfb5118f73 | |
parent | 594b40c3ae8d70e4a49148b4630cb2b78cd71a9b (diff) | |
download | voidsky-bc070199114a4cf5971dea09c381ce84a2c78d5f.tar.zst |
Further align web `List` with `FlatList`, add `contain` mode to web list implementation (#3867)
* add `onStartReached` to web list * fix `rootMargin` * Add `contain`, handle scroll events * improve types, fix typo * simplify * adjust `scrollToTop` and `scrollToOffset` to support `contain`, add `scrollToEnd` * rename `handleWindowScroll` to `handleScroll` * support basic `maintainVisibleContentPosition` * rename `contain` to `containWeb` * remove unnecessary `flex: 1` * add missing props * add root prop to `Visibility` * add root prop to `Visibility` * revert adding `maintainVisibleContentPosition` * oops * always apply `flex: 1` to styles when contained * add a contained list to storybook * make `onScroll` a worklet in storybook * revert test code * add scrolling to storybook * simplify getting scrollable node * nit: extra whitespace * nit: random comment * foolproof the logic * typecheck
-rw-r--r-- | src/view/com/util/List.tsx | 1 | ||||
-rw-r--r-- | src/view/com/util/List.web.tsx | 132 | ||||
-rw-r--r-- | src/view/screens/Storybook/ListContained.tsx | 98 | ||||
-rw-r--r-- | src/view/screens/Storybook/index.tsx | 164 |
4 files changed, 310 insertions, 85 deletions
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index ff60e94cd..61dc5f81d 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -25,6 +25,7 @@ export type ListProps<ItemT> = Omit< headerOffset?: number refreshing?: boolean onRefresh?: () => void + containWeb?: boolean } export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 02564e1e1..e5c427f13 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -1,5 +1,6 @@ import React, {isValidElement, memo, startTransition, useRef} from 'react' import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' +import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' import {batchedUpdates} from '#/lib/batchedUpdates' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -20,6 +21,7 @@ export type ListProps<ItemT> = Omit< refreshing?: boolean onRefresh?: () => void desktopFixedHeight: any // TODO: Better types. + containWeb?: boolean } export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. @@ -27,6 +29,7 @@ function ListImpl<ItemT>( { ListHeaderComponent, ListFooterComponent, + containWeb, contentContainerStyle, data, desktopFixedHeight, @@ -83,13 +86,62 @@ function ListImpl<ItemT>( }) } + const getScrollableNode = React.useCallback(() => { + if (containWeb) { + const element = nativeRef.current as HTMLDivElement | null + if (!element) return + + return { + scrollWidth: element.scrollWidth, + scrollHeight: element.scrollHeight, + clientWidth: element.clientWidth, + clientHeight: element.clientHeight, + scrollY: element.scrollTop, + scrollX: element.scrollLeft, + scrollTo(options?: ScrollToOptions) { + element.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + element.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + element.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + element.removeEventListener(event, handler) + }, + } + } else { + return { + scrollWidth: document.documentElement.scrollWidth, + scrollHeight: document.documentElement.scrollHeight, + clientWidth: window.innerWidth, + clientHeight: window.innerHeight, + scrollY: window.scrollY, + scrollX: window.scrollX, + scrollTo(options: ScrollToOptions) { + window.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + window.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + window.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + window.removeEventListener(event, handler) + }, + } + } + }, [containWeb]) + const nativeRef = React.useRef(null) React.useImperativeHandle( ref, () => ({ scrollToTop() { - window.scrollTo({top: 0}) + getScrollableNode()?.scrollTo({top: 0}) }, scrollToOffset({ animated, @@ -98,46 +150,74 @@ function ListImpl<ItemT>( animated: boolean offset: number }) { - window.scrollTo({ + getScrollableNode()?.scrollTo({ left: 0, top: offset, behavior: animated ? 'smooth' : 'instant', }) }, + scrollToEnd({animated = true}: {animated?: boolean}) { + const element = getScrollableNode() + element?.scrollTo({ + left: 0, + top: element.scrollHeight, + behavior: animated ? 'smooth' : 'instant', + }) + }, } as any), // TODO: Better types. - [], + [getScrollableNode], ) - // --- onContentSizeChange --- + // --- onContentSizeChange, maintainVisibleContentPosition --- const containerRef = useRef(null) useResizeObserver(containerRef, onContentSizeChange) // --- onScroll --- const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) - const handleWindowScroll = useNonReactiveCallback(() => { - if (isInsideVisibleTree) { - contextScrollHandlers.onScroll?.( - { - contentOffset: { - x: Math.max(0, window.scrollX), - y: Math.max(0, window.scrollY), - }, - } as any, // TODO: Better types. - null as any, - ) - } + const handleScroll = useNonReactiveCallback(() => { + if (!isInsideVisibleTree) return + + const element = getScrollableNode() + contextScrollHandlers.onScroll?.( + { + contentOffset: { + x: Math.max(0, element?.scrollX ?? 0), + y: Math.max(0, element?.scrollY ?? 0), + }, + layoutMeasurement: { + width: element?.clientWidth, + height: element?.clientHeight, + }, + contentSize: { + width: element?.scrollWidth, + height: element?.scrollHeight, + }, + } as Exclude< + ReanimatedScrollEvent, + | 'velocity' + | 'eventName' + | 'zoomScale' + | 'targetContentOffset' + | 'contentInset' + >, + null as any, + ) }) + React.useEffect(() => { if (!isInsideVisibleTree) { // Prevents hidden tabs from firing scroll events. // Only one list is expected to be firing these at a time. return } - window.addEventListener('scroll', handleWindowScroll) + + const element = getScrollableNode() + + element?.addEventListener('scroll', handleScroll) return () => { - window.removeEventListener('scroll', handleWindowScroll) + element?.removeEventListener('scroll', handleScroll) } - }, [isInsideVisibleTree, handleWindowScroll]) + }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode]) // --- onScrolledDownChange --- const isScrolledDown = useRef(false) @@ -174,7 +254,11 @@ function ListImpl<ItemT>( ) return ( - <View {...props} style={style} ref={nativeRef}> + <View + {...props} + // @ts-ignore web only + style={[style, containWeb && {flex: 1, 'overflow-y': 'scroll'}]} + ref={nativeRef}> <Visibility onVisibleChange={setIsInsideVisibleTree} style={ @@ -192,11 +276,13 @@ function ListImpl<ItemT>( pal.border, ]}> <Visibility + root={containWeb ? nativeRef.current : null} onVisibleChange={handleAboveTheFoldVisibleChange} style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> {onStartReached && ( <Visibility + root={containWeb ? nativeRef.current : null} onVisibleChange={onHeadVisibilityChange} topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} /> @@ -213,6 +299,7 @@ function ListImpl<ItemT>( ))} {onEndReached && ( <Visibility + root={containWeb ? nativeRef.current : null} onVisibleChange={onTailVisibilityChange} bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} /> @@ -275,11 +362,13 @@ let Row = function RowImpl<ItemT>({ Row = React.memo(Row) let Visibility = ({ + root = null, topMargin = '0px', bottomMargin = '0px', onVisibleChange, style, }: { + root?: Element | null topMargin?: string bottomMargin?: string onVisibleChange: (isVisible: boolean) => void @@ -303,6 +392,7 @@ let Visibility = ({ React.useEffect(() => { const observer = new IntersectionObserver(handleIntersection, { + root, rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, }) const tail: Element | null = tailRef.current! @@ -310,7 +400,7 @@ let Visibility = ({ return () => { observer.unobserve(tail) } - }, [bottomMargin, handleIntersection, topMargin]) + }, [bottomMargin, handleIntersection, topMargin, root]) return ( <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> diff --git a/src/view/screens/Storybook/ListContained.tsx b/src/view/screens/Storybook/ListContained.tsx new file mode 100644 index 000000000..c4e06efb2 --- /dev/null +++ b/src/view/screens/Storybook/ListContained.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import {FlatList, View} from 'react-native' + +import {ScrollProvider} from 'lib/ScrollContext' +import {List} from 'view/com/util/List' +import {Button, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +export function ListContained() { + const [animated, setAnimated] = React.useState(false) + const ref = React.useRef<FlatList>(null) + + const data = React.useMemo(() => { + return Array.from({length: 100}, (_, i) => ({ + id: i, + text: `Message ${i}`, + })) + }, []) + + return ( + <> + <View style={{width: '100%', height: 300}}> + <ScrollProvider + onScroll={() => { + 'worklet' + console.log('onScroll') + }}> + <List + data={data} + renderItem={item => { + return ( + <View + style={{ + padding: 10, + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }}> + <Text>{item.item.text}</Text> + </View> + ) + }} + keyExtractor={item => item.id.toString()} + containWeb={true} + style={{flex: 1}} + onStartReached={() => { + console.log('Start Reached') + }} + onEndReached={() => { + console.log('End Reached (threshold of 2)') + }} + onEndReachedThreshold={2} + ref={ref} + disableVirtualization={true} + /> + </ScrollProvider> + </View> + + <View style={{flexDirection: 'row', gap: 10, alignItems: 'center'}}> + <Toggle.Item + name="a" + label="Click me" + value={animated} + onChange={() => setAnimated(prev => !prev)}> + <Toggle.Checkbox /> + <Toggle.LabelText>Animated Scrolling</Toggle.LabelText> + </Toggle.Item> + </View> + + <Button + variant="solid" + color="primary" + size="large" + label="Scroll to End" + onPress={() => ref.current?.scrollToOffset({animated, offset: 0})}> + <ButtonText>Scroll to Top</ButtonText> + </Button> + + <Button + variant="solid" + color="primary" + size="large" + label="Scroll to End" + onPress={() => ref.current?.scrollToEnd({animated})}> + <ButtonText>Scroll to End</ButtonText> + </Button> + + <Button + variant="solid" + color="primary" + size="large" + label="Scroll to Offset 100" + onPress={() => ref.current?.scrollToOffset({animated, offset: 500})}> + <ButtonText>Scroll to Offset 500</ButtonText> + </Button> + </> + ) +} diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 35a666601..282b3ff5c 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -1,8 +1,10 @@ import React from 'react' -import {View} from 'react-native' +import {ScrollView, View} from 'react-native' import {useSetThemePrefs} from '#/state/shell' -import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {isWeb} from 'platform/detection' +import {CenteredView} from '#/view/com/util/Views' +import {ListContained} from 'view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Breakpoints} from './Breakpoints' @@ -18,77 +20,111 @@ import {Theming} from './Theming' import {Typography} from './Typography' export function Storybook() { + if (isWeb) return <StorybookInner /> + + return ( + <ScrollView> + <StorybookInner /> + </ScrollView> + ) +} + +function StorybookInner() { const t = useTheme() const {setColorMode, setDarkTheme} = useSetThemePrefs() + const [showContainedList, setShowContainedList] = React.useState(false) return ( - <ScrollView> - <CenteredView style={[t.atoms.bg]}> - <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> - <View style={[a.flex_row, a.align_start, a.gap_md]}> - <Button - variant="outline" - color="primary" - size="small" - label='Set theme to "system"' - onPress={() => setColorMode('system')}> - <ButtonText>System</ButtonText> - </Button> - <Button - variant="solid" - color="secondary" - size="small" - label='Set theme to "light"' - onPress={() => setColorMode('light')}> - <ButtonText>Light</ButtonText> - </Button> + <CenteredView style={[t.atoms.bg]}> + <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> + {!showContainedList ? ( + <> + <View style={[a.flex_row, a.align_start, a.gap_md]}> + <Button + variant="outline" + color="primary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('system')}> + <ButtonText>System</ButtonText> + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "light"' + onPress={() => setColorMode('light')}> + <ButtonText>Light</ButtonText> + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "dim"' + onPress={() => { + setColorMode('dark') + setDarkTheme('dim') + }}> + <ButtonText>Dim</ButtonText> + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "dark"' + onPress={() => { + setColorMode('dark') + setDarkTheme('dark') + }}> + <ButtonText>Dark</ButtonText> + </Button> + </View> + + <Dialogs /> + <ThemeProvider theme="light"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dim"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dark"> + <Theming /> + </ThemeProvider> + + <Typography /> + <Spacing /> + <Shadows /> + <Buttons /> + <Icons /> + <Links /> + <Forms /> + <Dialogs /> + <Menus /> + <Breakpoints /> + <Button variant="solid" - color="secondary" - size="small" - label='Set theme to "dim"' - onPress={() => { - setColorMode('dark') - setDarkTheme('dim') - }}> - <ButtonText>Dim</ButtonText> + color="primary" + size="large" + label="Switch to Contained List" + onPress={() => setShowContainedList(true)}> + <ButtonText>Switch to Contained List</ButtonText> </Button> + </> + ) : ( + <> <Button variant="solid" - color="secondary" - size="small" - label='Set theme to "dark"' - onPress={() => { - setColorMode('dark') - setDarkTheme('dark') - }}> - <ButtonText>Dark</ButtonText> + color="primary" + size="large" + label="Switch to Storybook" + onPress={() => setShowContainedList(false)}> + <ButtonText>Switch to Storybook</ButtonText> </Button> - </View> - - <Dialogs /> - <ThemeProvider theme="light"> - <Theming /> - </ThemeProvider> - <ThemeProvider theme="dim"> - <Theming /> - </ThemeProvider> - <ThemeProvider theme="dark"> - <Theming /> - </ThemeProvider> - - <Typography /> - <Spacing /> - <Shadows /> - <Buttons /> - <Icons /> - <Links /> - <Forms /> - <Dialogs /> - <Menus /> - <Breakpoints /> - </View> - </CenteredView> - </ScrollView> + <ListContained /> + </> + )} + </View> + </CenteredView> ) } |