Make scroll handling contextual (#2200)

* 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
This commit is contained in:
dan 2023-12-14 02:48:20 +00:00 committed by GitHub
parent fa3ccafa80
commit 7fd7970237
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 280 additions and 354 deletions

View file

@ -7,13 +7,15 @@ import {useNavigation} from '@react-navigation/native'
import {useAnalytics} from 'lib/analytics/analytics'
import {useQueryClient} from '@tanstack/react-query'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {MainScrollProvider} from '../util/MainScrollProvider'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {ComposeIcon2} from 'lib/icons'
import {colors, s} from 'lib/styles'
import {FlatList, View, useWindowDimensions} from 'react-native'
import {View, useWindowDimensions} from 'react-native'
import {ListMethods} from '../util/List'
import {Feed} from '../posts/Feed'
import {TextLink} from '../util/Link'
import {FAB} from '../util/fab/FAB'
@ -51,10 +53,11 @@ export function FeedPage({
const {isDesktop} = useWebMediaQueries()
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics()
const headerOffset = useHeaderOffset()
const scrollElRef = React.useRef<FlatList>(null)
const scrollElRef = React.useRef<ListMethods>(null)
const [hasNew, setHasNew] = React.useState(false)
const scrollToTop = React.useCallback(() => {
@ -62,8 +65,8 @@ export function FeedPage({
animated: isNative,
offset: -headerOffset,
})
resetMainScroll()
}, [headerOffset, resetMainScroll])
setMinimalShellMode(false)
}, [headerOffset, setMinimalShellMode])
const onSoftReset = React.useCallback(() => {
const isScreenFocused =
@ -164,21 +167,22 @@ export function FeedPage({
return (
<View testID={testID} style={s.h100pct}>
<Feed
testID={testID ? `${testID}-feed` : undefined}
enabled={isPageFocused}
feed={feed}
feedParams={feedParams}
pollInterval={POLL_FREQ}
scrollElRef={scrollElRef}
onScroll={onMainScroll}
onHasNew={setHasNew}
scrollEventThrottle={1}
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
ListHeaderComponent={ListHeaderComponent}
headerOffset={headerOffset}
/>
<MainScrollProvider>
<Feed
testID={testID ? `${testID}-feed` : undefined}
enabled={isPageFocused}
feed={feed}
feedParams={feedParams}
pollInterval={POLL_FREQ}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew}
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
ListHeaderComponent={ListHeaderComponent}
headerOffset={headerOffset}
/>
</MainScrollProvider>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}

View file

@ -1,4 +1,4 @@
import React, {MutableRefObject} from 'react'
import React from 'react'
import {
Dimensions,
RefreshControl,
@ -8,18 +8,16 @@ import {
ViewStyle,
} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {FlatList} from '../util/Views'
import {List, ListRef} from '../util/List'
import {FeedSourceCardLoaded} from './FeedSourceCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
import {cleanError} from '#/lib/strings/errors'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useTheme} from '#/lib/ThemeContext'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {hydrateFeedGenerator} from '#/state/queries/feed'
@ -37,9 +35,7 @@ interface SectionRef {
interface ProfileFeedgensProps {
did: string
scrollElRef: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollHandler
scrollEventThrottle?: number
scrollElRef: ListRef
headerOffset: number
enabled?: boolean
style?: StyleProp<ViewStyle>
@ -50,16 +46,7 @@ export const ProfileFeedgens = React.forwardRef<
SectionRef,
ProfileFeedgensProps
>(function ProfileFeedgensImpl(
{
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
},
{did, scrollElRef, headerOffset, enabled, style, testID},
ref,
) {
const pal = usePalette('default')
@ -185,10 +172,9 @@ export const ProfileFeedgens = React.forwardRef<
[error, refetch, onPressRetryLoadMore, pal, preferences],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View testID={testID} style={style}>
<FlatList
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
@ -207,8 +193,6 @@ export const ProfileFeedgens = React.forwardRef<
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}

View file

@ -1,4 +1,4 @@
import React, {MutableRefObject} from 'react'
import React from 'react'
import {
ActivityIndicator,
Dimensions,
@ -8,7 +8,7 @@ import {
ViewStyle,
} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {FlatList} from '../util/Views'
import {List, ListRef} from '../util/List'
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@ -18,10 +18,8 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useListMembersQuery} from '#/state/queries/list-members'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useSession} from '#/state/session'
import {cleanError} from '#/lib/strings/errors'
@ -34,24 +32,22 @@ export function ListMembers({
list,
style,
scrollElRef,
onScroll,
onScrolledDownChange,
onPressTryAgain,
renderHeader,
renderEmptyState,
testID,
scrollEventThrottle,
headerOffset = 0,
desktopFixedHeightOffset,
}: {
list: string
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onScroll: OnScrollHandler
scrollElRef?: ListRef
onScrolledDownChange: (isScrolledDown: boolean) => void
onPressTryAgain?: () => void
renderHeader: () => JSX.Element
renderEmptyState: () => JSX.Element
testID?: string
scrollEventThrottle?: number
headerOffset?: number
desktopFixedHeightOffset?: number
}) {
@ -209,10 +205,9 @@ export function ListMembers({
[isFetching],
)
const scrollHandler = useAnimatedScrollHandler(onScroll)
return (
<View testID={testID} style={style}>
<FlatList
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
@ -233,10 +228,9 @@ export function ListMembers({
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={scrollHandler}
onScrolledDownChange={onScrolledDownChange}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
scrollEventThrottle={scrollEventThrottle}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf

View file

@ -15,7 +15,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {FlatList} from '../util/Views'
import {List} from '../util/List'
import {s} from 'lib/styles'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
@ -119,7 +119,7 @@ export function MyLists({
[error, onRefresh, renderItem, pal],
)
const FlatListCom = inline ? RNFlatList : FlatList
const FlatListCom = inline ? RNFlatList : List
return (
<View testID={testID} style={style}>
{items.length > 0 && (

View file

@ -1,4 +1,4 @@
import React, {MutableRefObject} from 'react'
import React from 'react'
import {
Dimensions,
RefreshControl,
@ -8,7 +8,7 @@ import {
ViewStyle,
} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {FlatList} from '../util/Views'
import {List, ListRef} from '../util/List'
import {ListCard} from './ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@ -16,11 +16,9 @@ import {Text} from '../util/text/Text'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
import {cleanError} from '#/lib/strings/errors'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useTheme} from '#/lib/ThemeContext'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {isNative} from '#/platform/detection'
@ -36,9 +34,7 @@ interface SectionRef {
interface ProfileListsProps {
did: string
scrollElRef: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollHandler
scrollEventThrottle?: number
scrollElRef: ListRef
headerOffset: number
enabled?: boolean
style?: StyleProp<ViewStyle>
@ -47,16 +43,7 @@ interface ProfileListsProps {
export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
function ProfileListsImpl(
{
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
},
{did, scrollElRef, headerOffset, enabled, style, testID},
ref,
) {
const pal = usePalette('default')
@ -187,10 +174,9 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
[error, refetch, onPressRetryLoadMore, pal],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View testID={testID} style={style}>
<FlatList
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
@ -209,8 +195,6 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}

View file

@ -1,13 +1,11 @@
import React, {MutableRefObject} from 'react'
import {CenteredView, FlatList} from '../util/Views'
import React from 'react'
import {CenteredView} from '../util/Views'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {EmptyState} from '../util/EmptyState'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
@ -15,6 +13,7 @@ import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
import {logger} from '#/logger'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/queries/preferences'
import {List, ListRef} from '../util/List'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
@ -23,12 +22,12 @@ const LOADING_ITEM = {_reactKey: '__loading__'}
export function Feed({
scrollElRef,
onPressTryAgain,
onScroll,
onScrolledDownChange,
ListHeaderComponent,
}: {
scrollElRef?: MutableRefObject<FlatList<any> | null>
scrollElRef?: ListRef
onPressTryAgain?: () => void
onScroll?: OnScrollHandler
onScrolledDownChange: (isScrolledDown: boolean) => void
ListHeaderComponent?: () => JSX.Element
}) {
const pal = usePalette('default')
@ -135,7 +134,6 @@ export function Feed({
[isFetchingNextPage],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View style={s.hContentRegion}>
{error && (
@ -146,7 +144,7 @@ export function Feed({
/>
</CenteredView>
)}
<FlatList
<List
testID="notifsFeed"
ref={scrollElRef}
data={items}
@ -164,8 +162,7 @@ export function Feed({
}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
onScroll={scrollHandler}
scrollEventThrottle={1}
onScrolledDownChange={onScrolledDownChange}
contentContainerStyle={s.contentContainer}
// @ts-ignore our .web version only -prf
desktopFixedHeight

View file

@ -1,7 +1,6 @@
import * as React from 'react'
import {
LayoutChangeEvent,
FlatList,
ScrollView,
StyleSheet,
View,
@ -20,17 +19,14 @@ import Animated, {
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
const SCROLLED_DOWN_LIMIT = 200
import {ListMethods} from '../util/List'
import {ScrollProvider} from '#/lib/ScrollContext'
export interface PagerWithHeaderChildParams {
headerHeight: number
isFocused: boolean
onScroll: OnScrollHandler
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
scrollElRef: React.MutableRefObject<ListMethods | ScrollView | null>
}
export interface PagerWithHeaderProps {
@ -62,7 +58,6 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
const [currentPage, setCurrentPage] = React.useState(0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
@ -155,15 +150,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
if (!throttleTimeout.current) {
throttleTimeout.current = setTimeout(() => {
throttleTimeout.current = null
runOnUI(adjustScrollForOtherPages)()
const nextIsScrolledDown = scrollY.value > SCROLLED_DOWN_LIMIT
if (isScrolledDown !== nextIsScrolledDown) {
React.startTransition(() => {
setIsScrolledDown(nextIsScrolledDown)
})
}
}, 80 /* Sync often enough you're unlikely to catch it unsynced */)
}
})
@ -211,7 +198,6 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
index={i}
isReady={isReady}
isFocused={i === currentPage}
isScrolledDown={isScrolledDown}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
@ -293,7 +279,6 @@ function PagerItem({
index,
isReady,
isFocused,
isScrolledDown,
onScrollWorklet,
renderTab,
registerRef,
@ -302,7 +287,6 @@ function PagerItem({
index: number
isFocused: boolean
isReady: boolean
isScrolledDown: boolean
registerRef: (scrollRef: AnimatedRef<any> | null, atIndex: number) => void
onScrollWorklet: (e: NativeScrollEvent) => void
renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
@ -316,24 +300,21 @@ function PagerItem({
}
}, [scrollElRef, registerRef, index])
const scrollHandler = React.useMemo(
() => ({onScroll: onScrollWorklet}),
[onScrollWorklet],
)
if (!isReady || renderTab == null) {
return null
}
return renderTab({
headerHeight,
isFocused,
isScrolledDown,
onScroll: scrollHandler,
scrollElRef: scrollElRef as React.MutableRefObject<
FlatList<any> | ScrollView | null
>,
})
return (
<ScrollProvider onScroll={onScrollWorklet}>
{renderTab({
headerHeight,
isFocused,
scrollElRef: scrollElRef as React.MutableRefObject<
ListMethods | ScrollView | null
>,
})}
</ScrollProvider>
)
}
const styles = StyleSheet.create({

View file

@ -1,7 +1,8 @@
import React, {useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
import {CenteredView} from '../util/Views'
import {List} from '../util/List'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {usePalette} from 'lib/hooks/usePalette'
@ -84,7 +85,7 @@ export function PostLikedBy({uri}: {uri: string}) {
// loaded
// =
return (
<FlatList
<List
data={likes}
keyExtractor={item => item.actor.did}
refreshControl={

View file

@ -1,7 +1,8 @@
import React, {useMemo, useCallback, useState} from 'react'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
import {CenteredView} from '../util/Views'
import {List} from '../util/List'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {usePalette} from 'lib/hooks/usePalette'
@ -85,7 +86,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
// loaded
// =
return (
<FlatList
<List
data={repostedBy}
keyExtractor={item => item.did}
refreshControl={

View file

@ -8,7 +8,8 @@ import {
View,
} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
import {CenteredView} from '../util/Views'
import {List, ListMethods} from '../util/List'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@ -140,7 +141,7 @@ function PostThreadLoaded({
const {_} = useLingui()
const pal = usePalette('default')
const {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef<FlatList>(null)
const ref = useRef<ListMethods>(null)
const highlightedPostRef = useRef<View | null>(null)
const needsScrollAdjustment = useRef<boolean>(
!isNative || // web always uses scroll adjustment
@ -335,7 +336,7 @@ function PostThreadLoaded({
)
return (
<FlatList
<List
ref={ref}
data={posts}
initialNumToRender={!isNative ? posts.length : undefined}

View file

@ -1,4 +1,4 @@
import React, {memo, MutableRefObject} from 'react'
import React, {memo} from 'react'
import {
ActivityIndicator,
AppState,
@ -10,15 +10,13 @@ import {
ViewStyle,
} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {FlatList} from '../util/Views'
import {List, ListRef} from '../util/List'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {FeedErrorMessage} from './FeedErrorMessage'
import {FeedSlice} from './FeedSlice'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useTheme} from 'lib/ThemeContext'
import {logger} from '#/logger'
import {
@ -45,9 +43,8 @@ let Feed = ({
enabled,
pollInterval,
scrollElRef,
onScroll,
onScrolledDownChange,
onHasNew,
scrollEventThrottle,
renderEmptyState,
renderEndOfFeed,
testID,
@ -62,10 +59,9 @@ let Feed = ({
style?: StyleProp<ViewStyle>
enabled?: boolean
pollInterval?: number
scrollElRef?: MutableRefObject<FlatList<any> | null>
scrollElRef?: ListRef
onHasNew?: (v: boolean) => void
onScroll?: OnScrollHandler
scrollEventThrottle?: number
onScrolledDownChange?: (isScrolledDown: boolean) => void
renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element
testID?: string
@ -270,10 +266,9 @@ let Feed = ({
)
}, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View testID={testID} style={style}>
<FlatList
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={feedItems}
@ -294,8 +289,7 @@ let Feed = ({
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}
onScrolledDownChange={onScrolledDownChange}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
onEndReached={onEndReached}
onEndReachedThreshold={2} // number of posts left to trigger load more

View file

@ -1,7 +1,8 @@
import React from 'react'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
import {CenteredView} from '../util/Views'
import {List} from '../util/List'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from './ProfileCard'
import {usePalette} from 'lib/hooks/usePalette'
@ -86,7 +87,7 @@ export function ProfileFollowers({name}: {name: string}) {
// loaded
// =
return (
<FlatList
<List
data={followers}
keyExtractor={item => item.did}
refreshControl={

View file

@ -1,7 +1,8 @@
import React from 'react'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
import {CenteredView} from '../util/Views'
import {List} from '../util/List'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from './ProfileCard'
import {usePalette} from 'lib/hooks/usePalette'
@ -86,7 +87,7 @@ export function ProfileFollows({name}: {name: string}) {
// loaded
// =
return (
<FlatList
<List
data={follows}
keyExtractor={item => item.did}
refreshControl={

View file

@ -0,0 +1,64 @@
import React, {memo, startTransition} from 'react'
import {FlatListProps} from 'react-native'
import {FlatList_INTERNAL} from './Views'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
export type ListMethods = FlatList_INTERNAL
export type ListProps<ItemT> = Omit<
FlatListProps<ItemT>,
'onScroll' // Use ScrollContext instead.
> & {
onScrolledDownChange?: (isScrolledDown: boolean) => void
}
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
const SCROLLED_DOWN_LIMIT = 200
function ListImpl<ItemT>(
{onScrolledDownChange, ...props}: ListProps<ItemT>,
ref: React.Ref<ListMethods>,
) {
const isScrolledDown = useSharedValue(false)
const contextScrollHandlers = useScrollHandlers()
function handleScrolledDownChange(didScrollDown: boolean) {
startTransition(() => {
onScrolledDownChange?.(didScrollDown)
})
}
const scrollHandler = useAnimatedScrollHandler({
onBeginDrag(e, ctx) {
contextScrollHandlers.onBeginDrag?.(e, ctx)
},
onEndDrag(e, ctx) {
contextScrollHandlers.onEndDrag?.(e, ctx)
},
onScroll(e, ctx) {
contextScrollHandlers.onScroll?.(e, ctx)
const didScrollDown = e.contentOffset.y > SCROLLED_DOWN_LIMIT
if (isScrolledDown.value !== didScrollDown) {
isScrolledDown.value = didScrollDown
if (onScrolledDownChange != null) {
runOnJS(handleScrolledDownChange)(didScrollDown)
}
}
},
})
return (
<FlatList_INTERNAL
{...props}
onScroll={scrollHandler}
scrollEventThrottle={1}
ref={ref}
/>
)
}
export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
) => React.ReactElement

View file

@ -0,0 +1,97 @@
import React, {useCallback} from 'react'
import {ScrollProvider} from '#/lib/ScrollContext'
import {NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isWeb} from 'platform/detection'
import {useSharedValue, interpolate} from 'react-native-reanimated'
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
export function MainScrollProvider({children}: {children: React.ReactNode}) {
const {headerHeight} = useShellLayout()
const mode = useMinimalShellMode()
const setMode = useSetMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
const onBeginDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
},
[mode, startDragOffset, startMode],
)
const onEndDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
startDragOffset.value = null
startMode.value = null
if (e.contentOffset.y < headerHeight.value / 2) {
// If we're close to the top, show the shell.
setMode(false)
} else {
// Snap to whichever state is the closest.
setMode(Math.round(mode.value) === 1)
}
},
[startDragOffset, startMode, setMode, mode, headerHeight],
)
const onScroll = useCallback(
(e: NativeScrollEvent) => {
'worklet'
if (startDragOffset.value === null || startMode.value === null) {
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
// If we're close enough to the top, always show the shell.
// Even if we're not dragging.
setMode(false)
return
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag.
// When we get the first scroll event, we consider that the start.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
return
}
// The "mode" value is always between 0 and 1.
// Figure out how much to move it based on the current dragged distance.
const dy = e.contentOffset.y - startDragOffset.value
const dProgress = interpolate(
dy,
[-headerHeight.value, headerHeight.value],
[-1, 1],
)
const newValue = clamp(startMode.value + dProgress, 0, 1)
if (newValue !== mode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
mode.value = newValue
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag,
// so we don't have any specific anchor point to calculate the distance.
// Instead, update it continuosly along the way and diff with the last event.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
},
[headerHeight, mode, setMode, startDragOffset, startMode],
)
return (
<ScrollProvider
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}
onScroll={onScroll}>
{children}
</ScrollProvider>
)
}

View file

@ -1,13 +1,14 @@
import React, {useEffect, useState} from 'react'
import {
NativeSyntheticEvent,
NativeScrollEvent,
Pressable,
RefreshControl,
StyleSheet,
View,
ScrollView,
} from 'react-native'
import {FlatList} from './Views'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {FlatList_INTERNAL} from './Views'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
@ -38,7 +39,7 @@ export const ViewSelector = React.forwardRef<
| null
| undefined
onSelectView?: (viewIndex: number) => void
onScroll?: OnScrollCb
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
onRefresh?: () => void
onEndReached?: (info: {distanceFromEnd: number}) => void
}
@ -59,7 +60,7 @@ export const ViewSelector = React.forwardRef<
) {
const pal = usePalette('default')
const [selectedIndex, setSelectedIndex] = useState<number>(0)
const flatListRef = React.useRef<FlatList>(null)
const flatListRef = React.useRef<FlatList_INTERNAL>(null)
// events
// =
@ -110,7 +111,7 @@ export const ViewSelector = React.forwardRef<
[items],
)
return (
<FlatList
<FlatList_INTERNAL
ref={flatListRef}
data={data}
keyExtractor={keyExtractor}

View file

@ -1,6 +1,6 @@
import React from 'react'
import {ViewProps} from 'react-native'
export {FlatList, ScrollView} from 'react-native'
export {FlatList as FlatList_INTERNAL, ScrollView} from 'react-native'
export function CenteredView({
style,
sideBorders,

View file

@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import Animated from 'react-native-reanimated'
export const FlatList = Animated.FlatList
export const FlatList_INTERNAL = Animated.FlatList
export const ScrollView = Animated.ScrollView
export function CenteredView(props) {
return <View {...props} />

View file

@ -49,7 +49,7 @@ export function CenteredView({
return <View style={style} {...props} />
}
export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
{
contentContainerStyle,
style,