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
zio/stable
Hailey 2024-05-06 08:34:32 -07:00 committed by GitHub
parent 594b40c3ae
commit bc07019911
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 316 additions and 91 deletions

View File

@ -25,6 +25,7 @@ export type ListProps<ItemT> = Omit<
headerOffset?: number headerOffset?: number
refreshing?: boolean refreshing?: boolean
onRefresh?: () => void onRefresh?: () => void
containWeb?: boolean
} }
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>

View File

@ -1,5 +1,6 @@
import React, {isValidElement, memo, startTransition, useRef} from 'react' import React, {isValidElement, memo, startTransition, useRef} from 'react'
import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' 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 {batchedUpdates} from '#/lib/batchedUpdates'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@ -20,6 +21,7 @@ export type ListProps<ItemT> = Omit<
refreshing?: boolean refreshing?: boolean
onRefresh?: () => void onRefresh?: () => void
desktopFixedHeight: any // TODO: Better types. desktopFixedHeight: any // TODO: Better types.
containWeb?: boolean
} }
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
@ -27,6 +29,7 @@ function ListImpl<ItemT>(
{ {
ListHeaderComponent, ListHeaderComponent,
ListFooterComponent, ListFooterComponent,
containWeb,
contentContainerStyle, contentContainerStyle,
data, data,
desktopFixedHeight, 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) const nativeRef = React.useRef(null)
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
() => () =>
({ ({
scrollToTop() { scrollToTop() {
window.scrollTo({top: 0}) getScrollableNode()?.scrollTo({top: 0})
}, },
scrollToOffset({ scrollToOffset({
animated, animated,
@ -98,46 +150,74 @@ function ListImpl<ItemT>(
animated: boolean animated: boolean
offset: number offset: number
}) { }) {
window.scrollTo({ getScrollableNode()?.scrollTo({
left: 0, left: 0,
top: offset, top: offset,
behavior: animated ? 'smooth' : 'instant', 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. } as any), // TODO: Better types.
[], [getScrollableNode],
) )
// --- onContentSizeChange --- // --- onContentSizeChange, maintainVisibleContentPosition ---
const containerRef = useRef(null) const containerRef = useRef(null)
useResizeObserver(containerRef, onContentSizeChange) useResizeObserver(containerRef, onContentSizeChange)
// --- onScroll --- // --- onScroll ---
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
const handleWindowScroll = useNonReactiveCallback(() => { const handleScroll = useNonReactiveCallback(() => {
if (isInsideVisibleTree) { if (!isInsideVisibleTree) return
contextScrollHandlers.onScroll?.(
{ const element = getScrollableNode()
contentOffset: { contextScrollHandlers.onScroll?.(
x: Math.max(0, window.scrollX), {
y: Math.max(0, window.scrollY), contentOffset: {
}, x: Math.max(0, element?.scrollX ?? 0),
} as any, // TODO: Better types. y: Math.max(0, element?.scrollY ?? 0),
null as any, },
) 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(() => { React.useEffect(() => {
if (!isInsideVisibleTree) { if (!isInsideVisibleTree) {
// Prevents hidden tabs from firing scroll events. // Prevents hidden tabs from firing scroll events.
// Only one list is expected to be firing these at a time. // Only one list is expected to be firing these at a time.
return return
} }
window.addEventListener('scroll', handleWindowScroll)
const element = getScrollableNode()
element?.addEventListener('scroll', handleScroll)
return () => { return () => {
window.removeEventListener('scroll', handleWindowScroll) element?.removeEventListener('scroll', handleScroll)
} }
}, [isInsideVisibleTree, handleWindowScroll]) }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode])
// --- onScrolledDownChange --- // --- onScrolledDownChange ---
const isScrolledDown = useRef(false) const isScrolledDown = useRef(false)
@ -174,7 +254,11 @@ function ListImpl<ItemT>(
) )
return ( return (
<View {...props} style={style} ref={nativeRef}> <View
{...props}
// @ts-ignore web only
style={[style, containWeb && {flex: 1, 'overflow-y': 'scroll'}]}
ref={nativeRef}>
<Visibility <Visibility
onVisibleChange={setIsInsideVisibleTree} onVisibleChange={setIsInsideVisibleTree}
style={ style={
@ -192,11 +276,13 @@ function ListImpl<ItemT>(
pal.border, pal.border,
]}> ]}>
<Visibility <Visibility
root={containWeb ? nativeRef.current : null}
onVisibleChange={handleAboveTheFoldVisibleChange} onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]} style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/> />
{onStartReached && ( {onStartReached && (
<Visibility <Visibility
root={containWeb ? nativeRef.current : null}
onVisibleChange={onHeadVisibilityChange} onVisibleChange={onHeadVisibilityChange}
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
/> />
@ -213,6 +299,7 @@ function ListImpl<ItemT>(
))} ))}
{onEndReached && ( {onEndReached && (
<Visibility <Visibility
root={containWeb ? nativeRef.current : null}
onVisibleChange={onTailVisibilityChange} onVisibleChange={onTailVisibilityChange}
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
/> />
@ -275,11 +362,13 @@ let Row = function RowImpl<ItemT>({
Row = React.memo(Row) Row = React.memo(Row)
let Visibility = ({ let Visibility = ({
root = null,
topMargin = '0px', topMargin = '0px',
bottomMargin = '0px', bottomMargin = '0px',
onVisibleChange, onVisibleChange,
style, style,
}: { }: {
root?: Element | null
topMargin?: string topMargin?: string
bottomMargin?: string bottomMargin?: string
onVisibleChange: (isVisible: boolean) => void onVisibleChange: (isVisible: boolean) => void
@ -303,6 +392,7 @@ let Visibility = ({
React.useEffect(() => { React.useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, { const observer = new IntersectionObserver(handleIntersection, {
root,
rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
}) })
const tail: Element | null = tailRef.current! const tail: Element | null = tailRef.current!
@ -310,7 +400,7 @@ let Visibility = ({
return () => { return () => {
observer.unobserve(tail) observer.unobserve(tail)
} }
}, [bottomMargin, handleIntersection, topMargin]) }, [bottomMargin, handleIntersection, topMargin, root])
return ( return (
<View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />

View File

@ -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>
</>
)
}

View File

@ -1,8 +1,10 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {ScrollView, View} from 'react-native'
import {useSetThemePrefs} from '#/state/shell' 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 {atoms as a, ThemeProvider, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {Breakpoints} from './Breakpoints' import {Breakpoints} from './Breakpoints'
@ -18,77 +20,111 @@ import {Theming} from './Theming'
import {Typography} from './Typography' import {Typography} from './Typography'
export function Storybook() { export function Storybook() {
const t = useTheme() if (isWeb) return <StorybookInner />
const {setColorMode, setDarkTheme} = useSetThemePrefs()
return ( return (
<ScrollView> <ScrollView>
<CenteredView style={[t.atoms.bg]}> <StorybookInner />
<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>
<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 />
</View>
</CenteredView>
</ScrollView> </ScrollView>
) )
} }
function StorybookInner() {
const t = useTheme()
const {setColorMode, setDarkTheme} = useSetThemePrefs()
const [showContainedList, setShowContainedList] = React.useState(false)
return (
<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="primary"
size="large"
label="Switch to Contained List"
onPress={() => setShowContainedList(true)}>
<ButtonText>Switch to Contained List</ButtonText>
</Button>
</>
) : (
<>
<Button
variant="solid"
color="primary"
size="large"
label="Switch to Storybook"
onPress={() => setShowContainedList(false)}>
<ButtonText>Switch to Storybook</ButtonText>
</Button>
<ListContained />
</>
)}
</View>
</CenteredView>
)
}