Refactor the scroll-to-top UX

zio/stable
Paul Frazee 2023-05-24 18:46:27 -05:00
parent 9673225f78
commit 4e1876fe85
9 changed files with 102 additions and 100 deletions

View File

@ -1,28 +1,50 @@
import {useState} from 'react' import {useState, useCallback, useRef} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {RootStoreModel} from 'state/index' import {RootStoreModel} from 'state/index'
import {s} from 'lib/styles'
export type onMomentumScrollEndCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export type OnScrollCb = ( export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>, event: NativeSyntheticEvent<NativeScrollEvent>,
) => void ) => void
export type ResetCb = () => void
export function useOnMainScroll(store: RootStoreModel) { export function useOnMainScroll(
let [lastY, setLastY] = useState(0) store: RootStoreModel,
let isMinimal = store.shell.minimalShellMode ): [OnScrollCb, boolean, ResetCb] {
return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) { let lastY = useRef(0)
const y = event.nativeEvent.contentOffset.y let [isScrolledDown, setIsScrolledDown] = useState(false)
const dy = y - (lastY || 0) return [
setLastY(y) useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y
const dy = y - (lastY.current || 0)
lastY.current = y
if (!isMinimal && y > 10 && dy > 10) { if (!store.shell.minimalShellMode && y > 10 && dy > 10) {
store.shell.setMinimalShellMode(true) store.shell.setMinimalShellMode(true)
isMinimal = true } else if (store.shell.minimalShellMode && (y <= 10 || dy < -10)) {
} else if (isMinimal && (y <= 10 || dy < -10)) { store.shell.setMinimalShellMode(false)
}
if (
!isScrolledDown &&
event.nativeEvent.contentOffset.y > s.window.height
) {
setIsScrolledDown(true)
} else if (
isScrolledDown &&
event.nativeEvent.contentOffset.y < s.window.height
) {
setIsScrolledDown(false)
}
},
[store, isScrolledDown],
),
isScrolledDown,
useCallback(() => {
setIsScrolledDown(false)
store.shell.setMinimalShellMode(false) store.shell.setMinimalShellMode(false)
isMinimal = false lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
} }, [store, setIsScrolledDown]),
} ]
} }

View File

@ -154,6 +154,7 @@ export const Feed = observer(function Feed({
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={0.6} onEndReachedThreshold={0.6}
onScroll={onScroll} onScroll={onScroll}
scrollEventThrottle={100}
contentContainerStyle={s.contentContainer} contentContainerStyle={s.contentContainer}
/> />
) : null} ) : null}

View File

@ -14,7 +14,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {PostsFeedModel} from 'state/models/feeds/posts' import {PostsFeedModel} from 'state/models/feeds/posts'
import {FeedSlice} from './FeedSlice' import {FeedSlice} from './FeedSlice'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll' import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -47,7 +47,6 @@ export const Feed = observer(function Feed({
onPressTryAgain?: () => void onPressTryAgain?: () => void
onScroll?: OnScrollCb onScroll?: OnScrollCb
scrollEventThrottle?: number scrollEventThrottle?: number
onMomentumScrollEnd?: onMomentumScrollEndCb
renderEmptyState?: () => JSX.Element renderEmptyState?: () => JSX.Element
testID?: string testID?: string
headerOffset?: number headerOffset?: number

View File

