Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Hailey and committed by
GitHub
bc070199 594b40c3

+310 -85
+1
src/view/com/util/List.tsx
··· 25 25 headerOffset?: number 26 26 refreshing?: boolean 27 27 onRefresh?: () => void 28 + containWeb?: boolean 28 29 } 29 30 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> 30 31
+111 -21
src/view/com/util/List.web.tsx
··· 1 1 import React, {isValidElement, memo, startTransition, useRef} from 'react' 2 2 import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' 3 + import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' 3 4 4 5 import {batchedUpdates} from '#/lib/batchedUpdates' 5 6 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' ··· 20 21 refreshing?: boolean 21 22 onRefresh?: () => void 22 23 desktopFixedHeight: any // TODO: Better types. 24 + containWeb?: boolean 23 25 } 24 26 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. 25 27 ··· 27 29 { 28 30 ListHeaderComponent, 29 31 ListFooterComponent, 32 + containWeb, 30 33 contentContainerStyle, 31 34 data, 32 35 desktopFixedHeight, ··· 83 86 }) 84 87 } 85 88 89 + const getScrollableNode = React.useCallback(() => { 90 + if (containWeb) { 91 + const element = nativeRef.current as HTMLDivElement | null 92 + if (!element) return 93 + 94 + return { 95 + scrollWidth: element.scrollWidth, 96 + scrollHeight: element.scrollHeight, 97 + clientWidth: element.clientWidth, 98 + clientHeight: element.clientHeight, 99 + scrollY: element.scrollTop, 100 + scrollX: element.scrollLeft, 101 + scrollTo(options?: ScrollToOptions) { 102 + element.scrollTo(options) 103 + }, 104 + scrollBy(options: ScrollToOptions) { 105 + element.scrollBy(options) 106 + }, 107 + addEventListener(event: string, handler: any) { 108 + element.addEventListener(event, handler) 109 + }, 110 + removeEventListener(event: string, handler: any) { 111 + element.removeEventListener(event, handler) 112 + }, 113 + } 114 + } else { 115 + return { 116 + scrollWidth: document.documentElement.scrollWidth, 117 + scrollHeight: document.documentElement.scrollHeight, 118 + clientWidth: window.innerWidth, 119 + clientHeight: window.innerHeight, 120 + scrollY: window.scrollY, 121 + scrollX: window.scrollX, 122 + scrollTo(options: ScrollToOptions) { 123 + window.scrollTo(options) 124 + }, 125 + scrollBy(options: ScrollToOptions) { 126 + window.scrollBy(options) 127 + }, 128 + addEventListener(event: string, handler: any) { 129 + window.addEventListener(event, handler) 130 + }, 131 + removeEventListener(event: string, handler: any) { 132 + window.removeEventListener(event, handler) 133 + }, 134 + } 135 + } 136 + }, [containWeb]) 137 + 86 138 const nativeRef = React.useRef(null) 87 139 React.useImperativeHandle( 88 140 ref, 89 141 () => 90 142 ({ 91 143 scrollToTop() { 92 - window.scrollTo({top: 0}) 144 + getScrollableNode()?.scrollTo({top: 0}) 93 145 }, 94 146 scrollToOffset({ 95 147 animated, ··· 98 150 animated: boolean 99 151 offset: number 100 152 }) { 101 - window.scrollTo({ 153 + getScrollableNode()?.scrollTo({ 102 154 left: 0, 103 155 top: offset, 104 156 behavior: animated ? 'smooth' : 'instant', 105 157 }) 106 158 }, 159 + scrollToEnd({animated = true}: {animated?: boolean}) { 160 + const element = getScrollableNode() 161 + element?.scrollTo({ 162 + left: 0, 163 + top: element.scrollHeight, 164 + behavior: animated ? 'smooth' : 'instant', 165 + }) 166 + }, 107 167 } as any), // TODO: Better types. 108 - [], 168 + [getScrollableNode], 109 169 ) 110 170 111 - // --- onContentSizeChange --- 171 + // --- onContentSizeChange, maintainVisibleContentPosition --- 112 172 const containerRef = useRef(null) 113 173 useResizeObserver(containerRef, onContentSizeChange) 114 174 115 175 // --- onScroll --- 116 176 const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) 117 - const handleWindowScroll = useNonReactiveCallback(() => { 118 - if (isInsideVisibleTree) { 119 - contextScrollHandlers.onScroll?.( 120 - { 121 - contentOffset: { 122 - x: Math.max(0, window.scrollX), 123 - y: Math.max(0, window.scrollY), 124 - }, 125 - } as any, // TODO: Better types. 126 - null as any, 127 - ) 128 - } 177 + const handleScroll = useNonReactiveCallback(() => { 178 + if (!isInsideVisibleTree) return 179 + 180 + const element = getScrollableNode() 181 + contextScrollHandlers.onScroll?.( 182 + { 183 + contentOffset: { 184 + x: Math.max(0, element?.scrollX ?? 0), 185 + y: Math.max(0, element?.scrollY ?? 0), 186 + }, 187 + layoutMeasurement: { 188 + width: element?.clientWidth, 189 + height: element?.clientHeight, 190 + }, 191 + contentSize: { 192 + width: element?.scrollWidth, 193 + height: element?.scrollHeight, 194 + }, 195 + } as Exclude< 196 + ReanimatedScrollEvent, 197 + | 'velocity' 198 + | 'eventName' 199 + | 'zoomScale' 200 + | 'targetContentOffset' 201 + | 'contentInset' 202 + >, 203 + null as any, 204 + ) 129 205 }) 206 + 130 207 React.useEffect(() => { 131 208 if (!isInsideVisibleTree) { 132 209 // Prevents hidden tabs from firing scroll events. 133 210 // Only one list is expected to be firing these at a time. 134 211 return 135 212 } 136 - window.addEventListener('scroll', handleWindowScroll) 213 + 214 + const element = getScrollableNode() 215 + 216 + element?.addEventListener('scroll', handleScroll) 137 217 return () => { 138 - window.removeEventListener('scroll', handleWindowScroll) 218 + element?.removeEventListener('scroll', handleScroll) 139 219 } 140 - }, [isInsideVisibleTree, handleWindowScroll]) 220 + }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode]) 141 221 142 222 // --- onScrolledDownChange --- 143 223 const isScrolledDown = useRef(false) ··· 174 254 ) 175 255 176 256 return ( 177 - <View {...props} style={style} ref={nativeRef}> 257 + <View 258 + {...props} 259 + // @ts-ignore web only 260 + style={[style, containWeb && {flex: 1, 'overflow-y': 'scroll'}]} 261 + ref={nativeRef}> 178 262 <Visibility 179 263 onVisibleChange={setIsInsideVisibleTree} 180 264 style={ ··· 192 276 pal.border, 193 277 ]}> 194 278 <Visibility 279 + root={containWeb ? nativeRef.current : null} 195 280 onVisibleChange={handleAboveTheFoldVisibleChange} 196 281 style={[styles.aboveTheFoldDetector, {height: headerOffset}]} 197 282 /> 198 283 {onStartReached && ( 199 284 <Visibility 285 + root={containWeb ? nativeRef.current : null} 200 286 onVisibleChange={onHeadVisibilityChange} 201 287 topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} 202 288 /> ··· 213 299 ))} 214 300 {onEndReached && ( 215 301 <Visibility 302 + root={containWeb ? nativeRef.current : null} 216 303 onVisibleChange={onTailVisibilityChange} 217 304 bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} 218 305 /> ··· 275 362 Row = React.memo(Row) 276 363 277 364 let Visibility = ({ 365 + root = null, 278 366 topMargin = '0px', 279 367 bottomMargin = '0px', 280 368 onVisibleChange, 281 369 style, 282 370 }: { 371 + root?: Element | null 283 372 topMargin?: string 284 373 bottomMargin?: string 285 374 onVisibleChange: (isVisible: boolean) => void ··· 303 392 304 393 React.useEffect(() => { 305 394 const observer = new IntersectionObserver(handleIntersection, { 395 + root, 306 396 rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, 307 397 }) 308 398 const tail: Element | null = tailRef.current! ··· 310 400 return () => { 311 401 observer.unobserve(tail) 312 402 } 313 - }, [bottomMargin, handleIntersection, topMargin]) 403 + }, [bottomMargin, handleIntersection, topMargin, root]) 314 404 315 405 return ( 316 406 <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
+98
src/view/screens/Storybook/ListContained.tsx
··· 1 + import React from 'react' 2 + import {FlatList, View} from 'react-native' 3 + 4 + import {ScrollProvider} from 'lib/ScrollContext' 5 + import {List} from 'view/com/util/List' 6 + import {Button, ButtonText} from '#/components/Button' 7 + import * as Toggle from '#/components/forms/Toggle' 8 + import {Text} from '#/components/Typography' 9 + 10 + export function ListContained() { 11 + const [animated, setAnimated] = React.useState(false) 12 + const ref = React.useRef<FlatList>(null) 13 + 14 + const data = React.useMemo(() => { 15 + return Array.from({length: 100}, (_, i) => ({ 16 + id: i, 17 + text: `Message ${i}`, 18 + })) 19 + }, []) 20 + 21 + return ( 22 + <> 23 + <View style={{width: '100%', height: 300}}> 24 + <ScrollProvider 25 + onScroll={() => { 26 + 'worklet' 27 + console.log('onScroll') 28 + }}> 29 + <List 30 + data={data} 31 + renderItem={item => { 32 + return ( 33 + <View 34 + style={{ 35 + padding: 10, 36 + borderBottomWidth: 1, 37 + borderBottomColor: 'rgba(0,0,0,0.1)', 38 + }}> 39 + <Text>{item.item.text}</Text> 40 + </View> 41 + ) 42 + }} 43 + keyExtractor={item => item.id.toString()} 44 + containWeb={true} 45 + style={{flex: 1}} 46 + onStartReached={() => { 47 + console.log('Start Reached') 48 + }} 49 + onEndReached={() => { 50 + console.log('End Reached (threshold of 2)') 51 + }} 52 + onEndReachedThreshold={2} 53 + ref={ref} 54 + disableVirtualization={true} 55 + /> 56 + </ScrollProvider> 57 + </View> 58 + 59 + <View style={{flexDirection: 'row', gap: 10, alignItems: 'center'}}> 60 + <Toggle.Item 61 + name="a" 62 + label="Click me" 63 + value={animated} 64 + onChange={() => setAnimated(prev => !prev)}> 65 + <Toggle.Checkbox /> 66 + <Toggle.LabelText>Animated Scrolling</Toggle.LabelText> 67 + </Toggle.Item> 68 + </View> 69 + 70 + <Button 71 + variant="solid" 72 + color="primary" 73 + size="large" 74 + label="Scroll to End" 75 + onPress={() => ref.current?.scrollToOffset({animated, offset: 0})}> 76 + <ButtonText>Scroll to Top</ButtonText> 77 + </Button> 78 + 79 + <Button 80 + variant="solid" 81 + color="primary" 82 + size="large" 83 + label="Scroll to End" 84 + onPress={() => ref.current?.scrollToEnd({animated})}> 85 + <ButtonText>Scroll to End</ButtonText> 86 + </Button> 87 + 88 + <Button 89 + variant="solid" 90 + color="primary" 91 + size="large" 92 + label="Scroll to Offset 100" 93 + onPress={() => ref.current?.scrollToOffset({animated, offset: 500})}> 94 + <ButtonText>Scroll to Offset 500</ButtonText> 95 + </Button> 96 + </> 97 + ) 98 + }
+100 -64
src/view/screens/Storybook/index.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {ScrollView, View} from 'react-native' 3 3 4 4 import {useSetThemePrefs} from '#/state/shell' 5 - import {CenteredView, ScrollView} from '#/view/com/util/Views' 5 + import {isWeb} from 'platform/detection' 6 + import {CenteredView} from '#/view/com/util/Views' 7 + import {ListContained} from 'view/screens/Storybook/ListContained' 6 8 import {atoms as a, ThemeProvider, useTheme} from '#/alf' 7 9 import {Button, ButtonText} from '#/components/Button' 8 10 import {Breakpoints} from './Breakpoints' ··· 18 20 import {Typography} from './Typography' 19 21 20 22 export function Storybook() { 23 + if (isWeb) return <StorybookInner /> 24 + 25 + return ( 26 + <ScrollView> 27 + <StorybookInner /> 28 + </ScrollView> 29 + ) 30 + } 31 + 32 + function StorybookInner() { 21 33 const t = useTheme() 22 34 const {setColorMode, setDarkTheme} = useSetThemePrefs() 35 + const [showContainedList, setShowContainedList] = React.useState(false) 23 36 24 37 return ( 25 - <ScrollView> 26 - <CenteredView style={[t.atoms.bg]}> 27 - <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> 28 - <View style={[a.flex_row, a.align_start, a.gap_md]}> 29 - <Button 30 - variant="outline" 31 - color="primary" 32 - size="small" 33 - label='Set theme to "system"' 34 - onPress={() => setColorMode('system')}> 35 - <ButtonText>System</ButtonText> 36 - </Button> 37 - <Button 38 - variant="solid" 39 - color="secondary" 40 - size="small" 41 - label='Set theme to "light"' 42 - onPress={() => setColorMode('light')}> 43 - <ButtonText>Light</ButtonText> 44 - </Button> 38 + <CenteredView style={[t.atoms.bg]}> 39 + <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> 40 + {!showContainedList ? ( 41 + <> 42 + <View style={[a.flex_row, a.align_start, a.gap_md]}> 43 + <Button 44 + variant="outline" 45 + color="primary" 46 + size="small" 47 + label='Set theme to "system"' 48 + onPress={() => setColorMode('system')}> 49 + <ButtonText>System</ButtonText> 50 + </Button> 51 + <Button 52 + variant="solid" 53 + color="secondary" 54 + size="small" 55 + label='Set theme to "light"' 56 + onPress={() => setColorMode('light')}> 57 + <ButtonText>Light</ButtonText> 58 + </Button> 59 + <Button 60 + variant="solid" 61 + color="secondary" 62 + size="small" 63 + label='Set theme to "dim"' 64 + onPress={() => { 65 + setColorMode('dark') 66 + setDarkTheme('dim') 67 + }}> 68 + <ButtonText>Dim</ButtonText> 69 + </Button> 70 + <Button 71 + variant="solid" 72 + color="secondary" 73 + size="small" 74 + label='Set theme to "dark"' 75 + onPress={() => { 76 + setColorMode('dark') 77 + setDarkTheme('dark') 78 + }}> 79 + <ButtonText>Dark</ButtonText> 80 + </Button> 81 + </View> 82 + 83 + <Dialogs /> 84 + <ThemeProvider theme="light"> 85 + <Theming /> 86 + </ThemeProvider> 87 + <ThemeProvider theme="dim"> 88 + <Theming /> 89 + </ThemeProvider> 90 + <ThemeProvider theme="dark"> 91 + <Theming /> 92 + </ThemeProvider> 93 + 94 + <Typography /> 95 + <Spacing /> 96 + <Shadows /> 97 + <Buttons /> 98 + <Icons /> 99 + <Links /> 100 + <Forms /> 101 + <Dialogs /> 102 + <Menus /> 103 + <Breakpoints /> 104 + 45 105 <Button 46 106 variant="solid" 47 - color="secondary" 48 - size="small" 49 - label='Set theme to "dim"' 50 - onPress={() => { 51 - setColorMode('dark') 52 - setDarkTheme('dim') 53 - }}> 54 - <ButtonText>Dim</ButtonText> 107 + color="primary" 108 + size="large" 109 + label="Switch to Contained List" 110 + onPress={() => setShowContainedList(true)}> 111 + <ButtonText>Switch to Contained List</ButtonText> 55 112 </Button> 113 + </> 114 + ) : ( 115 + <> 56 116 <Button 57 117 variant="solid" 58 - color="secondary" 59 - size="small" 60 - label='Set theme to "dark"' 61 - onPress={() => { 62 - setColorMode('dark') 63 - setDarkTheme('dark') 64 - }}> 65 - <ButtonText>Dark</ButtonText> 118 + color="primary" 119 + size="large" 120 + label="Switch to Storybook" 121 + onPress={() => setShowContainedList(false)}> 122 + <ButtonText>Switch to Storybook</ButtonText> 66 123 </Button> 67 - </View> 68 - 69 - <Dialogs /> 70 - <ThemeProvider theme="light"> 71 - <Theming /> 72 - </ThemeProvider> 73 - <ThemeProvider theme="dim"> 74 - <Theming /> 75 - </ThemeProvider> 76 - <ThemeProvider theme="dark"> 77 - <Theming /> 78 - </ThemeProvider> 79 - 80 - <Typography /> 81 - <Spacing /> 82 - <Shadows /> 83 - <Buttons /> 84 - <Icons /> 85 - <Links /> 86 - <Forms /> 87 - <Dialogs /> 88 - <Menus /> 89 - <Breakpoints /> 90 - </View> 91 - </CenteredView> 92 - </ScrollView> 124 + <ListContained /> 125 + </> 126 + )} 127 + </View> 128 + </CenteredView> 93 129 ) 94 130 }