Compare commits

...

12 Commits

Author SHA1 Message Date
Dan Abramov a3348aca15 repro 2023-11-10 13:44:42 +00:00
Dan Abramov cb14cbb153 Work in progress 2023-11-10 13:14:22 +00:00
Dan Abramov 9beed0ee9a [WIP] A simpler solution 2023-11-10 13:03:02 +00:00
Dan Abramov 2b1cbf376a Add hacks to fix web 2023-11-10 06:00:15 +00:00
Dan Abramov 0a67e83b05 More "types" 2023-11-10 04:10:25 +00:00
Dan Abramov 79774e67d5 Hack together some types 2023-11-10 04:06:06 +00:00
Dan Abramov ace173c518 Make containers tall enough to avoid jumps 2023-11-10 03:58:23 +00:00
Dan Abramov a3c14b8108 Force scroll sync for inactive tabs 2023-11-10 03:29:29 +00:00
Dan Abramov 03789cd77c Fix up missing props 2023-11-10 03:27:49 +00:00
Dan Abramov 8d60937a25 Remove old scroll sync logic 2023-11-10 02:16:53 +00:00
Dan Abramov 53aa7d14c0 Move ref ownership to the pager 2023-11-10 02:14:04 +00:00
Dan Abramov 342d18949f Extract a PagerItem component 2023-11-10 01:52:50 +00:00
7 changed files with 122 additions and 362 deletions
+29 -114
View File
@@ -1,122 +1,37 @@
import 'react-native-url-polyfill/auto'
import 'lib/sentry' // must be near top
import React, {useState, useEffect} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
import * as SplashScreen from 'expo-splash-screen'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {observer} from 'mobx-react-lite'
import {QueryClientProvider} from '@tanstack/react-query'
import 'view/icons'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell'
import * as notifications from 'lib/notifications/notifications'
import * as analytics from 'lib/analytics/analytics'
import * as Toast from 'view/com/util/Toast'
import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {
Provider as SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import * as persisted from '#/state/persisted'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {messages} from './locale/locales/en/messages'
i18n.load('en', messages)
i18n.activate('en')
SplashScreen.preventAutoHideAsync()
const InnerApp = observer(function AppImpl() {
const colorMode = useColorMode()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
// init
useEffect(() => {
setupState().then(store => {
setRootStore(store)
analytics.init(store)
notifications.init(store)
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
})
}, [])
useEffect(() => {
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
// show nothing prior to init
if (!rootStore || isInitialLoad) {
// TODO add a loading state
return null
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<I18nProvider i18n={i18n}>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</I18nProvider>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
)
})
function App() {
const [isReady, setReady] = useState(false)
import * as React from 'react'
import {View} from 'react-native'
import Animated, {useAnimatedScrollHandler} from 'react-native-reanimated'
export default function App() {
const [x, setX] = React.useState(0)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
let id = setInterval(() => setX(x => x + 1), 1000)
return () => clearInterval(id)
}, [])
if (!isReady) {
return null
}
// props to pass into children render functions
const onScroll = useAnimatedScrollHandler({
onScroll(e) {
console.log(x)
},
})
return (
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<InnerApp />
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
<View style={{flex: 1, flexDirection: 'row'}}>
<Animated.FlatList
onScroll={onScroll}
style={{flex: 1, backgroundColor: 'red'}}
contentContainerStyle={{
paddingTop: 1000,
}}
/>
<Animated.FlatList
onScroll={onScroll}
style={{flex: 1, backgroundColor: 'blue'}}
contentContainerStyle={{
paddingTop: 1000,
}}
/>
</View>
)
}
export default App
+4 -2
View File
@@ -1,6 +1,7 @@
import React, {MutableRefObject} from 'react'
import {
ActivityIndicator,
Dimensions,
RefreshControl,
StyleProp,
View,
@@ -18,7 +19,6 @@ import {ListModel} from 'state/models/content/list'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
@@ -224,7 +224,9 @@ export const ListItems = observer(function ListItemsImpl({
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
contentContainerStyle={{
paddingBottom: Dimensions.get('window').height - headerOffset,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll}
onEndReached={onEndReached}
+12 -1
View File
@@ -49,7 +49,18 @@ export const Pager = React.forwardRef(function PagerImpl(
onSelect: onTabBarSelect,
})}
{React.Children.map(children, (child, i) => (
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
<View
style={
selectedPage === i
? s.flex1
: {
position: 'absolute',
pointerEvents: 'none',
// @ts-ignore web-only
visibility: 'hidden',
}
}
key={`page-${i}`}>
{child}
</View>
))}
+33 -231
View File
@@ -1,235 +1,37 @@
import * as React from 'react'
import {LayoutChangeEvent, StyleSheet, View} from 'react-native'
import Animated, {
Easing,
useAnimatedReaction,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS,
} from 'react-native-reanimated'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {View} from 'react-native'
import Animated, {useAnimatedScrollHandler} from 'react-native-reanimated'
const SCROLLED_DOWN_LIMIT = 200
export function PagerWithHeader() {
const [x, setX] = React.useState(0)
React.useEffect(() => {
let id = setInterval(() => setX(x => x + 1), 1000)
return () => clearInterval(id)
}, [])
interface PagerWithHeaderChildParams {
headerHeight: number
onScroll: OnScrollCb
isScrolledDown: boolean
}
export interface PagerWithHeaderProps {
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
| ((props: PagerWithHeaderChildParams) => JSX.Element)
items: string[]
isHeaderReady: boolean
renderHeader?: () => JSX.Element
initialPage?: number
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
testID,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
}: PagerWithHeaderProps,
ref,
) {
const {isMobile} = useWebMediaQueries()
const [currentPage, setCurrentPage] = React.useState(0)
const scrollYs = React.useRef<Record<number, number>>({})
const scrollY = useSharedValue(scrollYs.current[currentPage] || 0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
const [isScrolledDown, setIsScrolledDown] = React.useState(
scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT,
)
const headerHeight = headerOnlyHeight + tabBarHeight
// react to scroll updates
function onScrollUpdate(v: number) {
// track each page's current scroll position
scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight)
// update the 'is scrolled down' value
setIsScrolledDown(v > SCROLLED_DOWN_LIMIT)
}
useAnimatedReaction(
() => scrollY.value,
v => runOnJS(onScrollUpdate)(v),
)
// capture the header bar sizing
const onTabBarLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
setTabBarHeight(evt.nativeEvent.layout.height)
},
[setTabBarHeight],
)
const onHeaderOnlyLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
setHeaderOnlyHeight(evt.nativeEvent.layout.height)
},
[setHeaderOnlyHeight],
)
// render the the header and tab bar
const headerTransform = useAnimatedStyle(
() => ({
transform: [
{
translateY: Math.min(
Math.min(scrollY.value, headerOnlyHeight) * -1,
0,
),
},
],
}),
[scrollY, headerHeight, tabBarHeight],
)
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<Animated.View
style={[
isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
headerTransform,
]}>
<View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View>
<View
onLayout={onTabBarLayout}
style={{
// Render it immediately to measure it early since its size doesn't depend on the content.
// However, keep it invisible until the header above stabilizes in order to prevent jumps.
opacity: isHeaderReady ? 1 : 0,
pointerEvents: isHeaderReady ? 'auto' : 'none',
}}>
<TabBar
items={items}
selectedPage={currentPage}
onSelect={props.onSelect}
onPressSelected={onCurrentPageSelected}
/>
</View>
</Animated.View>
)
},
[
items,
isHeaderReady,
renderHeader,
headerTransform,
currentPage,
onCurrentPageSelected,
isMobile,
onTabBarLayout,
onHeaderOnlyLayout,
],
)
// props to pass into children render functions
const onScroll = useAnimatedScrollHandler({
onScroll(e) {
scrollY.value = e.contentOffset.y
},
})
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onPageSelecting = React.useCallback(
(index: number) => {
setCurrentPage(index)
if (scrollY.value > headerHeight) {
scrollY.value = headerHeight
}
scrollY.value = withTiming(scrollYs.current[index] || 0, {
duration: 170,
easing: Easing.inOut(Easing.quad),
})
},
[scrollY, setCurrentPage, scrollYs, headerHeight],
)
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageSelecting={onPageSelecting}
renderTabBar={renderTabBar}
tabBarPosition="top">
{toArray(children)
.filter(Boolean)
.map((child, i) => {
let output = null
if (
child != null &&
// Defer showing content until we know it won't jump.
isHeaderReady &&
headerOnlyHeight > 0 &&
tabBarHeight > 0
) {
output = child({
headerHeight,
isScrolledDown,
onScroll: i === currentPage ? onScroll : noop,
})
}
// Pager children must be noncollapsible plain <View>s.
return (
<View key={i} collapsable={false}>
{output}
</View>
)
})}
</Pager>
)
},
)
const styles = StyleSheet.create({
tabBarMobile: {
position: 'absolute',
zIndex: 1,
top: 0,
left: 0,
width: '100%',
},
tabBarDesktop: {
position: 'absolute',
zIndex: 1,
top: 0,
// @ts-ignore Web only -prf
left: 'calc(50% - 299px)',
width: 598,
},
})
function noop() {}
function toArray<T>(v: T | T[]): T[] {
if (Array.isArray(v)) {
return v
}
return [v]
// props to pass into children render functions
const onScroll = useAnimatedScrollHandler({
onScroll(e) {
console.log(x)
},
})
return (
<View style={{flex: 1, flexDirection: 'row'}}>
<Animated.FlatList
onScroll={onScroll}
style={{flex: 1, backgroundColor: 'red'}}
contentContainerStyle={{
paddingTop: 1000,
}}
/>
<Animated.FlatList
onScroll={onScroll}
style={{flex: 1, backgroundColor: 'blue'}}
contentContainerStyle={{
paddingTop: 1000,
}}
/>
</View>
)
}
+4 -2
View File
@@ -2,6 +2,7 @@ import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
Dimensions,
RefreshControl,
StyleProp,
StyleSheet,
@@ -15,7 +16,6 @@ import {PostsFeedModel} from 'state/models/feeds/posts'
import {FeedSlice} from './FeedSlice'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
@@ -176,7 +176,9 @@ export const Feed = observer(function Feed({
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
contentContainerStyle={{
paddingBottom: Dimensions.get('window').height - headerOffset,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
+24 -6
View File
@@ -1,5 +1,11 @@
import React, {useMemo, useCallback} from 'react'
import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native'
import {
Dimensions,
StyleSheet,
View,
ActivityIndicator,
FlatList,
} from 'react-native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
@@ -342,16 +348,19 @@ export const ProfileFeedScreenInner = observer(
isHeaderReady={feedInfo?.hasLoaded ?? false}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={feedSectionRef}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
/>
)}
{({onScroll, headerHeight}) => (
{({onScroll, headerHeight, scrollElRef}) => (
<AboutSection
feedOwnerDid={feedOwnerDid}
feedRkey={rkey}
@@ -359,6 +368,9 @@ export const ProfileFeedScreenInner = observer(
headerHeight={headerHeight}
onToggleLiked={onToggleLiked}
onScroll={onScroll}
scrollElRef={
scrollElRef as React.MutableRefObject<ScrollView | null>
}
/>
)}
</PagerWithHeader>
@@ -386,14 +398,14 @@ interface FeedSectionProps {
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | null>
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
{feed, onScroll, headerHeight, isScrolledDown, scrollElRef},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
@@ -437,6 +449,7 @@ const AboutSection = observer(function AboutPageImpl({
headerHeight,
onToggleLiked,
onScroll,
scrollElRef,
}: {
feedOwnerDid: string
feedRkey: string
@@ -444,6 +457,7 @@ const AboutSection = observer(function AboutPageImpl({
headerHeight: number
onToggleLiked: () => void
onScroll: OnScrollCb
scrollElRef: React.MutableRefObject<ScrollView | null>
}) {
const pal = usePalette('default')
const {_} = useLingui()
@@ -454,8 +468,12 @@ const AboutSection = observer(function AboutPageImpl({
return (
<ScrollView
ref={scrollElRef}
scrollEventThrottle={1}
contentContainerStyle={{paddingTop: headerHeight}}
contentContainerStyle={{
paddingTop: headerHeight,
paddingBottom: Dimensions.get('window').height - headerHeight,
}}
onScroll={onScroll}>
<View
style={[
+16 -6
View File
@@ -175,18 +175,24 @@ export const ProfileListScreenInner = observer(
isHeaderReady={list.hasLoaded}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={feedSectionRef}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<AboutSection
ref={aboutSectionRef}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
list={list}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
@@ -223,9 +229,12 @@ export const ProfileListScreenInner = observer(
items={SECTION_TITLES_MOD}
isHeaderReady={list.hasLoaded}
renderHeader={renderHeader}>
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<AboutSection
list={list}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
isCurateList={list.isCuratelist}
@@ -557,14 +566,14 @@ interface FeedSectionProps {
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | null>
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
{feed, scrollElRef, onScroll, headerHeight, isScrolledDown},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
@@ -611,6 +620,7 @@ interface AboutSectionProps {
onScroll: OnScrollCb
headerHeight: number
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | null>
}
const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
function AboutSectionImpl(
@@ -624,13 +634,13 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
onScroll,
headerHeight,
isScrolledDown,
scrollElRef,
},
ref,
) {
const pal = usePalette('default')
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})