@ -47,7 +47,7 @@ const styles = StyleSheet.create({
outer: { outer: {
position: 'absolute', position: 'absolute',
zIndex: 1, zIndex: 1,
right: 28, right: 24,
bottom: 94, bottom: 94,
width: 60, width: 60,
height: 60, height: 60,

View File

@ -1,23 +1,25 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity} from 'react-native' import {StyleSheet, TouchableOpacity} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Text} from '../text/Text'
import {colors, gradients} from 'lib/styles'
import {clamp} from 'lodash' import {clamp} from 'lodash'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
export const LoadLatestBtn = observer( export const LoadLatestBtn = observer(
({onPress, label}: {onPress: () => void; label: string}) => { ({onPress, label}: {onPress: () => void; label: string}) => {
const store = useStores() const store = useStores()
const pal = usePalette('default')
const safeAreaInsets = useSafeAreaInsets() const safeAreaInsets = useSafeAreaInsets()
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.loadLatest, styles.loadLatest,
pal.borderDark,
pal.view,
!store.shell.minimalShellMode && { !store.shell.minimalShellMode && {
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
}, },
@ -26,16 +28,8 @@ export const LoadLatestBtn = observer(
hitSlop={HITSLOP} hitSlop={HITSLOP}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={label}> accessibilityHint="">
<LinearGradient <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.loadLatestInner}>
<Text type="md-bold" style={styles.loadLatestText}>
{label}
</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
) )
}, },
@ -44,19 +38,14 @@ export const LoadLatestBtn = observer(
const styles = StyleSheet.create({ const styles = StyleSheet.create({
loadLatest: { loadLatest: {
position: 'absolute', position: 'absolute',
left: 20, left: 18,
bottom: 35, bottom: 35,
shadowColor: '#000', borderWidth: 1,
shadowOpacity: 0.3, width: 52,
shadowOffset: {width: 0, height: 1}, height: 52,
}, borderRadius: 26,
loadLatestInner: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 14, alignItems: 'center',
paddingVertical: 10, justifyContent: 'center',
borderRadius: 30,
},
loadLatestText: {
color: colors.white,
}, },
}) })

View File

@ -20,13 +20,13 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
import {Button} from 'view/com/util/forms/Button' import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {isDesktopWeb, isWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics' import {Haptics} from 'lib/haptics'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
export const CustomFeedScreen = withAuthRequired( export const CustomFeedScreen = withAuthRequired(
@ -48,7 +48,8 @@ export const CustomFeedScreen = withAuthRequired(
return feed return feed
}, [store, uri]) }, [store, uri])
const isPinned = store.me.savedFeeds.isPinned(uri) const isPinned = store.me.savedFeeds.isPinned(uri)
const [allowScrollToTop, setAllowScrollToTop] = useState(false) const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
useSetTitle(currentFeed?.displayName) useSetTitle(currentFeed?.displayName)
const onToggleSaved = React.useCallback(async () => { const onToggleSaved = React.useCallback(async () => {
@ -66,6 +67,7 @@ export const CustomFeedScreen = withAuthRequired(
store.log.error('Failed up update feeds', {err}) store.log.error('Failed up update feeds', {err})
} }
}, [store, currentFeed]) }, [store, currentFeed])
const onToggleLiked = React.useCallback(async () => { const onToggleLiked = React.useCallback(async () => {
Haptics.default() Haptics.default()
try { try {
@ -81,6 +83,7 @@ export const CustomFeedScreen = withAuthRequired(
store.log.error('Failed up toggle like', {err}) store.log.error('Failed up toggle like', {err})
} }
}, [store, currentFeed]) }, [store, currentFeed])
const onTogglePinned = React.useCallback(async () => { const onTogglePinned = React.useCallback(async () => {
Haptics.default() Haptics.default()
store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => { store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => {
@ -88,11 +91,17 @@ export const CustomFeedScreen = withAuthRequired(
store.log.error('Failed to toggle pinned feed', {e}) store.log.error('Failed to toggle pinned feed', {e})
}) })
}, [store, currentFeed]) }, [store, currentFeed])
const onPressShare = React.useCallback(() => { const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${name}/feed/${rkey}`) const url = toShareUrl(`/profile/${name}/feed/${rkey}`)
shareUrl(url) shareUrl(url)
}, [name, rkey]) }, [name, rkey])
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const renderHeaderBtns = React.useCallback(() => { const renderHeaderBtns = React.useCallback(() => {
return ( return (
<View style={styles.headerBtns}> <View style={styles.headerBtns}>
@ -220,15 +229,17 @@ export const CustomFeedScreen = withAuthRequired(
</Text> </Text>
) : null} ) : null}
<View style={styles.headerDetailsFooter}> <View style={styles.headerDetailsFooter}>
<TextLink {currentFeed ? (
type="md-medium" <TextLink
style={pal.textLight} type="md-medium"
href={`/profile/${name}/feed/${rkey}/liked-by`} style={pal.textLight}
text={`Liked by ${currentFeed?.data.likeCount} ${pluralize( href={`/profile/${name}/feed/${rkey}/liked-by`}
currentFeed?.data.likeCount || 0, text={`Liked by ${currentFeed.data.likeCount} ${pluralize(
'user', currentFeed?.data.likeCount || 0,
)}`} 'user',
/> )}`}
/>
) : null}
<Button <Button
type={'default'} type={'default'}
accessibilityLabel={ accessibilityLabel={
@ -267,46 +278,19 @@ export const CustomFeedScreen = withAuthRequired(
onTogglePinned, onTogglePinned,
]) ])
const onMomentumScrollEnd: onMomentumScrollEndCb = React.useCallback(
event => {
console.log('onMomentumScrollEnd')
if (event.nativeEvent.contentOffset.y > s.window.height * 3) {
setAllowScrollToTop(true)
} else {
setAllowScrollToTop(false)
}
},
[],
)
const onScroll: OnScrollCb = React.useCallback(event => {
// since onMomentumScrollEnd is not supported in react-native-web, we have to use onScroll which fires more often so is not desirable on mobile
if (isWeb) {
if (event.nativeEvent.contentOffset.y > s.window.height * 2) {
setAllowScrollToTop(true)
} else {
setAllowScrollToTop(false)
}
}
}, [])
return ( return (
<View style={s.hContentRegion}> <View style={s.hContentRegion}>
<ViewHeader title="" renderButton={renderHeaderBtns} /> <ViewHeader title="" renderButton={renderHeaderBtns} />
<Feed <Feed
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
feed={algoFeed} feed={algoFeed}
onMomentumScrollEnd={onMomentumScrollEnd} onScroll={onMainScroll}
onScroll={onScroll} // same logic as onMomentumScrollEnd but for web scrollEventThrottle={100}
ListHeaderComponent={renderListHeaderComponent} ListHeaderComponent={renderListHeaderComponent}
extraData={[uri, isPinned]} extraData={[uri, isPinned]}
/> />
{allowScrollToTop ? ( {isScrolledDown ? (
<LoadLatestBtn <LoadLatestBtn onPress={onScrollToTop} label="Scroll to top" />
onPress={() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
}}
label="Scroll to top"
/>
) : null} ) : null}
</View> </View>
) )

View File

@ -150,7 +150,8 @@ const FeedPage = observer(
renderEmptyState?: () => JSX.Element renderEmptyState?: () => JSX.Element
}) => { }) => {
const store = useStores() const store = useStores()
const onMainScroll = useOnMainScroll(store) const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const scrollElRef = React.useRef<FlatList>(null) const scrollElRef = React.useRef<FlatList>(null)
const {appState} = useAppState({ const {appState} = useAppState({
@ -178,12 +179,13 @@ const FeedPage = observer(
const scrollToTop = React.useCallback(() => { const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -HEADER_OFFSET}) scrollElRef.current?.scrollToOffset({offset: -HEADER_OFFSET})
}, [scrollElRef]) resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onSoftReset = React.useCallback(() => { const onSoftReset = React.useCallback(() => {
if (isPageFocused) { if (isPageFocused) {
feed.refresh()
scrollToTop() scrollToTop()
feed.refresh()
} }
}, [isPageFocused, scrollToTop, feed]) }, [isPageFocused, scrollToTop, feed])
@ -254,10 +256,11 @@ const FeedPage = observer(
showPostFollowBtn showPostFollowBtn
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll} onScroll={onMainScroll}
scrollEventThrottle={100}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
headerOffset={HEADER_OFFSET} headerOffset={HEADER_OFFSET}
/> />
{feed.hasNewLatest && !feed.isRefreshing && ( {isScrolledDown && (
<LoadLatestBtn onPress={onPressLoadLatest} label="Load new posts" /> <LoadLatestBtn onPress={onPressLoadLatest} label="Load new posts" />
)} )}
<FAB <FAB

View File

@ -25,7 +25,8 @@ type Props = NativeStackScreenProps<
export const NotificationsScreen = withAuthRequired( export const NotificationsScreen = withAuthRequired(
observer(({}: Props) => { observer(({}: Props) => {
const store = useStores() const store = useStores()
const onMainScroll = useOnMainScroll(store) const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null) const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics() const {screen} = useAnalytics()
@ -37,7 +38,8 @@ export const NotificationsScreen = withAuthRequired(
const scrollToTop = React.useCallback(() => { const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0}) scrollElRef.current?.scrollToOffset({offset: 0})
}, [scrollElRef]) resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onPressLoadLatest = React.useCallback(() => { const onPressLoadLatest = React.useCallback(() => {
scrollToTop() scrollToTop()
@ -96,10 +98,12 @@ export const NotificationsScreen = withAuthRequired(
onScroll={onMainScroll} onScroll={onMainScroll}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
/> />
{store.me.notifications.hasNewLatest && {isScrolledDown && (
!store.me.notifications.isRefreshing && ( <LoadLatestBtn
<LoadLatestBtn onPress={onPressLoadLatest} label="Load new notifications" /> onPress={onPressLoadLatest}
)} label="Load new notifications"
/>
)}
</View> </View>
) )
}), }),

View File

@ -35,7 +35,7 @@ export const SearchScreen = withAuthRequired(
const store = useStores() const store = useStores()
const scrollViewRef = React.useRef<ScrollView>(null) const scrollViewRef = React.useRef<ScrollView>(null)
const flatListRef = React.useRef<FlatList>(null) const flatListRef = React.useRef<FlatList>(null)
const onMainScroll = useOnMainScroll(store) const [onMainScroll] = useOnMainScroll(store)
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('') const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteModel>( const autocompleteView = React.useMemo<UserAutocompleteModel>(