Add scroll-to-top for all screens (#177)

This commit is contained in:
Paul Frazee
2023-02-09 16:10:22 -06:00
committed by GitHub
parent b84c88cd21
commit 70b0dc070a
8 changed files with 60 additions and 22 deletions
+1
View File
@@ -658,6 +658,7 @@ export const mockedRootStore = {
profiles: mockedProfilesStore, profiles: mockedProfilesStore,
linkMetas: mockedLinkMetasStore, linkMetas: mockedLinkMetasStore,
log: mockedLogStore, log: mockedLogStore,
onScreenSoftReset: jest.fn().mockReturnValue({remove: jest.fn()}),
} as RootStoreModel } as RootStoreModel
export const mockedProfileUiStore = { export const mockedProfileUiStore = {
+10
View File
@@ -166,6 +166,16 @@ export class RootStoreModel {
DeviceEventEmitter.emit('navigation') DeviceEventEmitter.emit('navigation')
} }
// a "soft reset" typically means scrolling to top and loading latest
// but it can depend on the screen
onScreenSoftReset(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('screen-soft-reset', handler)
}
emitScreenSoftReset() {
DeviceEventEmitter.emit('screen-soft-reset')
}
// background fetch // background fetch
// = // =
// - we use this to poll for unread notifications, which is not "ideal" behavior but // - we use this to poll for unread notifications, which is not "ideal" behavior but
+4 -1
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FlatList, StyleSheet, View} from 'react-native' import {FlatList, StyleSheet, View} from 'react-native'
import {NotificationsViewModel} from '../../../state/models/notifications-view' import {NotificationsViewModel} from '../../../state/models/notifications-view'
@@ -13,10 +13,12 @@ const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = observer(function Feed({ export const Feed = observer(function Feed({
view, view,
scrollElRef,
onPressTryAgain, onPressTryAgain,
onScroll, onScroll,
}: { }: {
view: NotificationsViewModel view: NotificationsViewModel
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void onPressTryAgain?: () => void
onScroll?: OnScrollCb onScroll?: OnScrollCb
}) { }) {
@@ -66,6 +68,7 @@ export const Feed = observer(function Feed({
)} )}
{data && ( {data && (
<FlatList <FlatList
ref={scrollElRef}
data={data} data={data}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
renderItem={renderItem} renderItem={renderItem}
+1 -3
View File
@@ -1,5 +1,4 @@
import React, {MutableRefObject} from 'react' import React from 'react'
import {FlatList} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core' import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Home} from './screens/Home' import {Home} from './screens/Home'
import {Contacts} from './screens/Contacts' import {Contacts} from './screens/Contacts'
@@ -21,7 +20,6 @@ export type ScreenParams = {
navIdx: string navIdx: string
params: Record<string, any> params: Record<string, any>
visible: boolean visible: boolean
scrollElRef?: MutableRefObject<FlatList<any> | undefined>
} }
export type Route = [React.FC<ScreenParams>, string, IconProp, RegExp] export type Route = [React.FC<ScreenParams>, string, IconProp, RegExp]
export type MatchResult = { export type MatchResult = {
+9 -6
View File
@@ -1,5 +1,5 @@
import React, {useEffect} from 'react' import React, {useEffect} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FlatList, StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook' import useAppState from 'react-native-appstate-hook'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
@@ -17,15 +17,12 @@ import {useAnalytics} from '@segment/analytics-react-native'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
export const Home = observer(function Home({ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
navIdx,
visible,
scrollElRef,
}: ScreenParams) {
const store = useStores() const store = useStores()
const onMainScroll = useOnMainScroll(store) const onMainScroll = useOnMainScroll(store)
const {track} = useAnalytics() const {track} = useAnalytics()
const safeAreaInsets = useSafeAreaInsets() const safeAreaInsets = useSafeAreaInsets()
const scrollElRef = React.useRef<FlatList>(null)
const [wasVisible, setWasVisible] = React.useState<boolean>(false) const [wasVisible, setWasVisible] = React.useState<boolean>(false)
const {appState} = useAppState({ const {appState} = useAppState({
onForeground: () => doPoll(true), onForeground: () => doPoll(true),
@@ -45,11 +42,17 @@ export const Home = observer(function Home({
[appState, visible, store], [appState, visible, store],
) )
const onSoftReset = () => {
scrollElRef.current?.scrollToOffset({offset: 0})
}
useEffect(() => { useEffect(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const feedCleanup = store.me.mainFeed.registerListeners() const feedCleanup = store.me.mainFeed.registerListeners()
const pollInterval = setInterval(() => doPoll(), 15e3) const pollInterval = setInterval(() => doPoll(), 15e3)
const cleanup = () => { const cleanup = () => {
clearInterval(pollInterval) clearInterval(pollInterval)
softResetSub.remove()
feedCleanup() feedCleanup()
} }
+13 -2
View File
@@ -1,5 +1,5 @@
import React, {useEffect} from 'react' import React, {useEffect} from 'react'
import {View} from 'react-native' import {FlatList, View} from 'react-native'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed' import {Feed} from '../com/notifications/Feed'
import {useStores} from '../../state' import {useStores} from '../../state'
@@ -11,21 +11,31 @@ import {useAnalytics} from '@segment/analytics-react-native'
export const Notifications = ({navIdx, visible}: ScreenParams) => { export const Notifications = ({navIdx, visible}: ScreenParams) => {
const store = useStores() const store = useStores()
const onMainScroll = useOnMainScroll(store) const onMainScroll = useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics() const {screen} = useAnalytics()
useEffect(() => { useEffect(() => {
screen('Notifications') screen('Notifications')
}, [screen]) }, [screen])
const onSoftReset = () => {
scrollElRef.current?.scrollToOffset({offset: 0})
}
useEffect(() => { useEffect(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
if (!visible) { if (!visible) {
return return cleanup
} }
store.log.debug('Updating notifications feed') store.log.debug('Updating notifications feed')
store.me.notifications.update().then(() => { store.me.notifications.update().then(() => {
store.me.notifications.updateReadState() store.me.notifications.updateReadState()
}) })
store.nav.setTitle(navIdx, 'Notifications') store.nav.setTitle(navIdx, 'Notifications')
return cleanup
}, [visible, store, navIdx]) }, [visible, store, navIdx])
const onPressTryAgain = () => { const onPressTryAgain = () => {
@@ -39,6 +49,7 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => {
view={store.me.notifications} view={store.me.notifications}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll} onScroll={onMainScroll}
scrollElRef={scrollElRef}
/> />
</View> </View>
) )
+12 -1
View File
@@ -29,6 +29,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {track} = useAnalytics() const {track} = useAnalytics()
const scrollElRef = React.useRef<ScrollView>(null)
const textInput = React.useRef<TextInput>(null) const textInput = React.useRef<TextInput>(null)
const [lastRenderTime, setRenderTime] = React.useState<number>(0) // used to trigger reloads const [lastRenderTime, setRenderTime] = React.useState<number>(0) // used to trigger reloads
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
@@ -39,7 +40,16 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
) )
const {name} = params const {name} = params
const onSoftReset = () => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
}
React.useEffect(() => { React.useEffect(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
if (visible) { if (visible) {
const now = Date.now() const now = Date.now()
if (lastRenderTime - now > FIVE_MIN) { if (lastRenderTime - now > FIVE_MIN) {
@@ -49,6 +59,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
autocompleteView.setup() autocompleteView.setup()
store.nav.setTitle(navIdx, 'Search') store.nav.setTitle(navIdx, 'Search')
} }
return cleanup
}, [store, visible, name, navIdx, autocompleteView, lastRenderTime]) }, [store, visible, name, navIdx, autocompleteView, lastRenderTime])
const onPressMenu = () => { const onPressMenu = () => {
@@ -143,7 +154,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
</Text> </Text>
</View> </View>
) : ( ) : (
<ScrollView onScroll={Keyboard.dismiss}> <ScrollView onScroll={Keyboard.dismiss} ref={scrollElRef}>
<WhoToFollow key={`wtf-${lastRenderTime}`} /> <WhoToFollow key={`wtf-${lastRenderTime}`} />
<SuggestedPosts key={`sp-${lastRenderTime}`} /> <SuggestedPosts key={`sp-${lastRenderTime}`} />
<View style={s.footerSpacer} /> <View style={s.footerSpacer} />
+10 -9
View File
@@ -1,9 +1,8 @@
import React, {useState, useEffect, useRef} from 'react' import React, {useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import { import {
Animated, Animated,
Easing, Easing,
FlatList,
GestureResponderEvent, GestureResponderEvent,
StatusBar, StatusBar,
StyleSheet, StyleSheet,
@@ -148,7 +147,6 @@ export const MobileShell: React.FC = observer(() => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const [isTabsSelectorActive, setTabsSelectorActive] = useState(false) const [isTabsSelectorActive, setTabsSelectorActive] = useState(false)
const scrollElRef = useRef<FlatList | undefined>()
const winDim = useWindowDimensions() const winDim = useWindowDimensions()
const [menuSwipingDirection, setMenuSwipingDirection] = useState(0) const [menuSwipingDirection, setMenuSwipingDirection] = useState(0)
const swipeGestureInterp = useAnimatedValue(0) const swipeGestureInterp = useAnimatedValue(0)
@@ -164,8 +162,8 @@ export const MobileShell: React.FC = observer(() => {
const onPressHome = () => { const onPressHome = () => {
track('MobileShell:HomeButtonPressed') track('MobileShell:HomeButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) { if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) {
if (store.nav.tab.current.url === '/') { if (!store.nav.tab.canGoBack) {
scrollElRef.current?.scrollToOffset({offset: 0}) store.emitScreenSoftReset()
} else { } else {
store.nav.tab.fixedTabReset() store.nav.tab.fixedTabReset()
} }
@@ -179,8 +177,8 @@ export const MobileShell: React.FC = observer(() => {
const onPressSearch = () => { const onPressSearch = () => {
track('MobileShell:SearchButtonPressed') track('MobileShell:SearchButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) { if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) {
if (store.nav.tab.current.url === '/') { if (!store.nav.tab.canGoBack) {
scrollElRef.current?.scrollToOffset({offset: 0}) store.emitScreenSoftReset()
} else { } else {
store.nav.tab.fixedTabReset() store.nav.tab.fixedTabReset()
} }
@@ -194,7 +192,11 @@ export const MobileShell: React.FC = observer(() => {
const onPressNotifications = () => { const onPressNotifications = () => {
track('MobileShell:NotificationsButtonPressed') track('MobileShell:NotificationsButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) { if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) {
store.nav.tab.fixedTabReset() if (!store.nav.tab.canGoBack) {
store.emitScreenSoftReset()
} else {
store.nav.tab.fixedTabReset()
}
} else { } else {
store.nav.switchTo(TabPurpose.Notifs, false) store.nav.switchTo(TabPurpose.Notifs, false)
if (store.nav.tab.index === 0) { if (store.nav.tab.index === 0) {
@@ -444,7 +446,6 @@ export const MobileShell: React.FC = observer(() => {
params={params} params={params}
navIdx={navIdx} navIdx={navIdx}
visible={current} visible={current}
scrollElRef={current ? scrollElRef : undefined}
/> />
</ErrorBoundary> </ErrorBoundary>
</Animated.View> </Animated.View>