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 * typecheckzio/stable
parent
594b40c3ae
commit
bc07019911
|
@ -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>
|
||||
|
||||
|
|
|
@ -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) {
|
||||
const handleScroll = useNonReactiveCallback(() => {
|
||||
if (!isInsideVisibleTree) return
|
||||
|
||||
const element = getScrollableNode()
|
||||
contextScrollHandlers.onScroll?.(
|
||||
{
|
||||
contentOffset: {
|
||||
x: Math.max(0, window.scrollX),
|
||||
y: Math.max(0, window.scrollY),
|
||||
x: Math.max(0, element?.scrollX ?? 0),
|
||||
y: Math.max(0, element?.scrollY ?? 0),
|
||||
},
|
||||
} as any, // TODO: Better types.
|
||||
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)} />
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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,13 +20,25 @@ import {Theming} from './Theming'
|
|||
import {Typography} from './Typography'
|
||||
|
||||
export function Storybook() {
|
||||
const t = useTheme()
|
||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||
if (isWeb) return <StorybookInner />
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<StorybookInner />
|
||||
</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"
|
||||
|
@ -87,8 +101,30 @@ export function Storybook() {
|
|||
<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>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue