New Web Layout (#2126)

* Rip out virtualization on the web

* Screw around with layout

* onEndReached

* scrollToOffset

* Fix background

* onScroll

* Shell bars

* More scroll

* Fixes

* position: sticky

* Clean up 1

* Clean up 2

* Undo PagerWithHeader changes and fork it

* Trim down both versions

* Cleanup 3

* Memoize, lint

* Don't scroll away modal or lightbox

* Add content-visibility for rows

* Fix composer

* Fix types

* Fix borked scroll animation

* Fixes to layout

* More FlatList parity

* Layout fixes

* Fix more layout

* More layout

* More layouts

* Fix profile layout

* Remove onScroll

* Display: none inactive pages

* Add an intermediate List component

* Fix type

* Add onScrolledDownChange

* Port pager to use onScrolledDownChange

* Fix on mobile

* Don't pass down onScroll (replacement TBD)

* Remove resetMainScroll

* Replace onMainScroll with MainScrollProvider

* Hook ScrollProvider to pager

* Fix the remaining special case

* Optimize a bit

* Enforce that onScroll cannot be passed

* Keep value updated even if no handler

* Also memo it

* Move the fork to List.web

* Add scroll handler

* Consolidate List props a bit

* More stuff

* Rm unused

* Simplify

* Make isScrolledDown work

* Oops

* Fixes

* Hook up context scroll handlers

* Scroll restore for tabs

* Route scroll restoration POC

* Fix some issues with restoration

* Remove bad idea

* Fix pager scroll restoration

* Undo accidental locale changes

* onContentSizeChange

* Scroll to post

* Better positioning

* Layout fixes

* Factor out navigation stuff

* Cleanup

* Oops

* Cleanup

* Fixes and types

* Naming etc

* Fix crash

* Match FL semantics

* Snap the header scroll on the web

* Add body scroll lock

* Scroll to top on search

* Fix types

* Typos

* Fix Safari overflow

* Fix search positioning

* Add border

* Patch react navigation

* Revert "Patch react navigation"

This reverts commit 62516ed9c20410d166e1582b43b656c819495ddc.

* fixes

* scroll

* scrollbar

* cleanup unrelated

* undo unrel

* flatter

* Fix css

* twk
This commit is contained in:
dan 2024-01-22 22:46:32 +00:00 committed by GitHub
parent cd02922b03
commit f015229acf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 849 additions and 97 deletions

View file

@ -1,4 +1,4 @@
import React, {memo, startTransition} from 'react'
import React, {memo} from 'react'
import {FlatListProps, RefreshControl} from 'react-native'
import {FlatList_INTERNAL} from './Views'
import {addStyle} from 'lib/styles'
@ -39,9 +39,7 @@ function ListImpl<ItemT>(
const pal = usePalette('default')
function handleScrolledDownChange(didScrollDown: boolean) {
startTransition(() => {
onScrolledDownChange?.(didScrollDown)
})
onScrolledDownChange?.(didScrollDown)
}
const scrollHandler = useAnimatedScrollHandler({

View file

@ -0,0 +1,341 @@
import React, {isValidElement, memo, useRef, startTransition} from 'react'
import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
import {addStyle} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {batchedUpdates} from '#/lib/batchedUpdates'
export type ListMethods = any // TODO: Better types.
export type ListProps<ItemT> = Omit<
FlatListProps<ItemT>,
| 'onScroll' // Use ScrollContext instead.
| 'refreshControl' // Pass refreshing and/or onRefresh instead.
| 'contentOffset' // Pass headerOffset instead.
> & {
onScrolledDownChange?: (isScrolledDown: boolean) => void
headerOffset?: number
refreshing?: boolean
onRefresh?: () => void
desktopFixedHeight: any // TODO: Better types.
}
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
function ListImpl<ItemT>(
{
ListHeaderComponent,
ListFooterComponent,
contentContainerStyle,
data,
desktopFixedHeight,
headerOffset,
keyExtractor,
refreshing: _unsupportedRefreshing,
onEndReached,
onEndReachedThreshold = 0,
onRefresh: _unsupportedOnRefresh,
onScrolledDownChange,
onContentSizeChange,
renderItem,
extraData,
style,
...props
}: ListProps<ItemT>,
ref: React.Ref<ListMethods>,
) {
const contextScrollHandlers = useScrollHandlers()
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
if (!isMobile) {
contentContainerStyle = addStyle(
contentContainerStyle,
styles.containerScroll,
)
}
let header: JSX.Element | null = null
if (ListHeaderComponent != null) {
if (isValidElement(ListHeaderComponent)) {
header = ListHeaderComponent
} else {
// @ts-ignore Nah it's fine.
header = <ListHeaderComponent />
}
}
let footer: JSX.Element | null = null
if (ListFooterComponent != null) {
if (isValidElement(ListFooterComponent)) {
footer = ListFooterComponent
} else {
// @ts-ignore Nah it's fine.
footer = <ListFooterComponent />
}
}
if (headerOffset != null) {
style = addStyle(style, {
paddingTop: headerOffset,
})
}
const nativeRef = React.useRef(null)
React.useImperativeHandle(
ref,
() =>
({
scrollToTop() {
window.scrollTo({top: 0})
},
scrollToOffset({
animated,
offset,
}: {
animated: boolean
offset: number
}) {
window.scrollTo({
left: 0,
top: offset,
behavior: animated ? 'smooth' : 'instant',
})
},
} as any), // TODO: Better types.
[],
)
// --- onContentSizeChange ---
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,
)
}
})
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)
return () => {
window.removeEventListener('scroll', handleWindowScroll)
}
}, [isInsideVisibleTree, handleWindowScroll])
// --- onScrolledDownChange ---
const isScrolledDown = useRef(false)
function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) {
const didScrollDown = !isAboveTheFold
if (isScrolledDown.current !== didScrollDown) {
isScrolledDown.current = didScrollDown
startTransition(() => {
onScrolledDownChange?.(didScrollDown)
})
}
}
// --- onEndReached ---
const onTailVisibilityChange = useNonReactiveCallback(
(isTailVisible: boolean) => {
if (isTailVisible) {
onEndReached?.({
distanceFromEnd: onEndReachedThreshold || 0,
})
}
},
)
return (
<View {...props} style={style} ref={nativeRef}>
<Visibility
onVisibleChange={setIsInsideVisibleTree}
style={
// This has position: fixed, so it should always report as visible
// unless we're within a display: none tree (like a hidden tab).
styles.parentTreeVisibilityDetector
}
/>
<View
ref={containerRef}
style={[
styles.contentContainer,
contentContainerStyle,
desktopFixedHeight ? styles.minHeightViewport : null,
pal.border,
]}>
<Visibility
onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
{header}
{(data as Array<ItemT>).map((item, index) => (
<Row<ItemT>
key={keyExtractor!(item, index)}
item={item}
index={index}
renderItem={renderItem}
extraData={extraData}
/>
))}
{onEndReached && (
<Visibility
topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
onVisibleChange={onTailVisibilityChange}
/>
)}
{footer}
</View>
</View>
)
}
function useResizeObserver(
ref: React.RefObject<Element>,
onResize: undefined | ((w: number, h: number) => void),
) {
const handleResize = useNonReactiveCallback(onResize ?? (() => {}))
const isActive = !!onResize
React.useEffect(() => {
if (!isActive) {
return
}
const resizeObserver = new ResizeObserver(entries => {
batchedUpdates(() => {
for (let entry of entries) {
const rect = entry.contentRect
handleResize(rect.width, rect.height)
}
})
})
const node = ref.current!
resizeObserver.observe(node)
return () => {
resizeObserver.unobserve(node)
}
}, [handleResize, isActive, ref])
}
let Row = function RowImpl<ItemT>({
item,
index,
renderItem,
extraData: _unused,
}: {
item: ItemT
index: number
renderItem:
| null
| undefined
| ((data: {index: number; item: any; separators: any}) => React.ReactNode)
extraData: any
}): React.ReactNode {
if (!renderItem) {
return null
}
return (
<View style={styles.row}>
{renderItem({item, index, separators: null as any})}
</View>
)
}
Row = React.memo(Row)
let Visibility = ({
topMargin = '0px',
onVisibleChange,
style,
}: {
topMargin?: string
onVisibleChange: (isVisible: boolean) => void
style?: ViewProps['style']
}): React.ReactNode => {
const tailRef = React.useRef(null)
const isIntersecting = React.useRef(false)
const handleIntersection = useNonReactiveCallback(
(entries: IntersectionObserverEntry[]) => {
batchedUpdates(() => {
entries.forEach(entry => {
if (entry.isIntersecting !== isIntersecting.current) {
isIntersecting.current = entry.isIntersecting
onVisibleChange(entry.isIntersecting)
}
})
})
},
)
React.useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, {
rootMargin: `${topMargin} 0px 0px 0px`,
})
const tail: Element | null = tailRef.current!
observer.observe(tail)
return () => {
observer.unobserve(tail)
}
}, [handleIntersection, topMargin])
return (
<View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
)
}
Visibility = React.memo(Visibility)
export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
) => React.ReactElement
const styles = StyleSheet.create({
contentContainer: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
containerScroll: {
width: '100%',
maxWidth: 600,
marginLeft: 'auto',
marginRight: 'auto',
},
row: {
// @ts-ignore web only
contentVisibility: 'auto',
},
minHeightViewport: {
// @ts-ignore web only
minHeight: '100vh',
},
parentTreeVisibilityDetector: {
// @ts-ignore web only
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
aboveTheFoldDetector: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
// Bottom is dynamic.
},
visibilityDetector: {
pointerEvents: 'none',
zIndex: -1,
},
})

View file

@ -1,9 +1,10 @@
import React, {useCallback} from 'react'
import React, {useCallback, useEffect} from 'react'
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
import {NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative} from 'platform/detection'
import {isNative, isWeb} from 'platform/detection'
import {useSharedValue, interpolate} from 'react-native-reanimated'
const WEB_HIDE_SHELL_THRESHOLD = 200
@ -20,6 +21,15 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
useEffect(() => {
if (isWeb) {
return listenToForcedWindowScroll(() => {
startDragOffset.value = null
startMode.value = null
})
}
})
const onBeginDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
@ -100,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
</ScrollProvider>
)
}
const emitter = new EventEmitter()
if (isWeb) {
const originalScroll = window.scroll
window.scroll = function () {
emitter.emit('forced-scroll')
return originalScroll.apply(this, arguments as any)
}
const originalScrollTo = window.scrollTo
window.scrollTo = function () {
emitter.emit('forced-scroll')
return originalScrollTo.apply(this, arguments as any)
}
}
function listenToForcedWindowScroll(listener: () => void) {
emitter.addListener('forced-scroll', listener)
return () => {
emitter.removeListener('forced-scroll', listener)
}
}

View file

@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell'
import {isWeb} from '#/platform/detection'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@ -47,7 +48,14 @@ export function SimpleViewHeader({
const Container = isMobile ? View : CenteredView
return (
<Container style={[styles.header, isMobile && styles.headerMobile, style]}>
<Container
style={[
styles.header,
isMobile && styles.headerMobile,
isWeb && styles.headerWeb,
pal.view,
style,
]}>
{showBackButton ? (
<TouchableOpacity
testID="viewHeaderDrawerBtn"
@ -89,6 +97,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
},
headerWeb: {
// @ts-ignore web-only
position: 'sticky',
top: 0,
zIndex: 1,
},
backBtn: {
width: 30,
height: 30,

View file

@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
const styles = StyleSheet.create({
container: {
position: 'absolute',
// @ts-ignore web only
position: 'fixed',
left: 20,
bottom: 20,
// @ts-ignore web only

View file

@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {clamp} from 'lib/numbers'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {isWeb} from '#/platform/detection'
import Animated from 'react-native-reanimated'
export interface FABProps
@ -64,7 +65,8 @@ const styles = StyleSheet.create({
borderRadius: 35,
},
outer: {
position: 'absolute',
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
zIndex: 1,
},
inner: {